From 75580f2b0b0013fade034edd8c87629d93127169 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Sat, 17 Oct 2026 16:10:29 +0000 Subject: [PATCH 1/7] Stored id sets: a cohort from a long list of ids, kept on the server A sampleList/patientList clause may now name a stored id set ({"kind":"sampleList","set":""}) instead of carrying its ids. The list is saved once (POST /api/id-sets, or "+ Add cohort" -> "From a list of ids"), matched to the studies' own samples or patients first (what matched nothing is reported), and the document, its ?doc= link and every request carry only the set's id. Why: an inline list is written into the SQL. On msk_chord_2024 sample ids hit ClickHouse's 256 KB max_query_size between 12,000 and 13,000 ids, each view re-inlined the list, and the readback echoed it: at 10,000 ids every id appeared four times in an 839 KB response. The ?doc= URL broke first, at roughly 350 sample ids (Node refuses headers past 16 KB). How: - db/migrations/0006_id_sets.sql: id_set (owner, sharing columns as for custom datasets, members gzipped per study, immutable by trigger, soft delete) and id_set_access_log. lib/id-set-store.ts is the only module that touches them; access goes through lib/access.ts. Group deletion unshares sets too. - lib/id-sets.ts: the per-request context. The compiler names one ClickHouse external table per set and study (IN (SELECT id FROM _idset_...)), and lib/ch.ts uploads it only with a statement that names it, only to the patient-level connection holding that study. - lib/id-sets-server.ts: every engine route passes its body as `uses` to withRequestCustomData, which reads the sets it names as the signed-in principal. MCP, scripts and signed-out requests do not read sets, and such a clause is refused by name. carriesPatientIds counts a set as ids. Answers that read a set bypass the shared result cache (hasIdSets()). - Inline lists are refused by name past INLINE_ID_SQL_LIMIT (200 KB of ids per study), pointing at stored sets, instead of ClickHouse's syntax error. - Sharing an analysis or cohort wider than a set it names warns, as for custom datasets, and offers to share the reader's own sets the same way. - DSL 2.7 (no-op upgrade step) so a 2.6 engine refuses a document naming a set rather than reading it as an empty list. Docs regenerated; DATA-BOUNDARY.md and PERFORMANCE.md updated. Verified against the live warehouse with runAnalysis (compare + clinical): all 25,040 msk_chord_2024 samples in 4.9 s, 416 B document, 23 KB response (inline: fails); 60,000 samples across msk_chord_2024 + genie_public in 7.3 s, 504 B document, 45 KB response. Saving the 60k set took 0.9 s. Tests: test/id-sets.test.ts (compile, request context, boundary, inline limit); idSetContract on PGlite and Postgres (round trip, access, immutable members, delete and unshare take effect at once: both mutation-checked); e2e paste -> save -> add, with the set refused signed out, for another user and after deletion. Gate (GATE_PORT=4111 scripts/gate.sh) passes. Deploying needs `npm run db:migrate` for 0006 on the configured Postgres. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti --- CLAUDE.md | 14 +- app/api/analysis/method/route.ts | 2 +- app/api/analysis/notebook/route.ts | 2 +- app/api/analysis/route.ts | 2 +- app/api/cell-explorer/route.ts | 2 +- app/api/custom-data/members/route.ts | 2 +- app/api/explore/route.ts | 7 +- app/api/id-sets/[id]/route.ts | 25 ++++ app/api/id-sets/[id]/sharing/route.ts | 13 ++ app/api/id-sets/route.ts | 27 ++++ app/api/oncoprint/route.ts | 2 +- app/api/partition/route.ts | 2 +- app/api/table/route.ts | 2 +- app/api/view-page/route.ts | 2 +- app/lab/add-cohort-dialog.tsx | 113 +++++++++++++++- app/lab/api.ts | 10 ++ app/lab/page.tsx | 1 + app/lab/pills.tsx | 3 + app/lab/saved-tab.tsx | 9 +- app/lab/share-dialog.tsx | 16 +-- db/migrations/0006_id_sets.sql | 76 +++++++++++ docs/DATA-BOUNDARY.md | 23 +++- docs/PERFORMANCE.md | 25 ++++ docs/dsl.md | 28 +++- e2e.cjs | 47 +++++++ lib/artifacts.ts | 23 +++- lib/ch.ts | 7 +- lib/cohort2.ts | 54 ++++++-- lib/custom-data-server.ts | 17 ++- lib/doc-version.ts | 13 +- lib/dsl-reference.ts | 26 +++- lib/federation.ts | 5 +- lib/groups.ts | 4 +- lib/id-set-import.ts | 51 ++++++++ lib/id-set-store.ts | 178 ++++++++++++++++++++++++++ lib/id-sets-server.ts | 37 ++++++ lib/id-sets.ts | 126 ++++++++++++++++++ lib/next-cache.ts | 9 +- lib/types.ts | 9 +- test/custom-store.test.ts | 3 +- test/id-sets.test.ts | 82 ++++++++++++ test/postgres.db-test.ts | 7 +- test/support/store-contracts.ts | 61 +++++++++ 43 files changed, 1091 insertions(+), 76 deletions(-) create mode 100644 app/api/id-sets/[id]/route.ts create mode 100644 app/api/id-sets/[id]/sharing/route.ts create mode 100644 app/api/id-sets/route.ts create mode 100644 db/migrations/0006_id_sets.sql create mode 100644 lib/id-set-import.ts create mode 100644 lib/id-set-store.ts create mode 100644 lib/id-sets-server.ts create mode 100644 lib/id-sets.ts create mode 100644 test/id-sets.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5b076572..e9029c97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,17 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a enabled only where the deployment signs people in, each dataset its saver's. A document pins what it uses as `uploads: [{as, dataset, version}]` (added by the page on run); requests send `customRefs` and the server loads the pinned, immutable version. +- **Stored id sets** — a cohort given as a long list of ids (`sampleList`/`patientList` with + `"set": ""` instead of `"ids"`). **`lib/id-set-store.ts`** is the only module touching its + tables (`db/migrations/0006_id_sets.sql`): owned and shared by `lib/access.ts`, members + immutable (trigger), soft delete, every read logged. `lib/id-set-import.ts` matches a pasted list + to the studies' cases before saving (`POST /api/id-sets`; UI: "+ Add cohort" → "From a list of + ids"). Every engine route passes its body as `uses` to `withRequestCustomData`, which reads the + sets it names (`lib/id-sets-server.ts`) into **`lib/id-sets.ts`**'s request context; the compiler + then names one ClickHouse external table per set and study, and `lib/ch.ts` uploads it only to + the patient-level connection holding that study. A set is not read without a signed-in request + (MCP, scripts), and an answer that reads one bypasses the result cache (`hasIdSets()`). Inline + lists are refused by name past `INLINE_ID_SQL_LIMIT` (`lib/cohort2.ts`). - **Signing in** (`lib/auth.ts`, the header's Sign in) guards custom data, saved cohorts/analyses (`/api/artifacts`) and campaign changes; exploring, analyses, `?doc=` links and MCP need none. A deployment has ONE way in: an OpenID Connect provider (`lib/oidc.ts`; @@ -311,7 +322,8 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a Read before claiming this project is "fast": on a cached single chart the portal is ~20x quicker (those measurements predate the result cache of invariant 11, which answers only exact repeats), and our advantage is data movement (36x less to the - browser), not speed. Also records the ~20,000-id ceiling on explicit patient lists. + browser), not speed. Also records the ceiling on inline id lists (~12,000 sample ids) and the + stored id sets that remove it. ## Invariants — do not break these diff --git a/app/api/analysis/method/route.ts b/app/api/analysis/method/route.ts index 0446c064..32430597 100644 --- a/app/api/analysis/method/route.ts +++ b/app/api/analysis/method/route.ts @@ -12,7 +12,7 @@ export const maxDuration = 60; export async function POST(req: Request) { try { const { customData, customRefs, ...doc } = parseBody(AnalysisRequest, await req.json()); - const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: "spec" }), { refs: customRefs, req }); + const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: "spec" }), { refs: customRefs, req, uses: doc }); const view = result.views[0]; if (!view || view.error) return Response.json({ error: view ? view.error : "the document has no view" }, { status: 422 }); return Response.json({ python: view.method?.python ?? null }); diff --git a/app/api/analysis/notebook/route.ts b/app/api/analysis/notebook/route.ts index ee1cc2a2..04c7f026 100644 --- a/app/api/analysis/notebook/route.ts +++ b/app/api/analysis/notebook/route.ts @@ -31,7 +31,7 @@ export async function POST(req: Request) { view, method: view.method, files, missing, doc, viewIndex: notebookView.index, origin: process.env.APP_BASE_URL || new URL(req.url).origin, }); - }, { refs: customRefs, req }); + }, { refs: customRefs, req, uses: doc }); const name = slug(notebookView.kind + "-" + String((doc as { study?: string }).study || "")) + ".ipynb"; return new Response(JSON.stringify(nb), { headers: { "Content-Type": "application/x-ipynb+json; charset=utf-8", "Content-Disposition": `attachment; filename="${name}"` }, diff --git a/app/api/analysis/route.ts b/app/api/analysis/route.ts index 4217656a..05b28321 100644 --- a/app/api/analysis/route.ts +++ b/app/api/analysis/route.ts @@ -14,7 +14,7 @@ export async function POST(req: Request) { const url = new URL(req.url); // the web app draws its own oncoprints; ?oncoprint=spec skips rendering them here const mode = url.searchParams.get("oncoprint") === "spec" ? "spec" as const : "svg" as const; - const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: mode }), { refs: customRefs, req }); + const result = await withRequestCustomData(customData, () => cachedAnalysis(doc as unknown as AnalysisDoc, { oncoprint: mode }), { refs: customRefs, req, uses: doc }); // ?figures=svg: every view drawn by the shared server renderer, for a client that cannot draw if (url.searchParams.get("figures") === "svg") return Response.json({ ...result, views: result.views.map((v) => v.svg || v.error ? v : { ...v, svg: viewToSvg(v) || undefined }) }); diff --git a/app/api/cell-explorer/route.ts b/app/api/cell-explorer/route.ts index 9db91e1e..99a029c9 100644 --- a/app/api/cell-explorer/route.ts +++ b/app/api/cell-explorer/route.ts @@ -11,7 +11,7 @@ export async function POST(req: Request) { const body = parseBody(CellExplorerRequest, await req.json()); if (body.mode === "import") return Response.json(await cohortFromCells(body)); const { customData, customRefs, doc, cohort } = body; - return Response.json(await withRequestCustomData(customData, () => cohortCellsLink({ doc, cohort }), { refs: customRefs, req })); + return Response.json(await withRequestCustomData(customData, () => cohortCellsLink({ doc, cohort }), { refs: customRefs, req, uses: doc })); } catch (e) { return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 }); } diff --git a/app/api/custom-data/members/route.ts b/app/api/custom-data/members/route.ts index 39ee090e..8482eea6 100644 --- a/app/api/custom-data/members/route.ts +++ b/app/api/custom-data/members/route.ts @@ -10,7 +10,7 @@ export const maxDuration = 60; export async function POST(req: Request) { try { const { customData, customRefs, def, studies } = parseBody(MembersRequest, await req.json()); - return Response.json({ studies: await withRequestCustomData(customData, () => cohortMembership(def, studies.map(String)), { refs: customRefs, req }) }); + return Response.json({ studies: await withRequestCustomData(customData, () => cohortMembership(def, studies.map(String)), { refs: customRefs, req, uses: def }) }); } catch (e) { return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 }); } diff --git a/app/api/explore/route.ts b/app/api/explore/route.ts index 09a7782b..bb48ff0b 100644 --- a/app/api/explore/route.ts +++ b/app/api/explore/route.ts @@ -10,6 +10,7 @@ import { studyProfiles, resolveStudy } from "../../../lib/analysis.ts"; import { chQuery } from "../../../lib/ch.ts"; import { withRequestCustomData } from "../../../lib/custom-data-server.ts"; import { assertCustomDataPresent } from "../../../lib/custom-data.ts"; +import { idSetRefsIn } from "../../../lib/id-sets.ts"; import type { DerivedRequest } from "../../../lib/types.ts"; export const maxDuration = 60; @@ -32,9 +33,9 @@ export async function POST(req: Request) { async function handle(req: Request) { try { const { customData, customRefs, ...body } = parseBody(ExploreRequest, await req.json()); - // facets over uploaded data are the uploader's alone: answered directly, never cached - if ((Array.isArray(customData) && customData.length) || (Array.isArray(customRefs) && customRefs.length)) - return Response.json(await withRequestCustomData(customData, () => ensureChContext(() => exploreFor(body)), { refs: customRefs, req })); + // facets over uploaded data or a stored id set are their reader's alone: answered directly, never cached + if ((Array.isArray(customData) && customData.length) || (Array.isArray(customRefs) && customRefs.length) || idSetRefsIn(body.draft).length) + return Response.json(await withRequestCustomData(customData, () => ensureChContext(() => exploreFor(body)), { refs: customRefs, req, uses: body.draft })); return Response.json(await throughCache(async () => exploreEntry(body, await dataVersion()), () => ensureChContext(() => exploreFor(body)))); } catch (e) { // The reader superseded this request: not a failure, and there is nobody left to tell. diff --git a/app/api/id-sets/[id]/route.ts b/app/api/id-sets/[id]/route.ts new file mode 100644 index 00000000..5f107971 --- /dev/null +++ b/app/api/id-sets/[id]/route.ts @@ -0,0 +1,25 @@ +import { requirePrincipal, AccessError } from "../../../../lib/auth.ts"; +import { getIdSet, readIdSet, deleteIdSet } from "../../../../lib/id-set-store.ts"; + +const fail = (e: unknown) => Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + +/** One set's description; with `?ids=1`, its ids too, one ":" per line (that read is logged). */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const principal = requirePrincipal(req, "use stored id sets"); + if (new URL(req.url).searchParams.get("ids") !== "1") return Response.json({ set: await getIdSet(principal, id) }); + const { perStudy } = await readIdSet(principal, id); + const text = Object.entries(perStudy).flatMap(([study, ids]) => ids.map((x) => study + ":" + x)).join("\n") + "\n"; + return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "private, no-store" } }); + } catch (e) { return fail(e); } +} + +/** Delete (its owner only): documents naming it are refused from then on, saying when it went. */ +export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + await deleteIdSet(requirePrincipal(req, "use stored id sets"), id); + return Response.json({ deleted: true }); + } catch (e) { return fail(e); } +} diff --git a/app/api/id-sets/[id]/sharing/route.ts b/app/api/id-sets/[id]/sharing/route.ts new file mode 100644 index 00000000..11b1b199 --- /dev/null +++ b/app/api/id-sets/[id]/sharing/route.ts @@ -0,0 +1,13 @@ +import { requirePrincipal, AccessError } from "../../../../../lib/auth.ts"; +import { shareIdSet } from "../../../../../lib/id-set-store.ts"; + +// Who a stored id set is shared with (its owner only; lib/access.ts). Body as for +// /api/artifacts/[id]/sharing. Its ids are patient-level: nothing shares them but this. +export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + return Response.json({ set: await shareIdSet(requirePrincipal(req, "use stored id sets"), id, await req.json().catch(() => null)) }); + } catch (e) { + return Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + } +} diff --git a/app/api/id-sets/route.ts b/app/api/id-sets/route.ts new file mode 100644 index 00000000..d01cf9bf --- /dev/null +++ b/app/api/id-sets/route.ts @@ -0,0 +1,27 @@ +import { connection } from "next/server"; +import { requirePrincipal, AccessError } from "../../../lib/auth.ts"; +import { listIdSets } from "../../../lib/id-set-store.ts"; +import { createIdSet } from "../../../lib/id-set-import.ts"; + +export const maxDuration = 60; + +const fail = (e: unknown) => Response.json({ error: String((e as Error)?.message || e) }, { status: e instanceof AccessError ? e.status : 400 }); + +/** The stored id sets this reader may use, without their ids. */ +export async function GET(req: Request) { + // rendered per request: without it the build prerenders whatever the login check answers then + await connection(); + try { return Response.json({ sets: await listIdSets(requirePrincipal(req, "use stored id sets")) }); } + catch (e) { return fail(e); } +} + +/** + * Save a list of ids as a stored set: `{ name, level, studies, ids }`. The ids are matched to the + * studies' own samples or patients first; the answer is the set and what did not match. + */ +export async function POST(req: Request) { + try { + const principal = requirePrincipal(req, "save stored id sets"); + return Response.json(await createIdSet(principal, await req.json()), { status: 201 }); + } catch (e) { return fail(e); } +} diff --git a/app/api/oncoprint/route.ts b/app/api/oncoprint/route.ts index 81011ec6..5fcb35cb 100644 --- a/app/api/oncoprint/route.ts +++ b/app/api/oncoprint/route.ts @@ -19,7 +19,7 @@ export async function POST(req: Request) { const sp = new URL(req.url).searchParams; // a provenance request is answered with the queries that built the (cached) contract const provenance = sp.get("provenance") === "1"; - const contract = await withRequestCustomData(customData, () => cachedContract(spec, { provenance }), { refs: customRefs, req }); + const contract = await withRequestCustomData(customData, () => cachedContract(spec, { provenance }), { refs: customRefs, req, uses: spec }); const engineMs = Date.now() - t0; const fmt = sp.get("format"); if (fmt === "svg" || fmt === "tsv") { diff --git a/app/api/partition/route.ts b/app/api/partition/route.ts index 18ec0837..4e58cd56 100644 --- a/app/api/partition/route.ts +++ b/app/api/partition/route.ts @@ -9,7 +9,7 @@ export const maxDuration = 60; export async function POST(req: Request) { try { const { customData, customRefs, ...body } = parseBody(PartitionRequest, await req.json()); - return Response.json(await withRequestCustomData(customData, () => proposePartition(body as unknown as Parameters[0]), { refs: customRefs, req })); + return Response.json(await withRequestCustomData(customData, () => proposePartition(body as unknown as Parameters[0]), { refs: customRefs, req, uses: body })); } catch (e) { return Response.json({ error: String((e as Error)?.message || e) }, { status: 400 }); } diff --git a/app/api/table/route.ts b/app/api/table/route.ts index e5d4ed2d..7f688082 100644 --- a/app/api/table/route.ts +++ b/app/api/table/route.ts @@ -33,7 +33,7 @@ export async function POST(req: Request) { } catch (e) { return new Response("cannot download: " + String((e as Error)?.message || e) + "\n", { status: 400, headers: { "content-type": "text/plain; charset=utf-8" } }); } - const run = (fn: () => Promise) => withRequestCustomData(body.customData, fn, { refs: body.customRefs, req }); + const run = (fn: () => Promise) => withRequestCustomData(body.customData, fn, { refs: body.customRefs, req, uses: body }); const table = typeof body.table === "string" ? body.table : undefined; const first = await run(() => runAnalysis(doc, { table })).catch((e) => ({ views: [{ kind: "", cohorts: [], error: String((e as Error)?.message || e) } as ViewResult] })); const v = first.views[0]; diff --git a/app/api/view-page/route.ts b/app/api/view-page/route.ts index 67372b9b..1a76f893 100644 --- a/app/api/view-page/route.ts +++ b/app/api/view-page/route.ts @@ -23,7 +23,7 @@ export async function POST(req: Request) { const view = Array.isArray(doc.views) ? doc.views[vi] : null; if (!view || !PAGED_KINDS.includes(view.kind)) throw new Error("view " + body.view + " is not a table view (" + PAGED_KINDS.join(", ") + ")"); const one = { ...doc, views: [view] }; - const run = (opts: Parameters[1]) => withRequestCustomData(customData, () => runAnalysis(one, opts), { refs: customRefs, req }); + const run = (opts: Parameters[1]) => withRequestCustomData(customData, () => runAnalysis(one, opts), { refs: customRefs, req, uses: one }); const pick = (r: Awaited>) => { const v = r.views[0]; const t = pagedTable(v, body.table); diff --git a/app/lab/add-cohort-dialog.tsx b/app/lab/add-cohort-dialog.tsx index 9934a526..14775762 100644 --- a/app/lab/add-cohort-dialog.tsx +++ b/app/lab/add-cohort-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -// "+ Add cohort" on a view: one dialog with the three places a cohort comes from — the analysis's -// own cohorts, the library (the login's saved cohorts and the built-in examples), and Explore, -// where a new one is built and comes back to this view. +// "+ Add cohort" on a view: one dialog with the places a cohort comes from — the analysis's own +// cohorts, the library (the login's saved cohorts and the built-in examples), Explore, where a new +// one is built and comes back to this view, and a pasted list of ids, saved as a stored id set. import { useEffect, useState } from "react"; import { Plus } from "lucide-react"; import type { CohortDef } from "../../lib/types.ts"; @@ -13,6 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../_ui/dialog" import { Input } from "../_ui/input"; import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; import { TB } from "../_ui/toolbar.ts"; +import { cn } from "../../lib/cn"; import { Textarea } from "../_ui/textarea"; import { api } from "./api.ts"; import type { CellExplorerDataset } from "../../lib/cell-explorer.ts"; @@ -36,13 +37,15 @@ export interface LibraryState { examples: { name: string; desc: string; def: CohortDef }[]; } -type Source = "analysis" | "library" | "explore" | "cells"; +type Source = "analysis" | "library" | "explore" | "list" | "cells"; -export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, onLogin, onClose, cells }: { +export function AddCohortDialog({ target, analysis, library, studies, onAdd, onExplore, onLogin, onClose, cells }: { target: AddCohortTarget | null; /** the analysis's cohorts in its order (the colour each has in its summary), with their sizes once known */ analysis: { name: string; def: CohortDef; count: number | null; views: string[] }[]; library: LibraryState; + /** the studies the analysis uses: a pasted bare id is looked up in each */ + studies: string[]; /** add a cohort to the view: an analysis cohort by name, or a library one with its definition */ onAdd: (name: string, def?: CohortDef) => void; onExplore: () => void; @@ -110,6 +113,7 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o In this analysis {available.length} Library Build in Explore + From a list of ids {cells && From Cell Explorer} @@ -160,6 +164,8 @@ export function AddCohortDialog({ target, analysis, library, onAdd, onExplore, o )} + {source === "list" && } {source === "cells" && cells && } } @@ -225,3 +231,100 @@ function CellsSource({ cells, full, replaces, addTitle, nameFor, onAdd }: { ); } + +/** One id per line, or separated by commas, semicolons, tabs or spaces. */ +const splitIds = (text: string): string[] => text.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean); + +interface SavedSet { + set: { id: string; name: string; level: "sample" | "patient"; studies: string[]; count: number }; + unmatched: number; unmatchedExamples: string[]; duplicates: number; multiStudy: number; + refused: { study: string; reason: string }[]; +} + +/** + * A cohort from a pasted list of sample or patient ids. The list is matched to the studies' own + * cases and saved on the server as a stored id set (lib/id-set-store.ts); the cohort names the + * set by its id, so the document and its link stay small however long the list is. Needs a + * sign-in, since the set is its saver's. The text stays in this component while it is typed. + */ +function ListSource({ studies, saved, full, replaces, addTitle, nameFor, onAdd, onLogin }: { + studies: string[]; saved: LibraryState["saved"]; full: boolean; replaces: boolean; addTitle: (what: string) => string; + nameFor: (name: string, def: CohortDef) => string; onAdd: (name: string, def?: CohortDef) => void; onLogin: () => void; +}) { + const [text, setText] = useState(""); + const [level, setLevel] = useState<"sample" | "patient">("sample"); + const [name, setName] = useState("from a list"); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [done, setDone] = useState(null); + if (saved === "off") return

A list of ids is kept on the server as a stored id set, which needs a sign-in, and this deployment has none.

; + if (saved === "locked") return

to add a cohort from a list of ids: the list is kept on the server as your stored id set.

; + const ids = splitIds(text); + const save = async () => { + setBusy(true); setErr(null); setDone(null); + try { + const res = await api.idSets.create({ name: name.trim() || "from a list", level, studies, ids }); + const j = await res.json(); + if (!res.ok || j.error) throw new Error(j.error || res.statusText); + setDone(j); + } catch (e) { setErr(String((e as Error)?.message || e)); } + finally { setBusy(false); } + }; + const def: CohortDef | null = done ? { + all: [{ kind: done.set.level === "sample" ? "sampleList" : "patientList", set: done.set.id, label: done.set.name, n: done.set.count }], + ...(done.set.studies.length > 1 ? { studies: done.set.studies } : done.set.studies[0] !== studies[0] ? { study: done.set.studies[0] } : {}), + } : null; + const as = def ? nameFor(done!.set.name, def) : name.trim(); + const noun = level === "sample" ? "sample" : "patient"; + return ( +
+

+ Paste {noun} ids, one per line or separated by commas or spaces, as study:id or bare ids, which are looked + up in {studies.length ? studies.join(", ") : "the analysis's study"}. The ids are matched to the {noun}s there and kept on + the server as a stored id set; the cohort names the set, so the analysis and its links stay small however long the list is. +

+
+
+ ids are + { setLevel(v as "sample" | "patient"); setDone(null); }}> + + samples + patients + + +
+
+