mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
perf(api): rank Mine inbox candidates from the participation set (#20413)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Mine inbox reads `GET /api/companies/:companyId/inbox/mine`. Its
main query is `issueService.list` with "touched by me", "not archived by
me", six statuses including `done`, and `LIMIT 100` by canonical last
activity.
> - That query evaluates `touchedByUserCondition` (a five-way OR of
EXISTS) on every status-matching issue in the company. Because `done` is
included, the scan grows with company history, not with the user's work.
> - The unread badge already solved the same problem with a UNION of
indexed participation sources (#17973). The list path did not.
> - This pull request makes the Mine list rank page ids from that same
participation UNION, then load full rows (with the expensive select-list
subqueries) only for the 100 selected ids.
> - The benefit is that the main Mine query scales with the user's
participation instead of the company's issue count. Results stay
identical, and a new test proves it against the original scan.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
The latency of the Mine inbox snapshot, `GET
/api/companies/:companyId/inbox/mine`.
**Subsystem affected**
Server: issue list service (`server/src/services/issues.ts`) and the
Mine route.
**Current behavior**
Measured on 2026-11-09: 280–570 ms server time on a dev instance and
1.9–3.2 s on staging. The list query scans every status-matching issue
(28k of 30k in the fixture below) to test participation, then sorts the
touched ones.
**Proposed behavior**
The list query reads only the user's participation set and computes the
row projection only for the page.
**Reason and benefit**
Inbox opens are frequent. The old cost grows with every completed task
in the company.
Refs #17973 (same UNION for the unread badge).
## What Changed
- Extracted the participation UNION from `unreadInboxIssueIds` into
`mineParticipationIssueIds()`. The unread badge uses it unchanged.
- New `IssueFilters.touchedCandidates: "participation"`. When it is set
with `touchedByUserId` (and there is no search and no
`oneChildPerParent`), `list`:
1. replaces `touchedByUserCondition` with `issues.id IN (<participation
UNION>)`. Each UNION member is one OR branch of the old condition, so
the set is the same;
2. runs a narrow `candidates` query (`SELECT id … WHERE <all other
filters> ORDER BY <same order> LIMIT/OFFSET`);
3. loads the full projection for only those ids (re-checking company,
visibility and the read condition), and restores the phase 1 order.
- Only the Mine route sets the flag. Every other `svc.list` caller keeps
the old SQL.
- A new Server-Timing phase, `inbox_issues_candidates`.
- `server/src/__tests__/inbox-mine-candidates.test.ts`: a
result-equivalence test.
- `scripts/benchmark-inbox-mine.ts`: a local benchmark that seeds a
large fixture, compares full payloads, and records `EXPLAIN (ANALYZE,
BUFFERS)` per query.
### Not changed, with evidence
- **Recomputed comment/activity maxima** (`lastActivityStatsForIssues`):
0.9 + 0.2 ms per call in the plans. Not worth a second code path.
- **Touched condition inside `userCommentStatsForIssues`**: 1.4–2.1 ms.
Not changed.
- **`liveDescendantCountMapForIssues`**: 12 ms at 30k issues and 40 ms
at 90k. It now costs about as much as the list query. It scans all live
runs and hashes all company issues. It is company-wide work, outside the
candidate selection, so I left it for a separate change.
## Verification
### Correctness
`inbox-mine-candidates.test.ts` runs the old scan and the new path on
the same data. It asserts equal ordered ids and equal full JSON
payloads. Fixture: 170+ touched issues (more than one page, with tied
timestamps) plus these edge cases:
- included: created by me, assigned to me only, user activity, my direct
comment, `done`, pending request addressed to me, archived then
re-activated by a newer outside comment, read state with a later agent
comment, a live child (live descendant count);
- excluded: archived with no newer activity, mention-only, read audit
only (`issue.read_marked`), uppercase entity id, channel-sourced
comment, agent-created with my user id, `cancelled`, hidden, harness,
plugin operation, private to another user (`issueReadSqlCondition`),
answered request, untouched issues, another company.
It also compares other shapes: no archive filter, `status=done` with the
full projection, `offset`/`limit`, ascending order, unrestricted read
condition, a second user, and a user with no participation. Mutation
check: removing the comment source from the UNION makes the test fail.
The benchmark also asserts identical full payloads for every sample of
both arms.
Focused suites (local): `inbox-mine-candidates`, `inbox-mine-routes`,
`authorization-privacy-query`, `issues-unread-inbox`,
`inbox-archive-routes`, `issues-assignee-unread`,
`issues-creation-visibility` — all pass.
### Performance (local SQL measurements)
Command: `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-inbox-mine.ts --baseline-ref=18c3f4ef7b
--issues=<N> [--touch-density=<d>]`. Baseline = 18c3f4ef7b, the #20408
head with the same timing hooks. Embedded PostgreSQL 18.1,
`shared_buffers` 128MB, `work_mem` 4MB, JIT on (not triggered), pool of
8. Alternating order, 2 warmups. Fixture per run: 12 activity rows and 6
comments per issue, 55% `done`, archives, read states, pending requests,
150 live runs.
| Fixture | Arm | `svc.list` median (min–max) | List query phase | Main
query plan |
| --- | --- | --- | --- | --- |
| 30k issues, user touched ~1.1k | before | 47.8 ms (41–66) | 30.0 ms |
28.1 ms exec, 33.7k buffers |
| | after | 39.4 ms (36–42) | 19.4 + 4.2 ms | 16.0 ms exec, 22.1k
buffers (+1.8 ms rows) |
| 90k issues, user touched ~3.4k | before | 124.4 ms (113–180) | 71.1 ms
| 70.8 ms exec, 103.9k buffers |
| | after | 102.9 ms (88–118) | 43.1 + 3.8 ms | 41.7 ms exec, 72.7k
buffers |
| 30k issues, user touched ~11k (dense) | before | 135.0 ms | 119.2 ms |
112.6 ms exec, 128k buffers |
| | after | 136.6 ms | 108.7 + 4.9 ms | 101.9 ms exec, 144k buffers |
- The old plan scanned 27,993 status-matching issues to test
participation. The new plan reads the participation UNION (1,155 ids at
30k; 3,448 at 90k) and probes `issues_pkey`.
- **Neutral case:** when a user has touched most issues (dense row), the
UNION does not shrink the set. The per-candidate privacy check, archive
visibility and activity maxima dominate. I report this as neutral, not
as a gain.
- On a loaded machine (a parallel test run), the same 30k fixture took
129 ms before and 55 ms after. Planning time of the old query rose to 27
ms there. I report the quiet-machine numbers above as the result.
- I did not get query evidence from staging's database. This session had
no Neon or CloudOps access (no `neonctl`, no stack credentials), so all
numbers are local. #20408 adds the per-phase header, which can confirm
the effect on staging after deploy.
### Pre-PR checks
- `tsc --noEmit -p server`: no errors in the changed files.
- `pnpm verify --explain` selects 2,417 of 3,204 test files for this
change, because `services/issues.ts` is imported almost everywhere. I
did not repeat that full suite locally (AGENTS.md §7); required CI
supplies it.
## Risks
- **Equivalence depends on the UNION matching
`touchedByUserCondition`.** Each branch has a UNION member:
human-created by me, assigned to me, pending request addressed to me, my
participation activity (canonical lowercase UUID ids only, the same as
the old `= issues.id::text` join), and my direct human comments. The
equivalence test pins these. A future change to `touchedByUserCondition`
must also change the UNION. Both live next to each other, and the unread
badge already depends on the same equality.
- **Two reads instead of one.** A row can change between the candidate
query and the row query. The row query re-checks company, visibility and
read access, so a row that became unreadable is dropped. The page can
then have fewer than 100 rows for that one response.
- Scope: only the Mine route sets the flag. No migration.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, embedded PostgreSQL benchmarks). Standard
context window; extended thinking not used.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a2eb920cf
commit
326df52673
5 files changed
+452
-47
No files matched your search
@@ -0,0 +1,223 @@
|
||||
// Local SQL benchmark for the Mine inbox list (GET /api/companies/:id/inbox/mine).
|
||||
//
|
||||
// pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-inbox-mine.ts \
|
||||
// --baseline-ref=<pre-fix commit> [--issues=30000] [--activity-per-issue=12]
|
||||
// [--comments-per-issue=6] [--touch-density=1] [--samples=7] [--evidence-dir=<dir>]
|
||||
//
|
||||
// Seeds one disposable embedded-PostgreSQL company with tens of thousands of
|
||||
// issues (most of them `done`), comments, activity, archives, read states,
|
||||
// pending interactions and live runs, plus a user who touched a few hundred.
|
||||
// It then runs the baseline (from --baseline-ref) and the working-tree
|
||||
// `issueService.list` with the exact Mine filters, asserts the complete row
|
||||
// payloads are identical, and reports median timings and per-query
|
||||
// EXPLAIN (ANALYZE, BUFFERS) ranked by execution time.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createDb, applyPendingMigrations, startEmbeddedPostgresTestDatabase, type Db } from "../packages/db/src/index.js";
|
||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "../packages/shared/src/index.js";
|
||||
import { issueService, type IssueFilters } from "../server/src/services/issues.js";
|
||||
import { issueReadSqlCondition } from "../server/src/services/authorization.js";
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const git = (args: string[]) => execFileSync("git", args, { cwd: repositoryRoot, encoding: "utf8" });
|
||||
const arg = (name: string) => process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const baselineRef = arg("baseline-ref");
|
||||
assert.ok(baselineRef, "Pass a pinned pre-fix --baseline-ref=<commit>");
|
||||
const baselineRevision = git(["rev-parse", `${baselineRef}^{commit}`]).trim();
|
||||
const issueCount = Number(arg("issues") ?? 30000);
|
||||
const samples = Number(arg("samples") ?? 7);
|
||||
const activityPerIssue = Number(arg("activity-per-issue") ?? 12);
|
||||
const commentsPerIssue = Number(arg("comments-per-issue") ?? 6);
|
||||
// Participation density: 1 touches ~1.4k of 30k issues; 10 touches ~11k.
|
||||
const density = Number(arg("touch-density") ?? 1);
|
||||
const every = (modulus: number) => Math.max(2, Math.round(modulus / density));
|
||||
const evidenceDir = arg("evidence-dir");
|
||||
|
||||
// Baseline modules: the Mine list SQL lives in these two files. Every other
|
||||
// dependency is held constant at the working tree.
|
||||
const modulePaths = ["server/src/services/issues.ts", "server/src/services/issue-participation.ts"];
|
||||
const baselineDir = await fs.mkdtemp(path.join(repositoryRoot, "server/scripts/.inbox-mine-baseline-"));
|
||||
for (const modulePath of modulePaths) {
|
||||
const source = git(["show", `${baselineRevision}:${modulePath}`]).replace(/from "(\.{1,2}\/[^"]+)"/g, (_m, specifier: string) => {
|
||||
const resolved = fileURLToPath(new URL(specifier, pathToFileURL(path.join(repositoryRoot, modulePath))));
|
||||
return `from ${JSON.stringify(resolved.endsWith("/services/issue-participation.js") ? path.join(baselineDir, "issue-participation.ts") : resolved)}`;
|
||||
});
|
||||
await fs.writeFile(path.join(baselineDir, path.basename(modulePath)), source);
|
||||
}
|
||||
const baseline = await import(pathToFileURL(path.join(baselineDir, "issues.ts")).href) as typeof import("../server/src/services/issues.js");
|
||||
|
||||
type Captured = { sql: string; params: unknown[] };
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
||||
let db: Db | undefined;
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
try {
|
||||
database = await startEmbeddedPostgresTestDatabase("inbox-mine-benchmark-");
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
// A small pool, like the server's: the list issues enrichment reads in parallel.
|
||||
db = createDb(database.connectionString, { maxConnections: 8 });
|
||||
const client = db.$client as any;
|
||||
let capture: Captured[] | null = null;
|
||||
const unsafe = client.unsafe.bind(client);
|
||||
client.unsafe = (query: string, params?: unknown[], options?: unknown) => {
|
||||
if (capture && /^\s*(select|with)\b/i.test(query)) capture.push({ sql: query, params: params ?? [] });
|
||||
return unsafe(query, params, options);
|
||||
};
|
||||
await client.unsafe("set client_min_messages = warning");
|
||||
const [settings] = await client.unsafe("select version(), current_setting('shared_buffers') shared_buffers, current_setting('work_mem') work_mem, current_setting('jit') jit");
|
||||
|
||||
const companyId = randomUUID(), userId = "synthetic-mine-user", otherUserId = "synthetic-other-human";
|
||||
const seedStart = performance.now();
|
||||
await client.unsafe(`insert into companies(id,name,issue_prefix) values($1,'Synthetic Mine','MINE')`, [companyId]);
|
||||
const agentIds = [randomUUID(), randomUUID(), randomUUID()];
|
||||
for (const agentId of agentIds) await client.unsafe(`insert into agents(id,company_id,name,adapter_type,status) values($1,$2,$3,'process','active')`, [agentId, companyId, `Agent ${agentId.slice(0, 8)}`]);
|
||||
// h(n, salt) is a deterministic 0..99 bucket so the fixture is reproducible.
|
||||
await client.unsafe(`create or replace function bench_h(n int, salt text) returns int language sql immutable as $$ select abs(hashtext(n::text || salt)) % 100 $$`);
|
||||
await client.unsafe(`insert into issues(id,company_id,title,status,created_by_user_id,created_by_agent_id,assignee_user_id,origin_kind,hidden_at,harness_kind,completed_at,created_at,updated_at)
|
||||
select gen_random_uuid(),$1::uuid,'Synthetic '||n,
|
||||
case when bench_h(n,'s')<55 then 'done' when bench_h(n,'s')<60 then 'cancelled' when bench_h(n,'s')<70 then 'backlog'
|
||||
when bench_h(n,'s')<82 then 'todo' when bench_h(n,'s')<90 then 'in_progress' when bench_h(n,'s')<95 then 'in_review' else 'blocked' end,
|
||||
case when n%${every(75)}=0 then $2 when bench_h(n,'c')<30 then $3 else null end,
|
||||
case when n%${every(75)}<>0 and bench_h(n,'c')>=30 then ($4::uuid[])[1+n%3] else null end,
|
||||
case when n%${every(311)}=0 then $2 else null end,
|
||||
case when n%997=0 then 'plugin:demo:operation' else 'manual' end,
|
||||
case when n%1499=0 then '2026-01-01'::timestamptz else null end,
|
||||
case when n%1777=0 then 'synthetic' else null end,
|
||||
case when bench_h(n,'s')<55 then '2026-01-01'::timestamptz+n*interval '1 second' else null end,
|
||||
'2026-01-01'::timestamptz,'2026-01-01'::timestamptz+n*interval '1 second'
|
||||
from generate_series(1,$5::int) n`, [companyId, userId, otherUserId, agentIds, issueCount]);
|
||||
await client.unsafe(`create table bench_fixture_issues as select id, row_number() over (order by updated_at)::int n, status from issues where company_id=$1`, [companyId]);
|
||||
await client.unsafe(`update issues i set parent_id=p.id from bench_fixture_issues c join bench_fixture_issues p on p.n=c.n/2 where i.id=c.id and c.n>100 and bench_h(c.n,'p')<35`);
|
||||
// Agent comments on every issue, an outside human on some, and the user's own direct comments.
|
||||
await client.unsafe(`insert into issue_comments(company_id,issue_id,author_agent_id,body,created_at)
|
||||
select $1::uuid,f.id,($2::uuid[])[1+k%3],'Agent progress '||f.n||'/'||k,'2026-02-01'::timestamptz+(f.n*$3::int+k)*interval '1 second'
|
||||
from bench_fixture_issues f cross join generate_series(0,$3::int-1) k`, [companyId, agentIds, commentsPerIssue]);
|
||||
await client.unsafe(`insert into issue_comments(company_id,issue_id,author_user_id,body,created_at)
|
||||
select $1::uuid,id,$2,'Outside human note','2026-03-01'::timestamptz+n*interval '1 second' from bench_fixture_issues where n%7=0`, [companyId, otherUserId]);
|
||||
await client.unsafe(`insert into issue_comments(company_id,issue_id,author_user_id,body,created_at)
|
||||
select $1::uuid,id,$2,'My note','2026-01-15'::timestamptz+n*interval '1 second' from bench_fixture_issues where n%${every(97)}=0`, [companyId, userId]);
|
||||
// Mention-only: the user is named in a comment but never participated.
|
||||
await client.unsafe(`insert into issue_comments(company_id,issue_id,author_user_id,body,created_at)
|
||||
select $1::uuid,id,$2,'@synthetic-mine-user can you look?','2026-03-02'::timestamptz+n*interval '1 second' from bench_fixture_issues where n%113=0`, [companyId, otherUserId]);
|
||||
await client.unsafe(`insert into activity_log(company_id,entity_type,entity_id,actor_type,actor_id,action,details,created_at)
|
||||
select $1::uuid,'issue',f.id::text,'agent',($2::uuid[])[1+k%3]::text,
|
||||
case when k%2=0 then 'issue.updated' else 'issue.comment_added' end,
|
||||
jsonb_build_object('status',f.status,'_previous',jsonb_build_object('status','todo')),
|
||||
'2026-01-20'::timestamptz+(f.n*$3::int+k)*interval '1 second'
|
||||
from bench_fixture_issues f cross join generate_series(0,$3::int-1) k`, [companyId, agentIds, activityPerIssue]);
|
||||
await client.unsafe(`insert into activity_log(company_id,entity_type,entity_id,actor_type,actor_id,action,details,created_at)
|
||||
select $1::uuid,'issue',id::text,'user',$2,'issue.updated','{}'::jsonb,'2026-01-25'::timestamptz+n*interval '1 second' from bench_fixture_issues where n%${every(89)}=0`, [companyId, userId]);
|
||||
// Reads are audited but are not participation.
|
||||
await client.unsafe(`insert into activity_log(company_id,entity_type,entity_id,actor_type,actor_id,action,details,created_at)
|
||||
select $1::uuid,'issue',id::text,'user',$2,'issue.read_marked','{}'::jsonb,'2026-01-26'::timestamptz+n*interval '1 second' from bench_fixture_issues where n%13=0`, [companyId, userId]);
|
||||
await client.unsafe(`insert into activity_log(company_id,entity_type,entity_id,actor_type,actor_id,action,details,created_at)
|
||||
select $1::uuid,'heartbeat_run',gen_random_uuid()::text,'agent',($2::uuid[])[1],'heartbeat.run.created','{}'::jsonb,'2026-01-01'::timestamptz+n*interval '1 second' from generate_series(1,$3::int) n`, [companyId, agentIds, issueCount * 2]);
|
||||
// Archives: some stay archived, some are reactivated by later outside comments (n%7=0).
|
||||
await client.unsafe(`insert into issue_inbox_archives(company_id,issue_id,user_id,archived_at)
|
||||
select $1::uuid,id,$2,'2026-02-15'::timestamptz from bench_fixture_issues where n%3=0 and (n%${every(75)}=0 or n%${every(89)}=0 or n%${every(97)}=0 or n%${every(311)}=0)`, [companyId, userId]);
|
||||
await client.unsafe(`insert into issue_read_states(company_id,issue_id,user_id,last_read_at)
|
||||
select $1::uuid,id,$2,'2026-02-20'::timestamptz from bench_fixture_issues where n%40=0`, [companyId, userId]);
|
||||
await client.unsafe(`insert into issue_thread_interactions(company_id,issue_id,kind,status,addressee_user_id,payload,created_at)
|
||||
select $1::uuid,id,'request_confirmation','pending',$2,'{"version":1,"prompt":"Confirm?"}'::jsonb,'2026-03-05'::timestamptz+n*interval '1 second' from bench_fixture_issues where n%503=0`, [companyId, userId]);
|
||||
await client.unsafe(`insert into heartbeat_runs(id,company_id,agent_id,status,context_snapshot)
|
||||
select gen_random_uuid(),$1::uuid,($2::uuid[])[1+n%3],case when n%2=0 then 'running' else 'queued' end,
|
||||
jsonb_build_object('issueId',(select id from bench_fixture_issues where bench_fixture_issues.n=g.n*97))
|
||||
from generate_series(1,150) g(n)`, [companyId, agentIds]);
|
||||
await client.unsafe(`insert into heartbeat_runs(company_id,agent_id,status) select $1::uuid,($2::uuid[])[1],'succeeded' from generate_series(1,$3::int)`, [companyId, agentIds, issueCount]);
|
||||
await client.unsafe("ANALYZE");
|
||||
const [counts] = await client.unsafe(`select
|
||||
(select count(*)::int from issues where company_id=$1) issues,
|
||||
(select count(*)::int from issues where company_id=$1 and status='done') done,
|
||||
(select count(*)::int from issue_comments where company_id=$1) comments,
|
||||
(select count(*)::int from activity_log where company_id=$1) activity,
|
||||
(select count(*)::int from issue_inbox_archives where company_id=$1) archives,
|
||||
(select count(*)::int from issue_read_states where company_id=$1) read_states,
|
||||
(select count(*)::int from issue_thread_interactions where company_id=$1) interactions,
|
||||
(select count(*)::int from heartbeat_runs where company_id=$1 and status in ('queued','running')) live_runs`, [companyId]);
|
||||
const fixtureMs = performance.now() - seedStart;
|
||||
|
||||
const readCondition = await issueReadSqlCondition(db, { type: "board", source: "session", userId, authorizingUserId: userId } as never);
|
||||
const filters: IssueFilters = {
|
||||
// The route's exact filters. Baselines that predate touchedCandidates ignore it.
|
||||
readCondition, touchedByUserId: userId, touchedCandidates: "participation", inboxArchivedByUserId: userId,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER, listProjection: "index",
|
||||
sortField: "updated", sortDir: "desc", limit: 100,
|
||||
includeRoutineExecutions: true, includeLiveDescendantSummary: true,
|
||||
};
|
||||
const arms = { baseline: baseline.issueService(db), treatment: issueService(db) };
|
||||
const times = { baseline: [] as number[], treatment: [] as number[] };
|
||||
let reference: unknown;
|
||||
for (let iteration = -2; iteration < samples; iteration++) {
|
||||
for (const arm of (iteration % 2 === 0 ? ["baseline", "treatment"] : ["treatment", "baseline"]) as Array<keyof typeof arms>) {
|
||||
const started = performance.now();
|
||||
const rows = await arms[arm].list(companyId, filters);
|
||||
const elapsed = performance.now() - started;
|
||||
if (iteration >= 0) times[arm].push(elapsed);
|
||||
const serialized = JSON.parse(JSON.stringify(rows));
|
||||
if (reference === undefined) reference = serialized;
|
||||
else assert.deepEqual(serialized, reference, `${arm}: complete Mine row payloads must match`);
|
||||
}
|
||||
}
|
||||
const resultIds = (reference as Array<{ id: string }>).map((row) => row.id);
|
||||
|
||||
// Per-phase timing from the list's own phase hook (absent in old baselines).
|
||||
const phaseTimes: Record<string, Record<string, number[]>> = { baseline: {}, treatment: {} };
|
||||
for (let iteration = 0; iteration < samples; iteration++) {
|
||||
for (const arm of ["baseline", "treatment"] as const) {
|
||||
await arms[arm].list(companyId, filters, {
|
||||
timePhase: async (phase, read) => {
|
||||
const started = performance.now();
|
||||
try { return await read(); } finally { (phaseTimes[arm]![phase] ??= []).push(performance.now() - started); }
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
assert.ok(resultIds.length === 100, `Fixture must fill the 100-row page (got ${resultIds.length})`);
|
||||
|
||||
const plans: Record<string, unknown> = {};
|
||||
for (const arm of ["baseline", "treatment"] as const) {
|
||||
capture = [];
|
||||
await arms[arm].list(companyId, filters);
|
||||
const captured = capture;
|
||||
capture = null;
|
||||
const ranked = [];
|
||||
for (const query of captured) {
|
||||
const [row] = await client.unsafe(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query.sql}`, query.params);
|
||||
const plan = row["QUERY PLAN"][0];
|
||||
ranked.push({ executionMs: plan["Execution Time"], planningMs: plan["Planning Time"],
|
||||
sharedHit: plan.Plan["Shared Hit Blocks"], sharedRead: plan.Plan["Shared Read Blocks"],
|
||||
sql: query.sql, params: query.params, plan });
|
||||
}
|
||||
ranked.sort((a, b) => b.executionMs - a.executionMs);
|
||||
plans[arm] = ranked;
|
||||
}
|
||||
const stats = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return { medianMs: sorted[Math.floor(sorted.length / 2)]!, minMs: sorted[0]!, maxMs: sorted[sorted.length - 1]!, samplesMs: values };
|
||||
};
|
||||
const summary = (arm: "baseline" | "treatment") => (plans[arm] as Array<{ executionMs: number; sql: string; sharedHit: number; sharedRead: number }>)
|
||||
.map((query) => ({ executionMs: Number(query.executionMs.toFixed(2)), buffers: query.sharedHit + query.sharedRead, sql: query.sql.replace(/\s+/g, " ").slice(0, 160) }));
|
||||
const report = {
|
||||
baselineRevision, treatmentRevision: git(["rev-parse", "HEAD"]).trim(), treatmentDirty: git(["status", "--porcelain", "--", ...modulePaths]).trim() !== "",
|
||||
settings, fixture: { ...counts, fixtureMs, resultRows: resultIds.length },
|
||||
baseline: stats(times.baseline), treatment: stats(times.treatment),
|
||||
phases: Object.fromEntries(Object.entries(phaseTimes).map(([arm, phases]) => [arm,
|
||||
Object.fromEntries(Object.entries(phases).map(([phase, values]) => [phase, Number(stats(values).medianMs.toFixed(2))]))])),
|
||||
speedup: stats(times.baseline).medianMs / stats(times.treatment).medianMs,
|
||||
queries: { baseline: summary("baseline"), treatment: summary("treatment") },
|
||||
};
|
||||
if (evidenceDir) {
|
||||
await fs.mkdir(evidenceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(evidenceDir, "summary.json"), JSON.stringify(report, null, 2));
|
||||
await fs.writeFile(path.join(evidenceDir, "plans.json"), JSON.stringify(plans, null, 2));
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} finally {
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await database?.cleanup();
|
||||
await fs.rm(baselineDir, { recursive: true, force: true });
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
activityLog, agents, companies, heartbeatRuns, issueComments, issueInboxArchives, issueReadStates, issues, issueThreadInteractions,
|
||||
} from "@paperclipai/db";
|
||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||
import { expect, it } from "vitest";
|
||||
import { issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueService, type IssueFilters } from "../services/issues.js";
|
||||
import { describeEmbeddedPostgres, useEmbeddedPostgres } from "./helpers/route-test-harness.js";
|
||||
|
||||
// The Mine list ranks candidates from the participation UNION
|
||||
// (`touchedCandidates: "participation"`). These tests run the original
|
||||
// `touchedByUserCondition` scan beside it and require identical ordered rows.
|
||||
describeEmbeddedPostgres("Mine inbox participation candidates", () => {
|
||||
const ctx = useEmbeddedPostgres("inbox-mine-candidates-");
|
||||
const at = (minute: number) => new Date(Date.UTC(2026, 9, 1, 0, minute));
|
||||
|
||||
async function mineFilters(userId: string, overrides: Partial<IssueFilters> = {}): Promise<IssueFilters> {
|
||||
return {
|
||||
readCondition: await issueReadSqlCondition(ctx.db, { type: "board", source: "session", userId, authorizingUserId: userId } as never),
|
||||
touchedByUserId: userId, inboxArchivedByUserId: userId,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER, listProjection: "index",
|
||||
sortField: "updated", sortDir: "desc", limit: 100,
|
||||
includeRoutineExecutions: true, includeLiveDescendantSummary: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function compare(companyId: string, filters: IssueFilters) {
|
||||
const svc = issueService(ctx.db);
|
||||
const scan = await svc.list(companyId, filters);
|
||||
const ranked = await svc.list(companyId, { ...filters, touchedCandidates: "participation" });
|
||||
expect(ranked.map((row) => row.id)).toEqual(scan.map((row) => row.id));
|
||||
expect(JSON.parse(JSON.stringify(ranked))).toEqual(JSON.parse(JSON.stringify(scan)));
|
||||
return scan.map((row) => row.id);
|
||||
}
|
||||
|
||||
it("selects the same ordered rows and payloads as the touched scan across participation edge cases", async () => {
|
||||
const companyId = randomUUID(), foreignCompanyId = randomUUID();
|
||||
const userId = `mine-${randomUUID()}`, otherUserId = `other-${randomUUID()}`;
|
||||
await ctx.db.insert(companies).values([
|
||||
{ id: companyId, name: "Mine candidates", issuePrefix: `M${companyId.slice(0, 6).toUpperCase()}` },
|
||||
{ id: foreignCompanyId, name: "Foreign", issuePrefix: `F${foreignCompanyId.slice(0, 6).toUpperCase()}` },
|
||||
]);
|
||||
const [agent] = await ctx.db.insert(agents).values({ companyId, name: "Worker", adapterType: "process", status: "active" }).returning();
|
||||
const named = new Map<string, string>();
|
||||
const add = async (name: string, values: Partial<typeof issues.$inferInsert> = {}, minute = named.size) => {
|
||||
const [row] = await ctx.db.insert(issues).values({
|
||||
companyId, title: name, status: "todo", createdByUserId: otherUserId,
|
||||
createdAt: at(0), updatedAt: at(minute), ...values,
|
||||
}).returning();
|
||||
named.set(name, row!.id);
|
||||
return row!.id;
|
||||
};
|
||||
const activity = (issueId: string, values: Partial<typeof activityLog.$inferInsert>) => ctx.db.insert(activityLog).values({
|
||||
companyId, entityType: "issue", entityId: issueId, actorType: "user", actorId: userId, action: "issue.updated", createdAt: at(500), ...values,
|
||||
});
|
||||
const comment = (issueId: string, values: Partial<typeof issueComments.$inferInsert>) => ctx.db.insert(issueComments).values({
|
||||
companyId, issueId, body: "note", authorUserId: userId, createdAt: at(400), ...values,
|
||||
});
|
||||
|
||||
// Included: every participation source, done work, and re-activated archives.
|
||||
await add("created by me", { createdByUserId: userId });
|
||||
await add("assigned to me", { assigneeUserId: userId, createdByUserId: null, createdByAgentId: agent!.id });
|
||||
await activity(await add("updated by me"), {});
|
||||
await comment(await add("commented by me"), {});
|
||||
await add("done by me", { createdByUserId: userId, status: "done", completedAt: at(30) });
|
||||
const pending = await add("pending request", { createdByUserId: null, createdByAgentId: agent!.id });
|
||||
await ctx.db.insert(issueThreadInteractions).values({
|
||||
companyId, issueId: pending, kind: "request_confirmation", status: "pending", addresseeUserId: userId,
|
||||
payload: { version: 1, prompt: "Confirm?" } as never, createdAt: at(600),
|
||||
});
|
||||
const reactivated = await add("archived then re-activated", { createdByUserId: userId });
|
||||
await ctx.db.insert(issueInboxArchives).values({ companyId, issueId: reactivated, userId, archivedAt: at(700) });
|
||||
await comment(reactivated, { authorUserId: otherUserId, createdAt: at(800) });
|
||||
const readIssue = await add("read with later agent comment", { createdByUserId: userId });
|
||||
await ctx.db.insert(issueReadStates).values({ companyId, issueId: readIssue, userId, lastReadAt: at(900) });
|
||||
await comment(readIssue, { authorUserId: null, authorAgentId: agent!.id, createdAt: at(950) });
|
||||
const liveChild = await add("live child of mine", { createdByUserId: userId, parentId: named.get("created by me")! });
|
||||
const [run] = await ctx.db.insert(heartbeatRuns).values({ companyId, agentId: agent!.id, status: "running", contextSnapshot: { issueId: liveChild } }).returning();
|
||||
await ctx.db.update(issues).set({ executionRunId: run!.id }).where(sql`${issues.id} = ${liveChild}`);
|
||||
|
||||
// Excluded by both selections.
|
||||
const archived = await add("archived without new activity", { createdByUserId: userId });
|
||||
await ctx.db.insert(issueInboxArchives).values({ companyId, issueId: archived, userId, archivedAt: at(990) });
|
||||
await comment(await add("mention only"), { authorUserId: otherUserId, body: `@${userId} please look` });
|
||||
await activity(await add("read audit only"), { action: "issue.read_marked" });
|
||||
const upper = await add("uppercase entity id");
|
||||
await activity(upper, { entityId: upper.toUpperCase() });
|
||||
await comment(await add("channel comment"), { metadata: { sourceChannel: "slack" } as never });
|
||||
await add("agent created with my user id", { createdByUserId: userId, createdByAgentId: agent!.id });
|
||||
await add("cancelled by me", { createdByUserId: userId, status: "cancelled" });
|
||||
await add("hidden by me", { createdByUserId: userId, hiddenAt: at(1) });
|
||||
await add("harness by me", { createdByUserId: userId, harnessKind: "synthetic" });
|
||||
await add("plugin operation by me", { createdByUserId: userId, originKind: "plugin:demo:operation" });
|
||||
const privateId = randomUUID();
|
||||
await add("private to someone else", {
|
||||
id: privateId, visibility: "private", privacyRootIssueId: privateId, responsibleUserId: otherUserId,
|
||||
});
|
||||
await activity(privateId, {});
|
||||
const answered = await add("answered request", { createdByUserId: null, createdByAgentId: agent!.id });
|
||||
await ctx.db.insert(issueThreadInteractions).values({
|
||||
companyId, issueId: answered, kind: "request_confirmation", status: "answered", addresseeUserId: userId,
|
||||
payload: { version: 1, prompt: "Confirm?" } as never,
|
||||
});
|
||||
await ctx.db.insert(issues).values({ companyId: foreignCompanyId, title: "foreign", createdByUserId: userId });
|
||||
|
||||
// Page pressure: more than 100 touched issues, many with tied activity times.
|
||||
for (let index = 0; index < 130; index++) {
|
||||
await add(`bulk ${index}`, { createdByUserId: userId, status: index % 3 === 0 ? "done" : "in_progress" }, index % 4);
|
||||
}
|
||||
for (let index = 0; index < 40; index++) await add(`untouched ${index}`, {}, 1000 + index);
|
||||
|
||||
const ids = await compare(companyId, await mineFilters(userId));
|
||||
expect(ids).toHaveLength(100);
|
||||
const all = await compare(companyId, await mineFilters(userId, { limit: 1000 }));
|
||||
for (const name of [
|
||||
"created by me", "assigned to me", "updated by me", "commented by me", "done by me", "pending request",
|
||||
"archived then re-activated", "read with later agent comment", "live child of mine",
|
||||
]) expect(all, name).toContain(named.get(name));
|
||||
for (const name of [
|
||||
"archived without new activity", "mention only", "read audit only", "uppercase entity id", "channel comment",
|
||||
"agent created with my user id", "cancelled by me", "hidden by me", "harness by me", "plugin operation by me",
|
||||
"private to someone else", "answered request", "untouched 0",
|
||||
]) expect(all, name).not.toContain(named.get(name));
|
||||
expect(all.indexOf(named.get("archived then re-activated")!)).toBeLessThan(all.indexOf(named.get("created by me")!));
|
||||
|
||||
// Other selection shapes stay equivalent: no archive filter, other statuses,
|
||||
// offsets, ascending order, unrestricted read access, and another user.
|
||||
await compare(companyId, await mineFilters(userId, { inboxArchivedByUserId: undefined }));
|
||||
await compare(companyId, await mineFilters(userId, { status: "done", listProjection: undefined, includeLiveDescendantSummary: false }));
|
||||
await compare(companyId, await mineFilters(userId, { offset: 50, limit: 30 }));
|
||||
await compare(companyId, await mineFilters(userId, { sortDir: "asc" }));
|
||||
await compare(companyId, { ...await mineFilters(userId), readCondition: sql<boolean>`true` });
|
||||
expect(await compare(companyId, await mineFilters(otherUserId, { limit: 1000 }))).toContain(named.get("archived then re-activated"));
|
||||
expect(await compare(companyId, await mineFilters(`nobody-${randomUUID()}`))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -112,9 +112,12 @@ describeEmbeddedPostgres("Mine inbox snapshot routes", () => {
|
||||
}));
|
||||
for (const phase of [
|
||||
"paperclip_inbox", "inbox_access", "inbox_issues", "inbox_parents", "inbox_failures", "inbox_failure_context",
|
||||
"inbox_issues_query", "inbox_issues_labels", "inbox_issues_user_stats", "inbox_issues_read_stats",
|
||||
"inbox_issues_candidates", "inbox_issues_query", "inbox_issues_labels", "inbox_issues_user_stats", "inbox_issues_read_stats",
|
||||
"inbox_issues_last_activity", "inbox_issues_archives", "inbox_issues_live_descendants",
|
||||
]) expect(timingPhases.get(phase), phase).toBeGreaterThanOrEqual(0);
|
||||
// Attribution: list sub-phases run inside inbox_issues, and every phase inside the request.
|
||||
expect(timingPhases.get("inbox_issues")!).toBeGreaterThanOrEqual(timingPhases.get("inbox_issues_candidates")! + timingPhases.get("inbox_issues_query")! - 0.2);
|
||||
expect(timingPhases.get("paperclip_inbox")!).toBeGreaterThanOrEqual(timingPhases.get("inbox_issues")! - 0.1);
|
||||
expect(snapshot).not.toHaveProperty("failureError");
|
||||
expect(snapshot).not.toHaveProperty("contextError");
|
||||
});
|
||||
|
||||
@@ -8831,7 +8831,7 @@ export function issueRoutes(
|
||||
if (!userId) throw new HttpError(401, "A signed-in board user is required");
|
||||
const readCondition = await timing.time("access", () => issueReadSqlCondition(db, req.actor));
|
||||
const listFilters: IssueFilters = {
|
||||
readCondition, touchedByUserId: userId, inboxArchivedByUserId: userId,
|
||||
readCondition, touchedByUserId: userId, touchedCandidates: "participation", inboxArchivedByUserId: userId,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER, listProjection: "index",
|
||||
sortField: "updated", sortDir: "desc", limit: 100,
|
||||
includeRoutineExecutions: true, includeLiveDescendantSummary: true,
|
||||
|
||||
@@ -1867,7 +1867,7 @@ export function parseStatusFilter(
|
||||
|
||||
/** Closed, content-free phases of `issueService.list`, for response timing headers. */
|
||||
export type IssueListPhase =
|
||||
| "query" | "labels" | "active_runs" | "user_stats" | "read_stats" | "last_activity"
|
||||
| "candidates" | "query" | "labels" | "active_runs" | "user_stats" | "read_stats" | "last_activity"
|
||||
| "archives" | "blocked_by" | "live_descendants" | "attention";
|
||||
|
||||
export interface IssueListOptions {
|
||||
@@ -1898,6 +1898,14 @@ export interface IssueFilters {
|
||||
oneChildPerParent?: boolean;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
/**
|
||||
* `participation` ranks touched issues from the indexed participation
|
||||
* UNION (see `mineParticipationIssueIds`) and loads full rows only for the
|
||||
* selected page, instead of scanning every status-matching issue with the
|
||||
* five-way `touchedByUserCondition`. Same rows and order. Requires
|
||||
* `touchedByUserId`; ignored with search or `oneChildPerParent`.
|
||||
*/
|
||||
touchedCandidates?: "participation";
|
||||
inboxArchivedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
projectId?: string;
|
||||
@@ -3028,6 +3036,36 @@ function unreadForUserCondition(companyId: string, userId: string) {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of every issue the user participated in, built from indexed sources.
|
||||
* Each UNION member is one branch of `touchedByUserCondition`, so membership
|
||||
* equals that condition. The result is aliased `mine_participants` (`id`).
|
||||
*/
|
||||
function mineParticipationIssueIds(db: Db, companyId: string, userId: string) {
|
||||
const directlyParticipated = or(
|
||||
and(eq(issues.createdByUserId, userId), humanCreatedIssueCondition()),
|
||||
eq(issues.assigneeUserId, userId),
|
||||
);
|
||||
const participationSources = unionAll(
|
||||
db.select({ id: sql<string | null>`${issues.id}`.as("id") }).from(issues).where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
directlyParticipated,
|
||||
)),
|
||||
db.select({ id: sql<string | null>`case when length(${activityLog.entityId}) = 36 and ${activityLog.entityId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' then ${activityLog.entityId}::uuid end`.as("id") }).from(activityLog).where(and(
|
||||
userParticipationActivityCondition(companyId, userId),
|
||||
notInArray(activityLog.entityId, db.select({ id: sql<string>`${issues.id}::text` }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), directlyParticipated))),
|
||||
)),
|
||||
db.select({ id: sql<string | null>`${issueComments.issueId}`.as("id") }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, companyId), eq(issueComments.authorUserId, userId), directHumanCommentCondition(),
|
||||
)),
|
||||
db.select({ id: sql<string | null>`${issues.id}`.as("id") }).from(issueThreadInteractions)
|
||||
.innerJoin(issues, and(eq(issues.id, issueThreadInteractions.issueId), eq(issues.companyId, companyId)))
|
||||
.where(pendingUserRequestRowCondition(companyId, userId)),
|
||||
).as("mine_participation_sources");
|
||||
return db.selectDistinct({ id: participationSources.id }).from(participationSources).as("mine_participants");
|
||||
}
|
||||
|
||||
function inboxVisibleForUserCondition(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
NOT EXISTS (
|
||||
@@ -8239,28 +8277,7 @@ export function issueService(db: Db) {
|
||||
unreadInboxIssueIds: async (companyId: string, userId: string, readCondition: SQL<boolean>) => {
|
||||
// Rank the visible Mine set before counting unread, just like the inbox.
|
||||
// This projection never reads descriptions, run payloads, or recovery details.
|
||||
const directlyParticipated = or(
|
||||
and(eq(issues.createdByUserId, userId), humanCreatedIssueCondition()),
|
||||
eq(issues.assigneeUserId, userId),
|
||||
);
|
||||
const participationSources = unionAll(
|
||||
db.select({ id: sql<string | null>`${issues.id}`.as("id") }).from(issues).where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
directlyParticipated,
|
||||
)),
|
||||
db.select({ id: sql<string | null>`case when length(${activityLog.entityId}) = 36 and ${activityLog.entityId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' then ${activityLog.entityId}::uuid end`.as("id") }).from(activityLog).where(and(
|
||||
userParticipationActivityCondition(companyId, userId),
|
||||
notInArray(activityLog.entityId, db.select({ id: sql<string>`${issues.id}::text` }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), directlyParticipated))),
|
||||
)),
|
||||
db.select({ id: sql<string | null>`${issueComments.issueId}`.as("id") }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, companyId), eq(issueComments.authorUserId, userId), directHumanCommentCondition(),
|
||||
)),
|
||||
db.select({ id: sql<string | null>`${issues.id}`.as("id") }).from(issueThreadInteractions)
|
||||
.innerJoin(issues, and(eq(issues.id, issueThreadInteractions.issueId), eq(issues.companyId, companyId)))
|
||||
.where(pendingUserRequestRowCondition(companyId, userId)),
|
||||
).as("mine_participation_sources");
|
||||
const participated = db.selectDistinct({ id: participationSources.id }).from(participationSources).as("mine_participants");
|
||||
const participated = mineParticipationIssueIds(db, companyId, userId);
|
||||
const eligible = db.select({ id: issues.id, updatedAt: issues.updatedAt })
|
||||
.from(participated).innerJoin(issues, eq(issues.id, sql`"mine_participants"."id"`)).where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
@@ -8354,6 +8371,10 @@ export function issueService(db: Db) {
|
||||
const rawSearch = filters?.q?.trim() ?? "";
|
||||
const hasSearch = rawSearch.length > 0;
|
||||
const taskSearch = parseTaskSearch(rawSearch);
|
||||
// Rank page ids from the user's participation set, then load full rows
|
||||
// only for that page (see IssueFilters.touchedCandidates).
|
||||
const participationCandidates = filters?.touchedCandidates === "participation"
|
||||
&& touchedByUserId !== undefined && !hasSearch && !filters?.oneChildPerParent;
|
||||
if (filters?.createdFromIssueId) {
|
||||
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
|
||||
}
|
||||
@@ -8402,7 +8423,9 @@ export function issueService(db: Db) {
|
||||
conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||
}
|
||||
if (touchedByUserId) {
|
||||
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
||||
conditions.push(participationCandidates
|
||||
? sql<boolean>`${issues.id} IN (SELECT "mine_participants"."id" FROM ${mineParticipationIssueIds(db, companyId, touchedByUserId)})`
|
||||
: touchedByUserCondition(companyId, touchedByUserId));
|
||||
}
|
||||
if (inboxArchivedByUserId) {
|
||||
conditions.push(
|
||||
@@ -8483,6 +8506,28 @@ export function issueService(db: Db) {
|
||||
description: sql<string | null>`NULL`,
|
||||
descriptionTruncated: sql<boolean>`false`,
|
||||
} : issueListSelect;
|
||||
const orderBy = [
|
||||
...(filters?.oneChildPerParent ? [asc(issues.parentId)] : []),
|
||||
...issueListOrderBy(companyId, {
|
||||
hasSearch,
|
||||
priorityOrder,
|
||||
searchOrder,
|
||||
sortField: filters?.sortField,
|
||||
sortDir: filters?.sortDir,
|
||||
}),
|
||||
];
|
||||
const paginate = <Q extends { limit(limit: number): Q; offset(offset: number): Q }>(query: Q) =>
|
||||
offset > 0
|
||||
? limit === undefined ? query.offset(offset) : query.limit(limit).offset(offset)
|
||||
: limit === undefined ? query : query.limit(limit);
|
||||
// Phase 1 of the participation path: the page's ids in final order. The
|
||||
// select-list subqueries below then run only for these rows.
|
||||
const pageIds = participationCandidates
|
||||
? (await timePhase("candidates", async () => paginate(
|
||||
db.select({ id: issues.id }).from(issues).where(and(...conditions)).orderBy(...orderBy).$dynamic(),
|
||||
))).map((row) => row.id)
|
||||
: null;
|
||||
if (pageIds?.length === 0) return [];
|
||||
const issueSource = (filters?.oneChildPerParent
|
||||
? db.selectDistinctOn([issues.parentId], selectedFields)
|
||||
: db.select(selectedFields)).from(issues);
|
||||
@@ -8492,27 +8537,22 @@ export function issueService(db: Db) {
|
||||
SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m
|
||||
) task_search`, sql`task_search.id = ${issues.id}`)
|
||||
: issueSource;
|
||||
const baseQuery = searchedSource
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
...(filters?.oneChildPerParent ? [asc(issues.parentId)] : []),
|
||||
...issueListOrderBy(companyId, {
|
||||
hasSearch,
|
||||
priorityOrder,
|
||||
searchOrder,
|
||||
sortField: filters?.sortField,
|
||||
sortDir: filters?.sortDir,
|
||||
}),
|
||||
);
|
||||
const pageQuery =
|
||||
offset > 0
|
||||
? limit === undefined
|
||||
? baseQuery.offset(offset)
|
||||
: baseQuery.limit(limit).offset(offset)
|
||||
: limit === undefined
|
||||
? baseQuery
|
||||
: baseQuery.limit(limit);
|
||||
const rows = (await timePhase("query", async () => pageQuery)).map((row) => ({
|
||||
const pageQuery = pageIds
|
||||
// Re-check company, visibility and read access for the selected ids;
|
||||
// the rows are put back into the phase 1 order below.
|
||||
? searchedSource.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.id, pageIds),
|
||||
visibleIssueCondition(),
|
||||
filters?.readCondition,
|
||||
))
|
||||
: paginate(searchedSource.where(and(...conditions)).orderBy(...orderBy).$dynamic());
|
||||
const pageRows = await timePhase("query", async () => pageQuery);
|
||||
if (pageIds) {
|
||||
const position = new Map(pageIds.map((id, index) => [id, index]));
|
||||
pageRows.sort((a, b) => position.get(a.id)! - position.get(b.id)!);
|
||||
}
|
||||
const rows = pageRows.map((row) => ({
|
||||
...row,
|
||||
description: decodeDatabaseTextPreview(
|
||||
row.description,
|
||||
|
||||
Reference in new issue
Block a user