From dea0635b124c56074b1bb13e40c133d212d626ce Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 11:34:08 -0700 Subject: [PATCH 01/11] feat: serve samples from the typescript server --- .../__tests__/CreateAnalysisForm.test.tsx | 4 +- .../__tests__/AnalysisList.test.tsx | 12 +- .../src/samples/__tests__/queries.test.tsx | 21 +- .../components/Create/CreateSample.tsx | 2 +- .../Create/CreateSampleFromFile.tsx | 2 +- .../Create/__tests__/CreateSample.test.tsx | 71 +- .../__tests__/CreateSampleFromFile.test.tsx | 51 +- .../Detail/__tests__/DeleteSample.test.tsx | 8 +- .../Detail/__tests__/SampleRights.test.tsx | 47 +- .../web/src/samples/components/EditSample.tsx | 3 +- .../components/__tests__/EditSample.test.tsx | 21 +- .../__tests__/SampleLabelsSelector.test.tsx | 52 +- .../components/__tests__/SamplesList.test.tsx | 65 +- apps/web/src/samples/queries.ts | 82 +- apps/web/src/samples/types.ts | 136 +- .../server/__tests__/authorization.test.ts | 7 + apps/web/src/server/db/schema/analyses.ts | 11 +- apps/web/src/server/db/schema/samples.ts | 122 +- apps/web/src/server/jobs/data.test.ts | 9 +- apps/web/src/server/jobs/data.ts | 36 +- apps/web/src/server/samples/data.test.ts | 688 +++++++++ apps/web/src/server/samples/data.ts | 1229 +++++++++++++++++ apps/web/src/server/samples/functions.test.ts | 238 ++++ apps/web/src/server/samples/functions.ts | 279 ++++ apps/web/src/server/subtraction/data.test.ts | 12 +- apps/web/src/server/uploads/data.ts | 47 +- apps/web/src/tests/api/samples.ts | 164 --- apps/web/src/tests/fake/samples.ts | 4 +- apps/web/src/tests/server-fn/samples.ts | 105 ++ apps/web/src/tests/setup.tsx | 12 + docs/database.md | 12 +- packages/contracts/src/groups.ts | 11 + packages/contracts/src/index.ts | 2 + packages/contracts/src/labels.ts | 14 + packages/contracts/src/samples.ts | 162 ++- packages/contracts/src/subtractions.ts | 9 + 36 files changed, 3181 insertions(+), 569 deletions(-) create mode 100644 apps/web/src/server/samples/data.test.ts create mode 100644 apps/web/src/server/samples/data.ts create mode 100644 apps/web/src/server/samples/functions.test.ts create mode 100644 apps/web/src/server/samples/functions.ts delete mode 100644 apps/web/src/tests/api/samples.ts create mode 100644 apps/web/src/tests/server-fn/samples.ts create mode 100644 packages/contracts/src/groups.ts create mode 100644 packages/contracts/src/labels.ts diff --git a/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx b/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx index 78cd00a41..cd083ce50 100644 --- a/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx +++ b/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx @@ -2,10 +2,10 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mockApiCreateAnalysis } from "@tests/api/analyses"; import { mockApiListIndexes } from "@tests/api/indexes"; -import { mockApiGetSampleDetail } from "@tests/api/samples"; import { createFakeAnalysisMinimal } from "@tests/fake/analyses"; import { createFakeIndexMinimal } from "@tests/fake/indexes"; import { createFakeSample } from "@tests/fake/samples"; +import { mockGetSampleDetail } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { renderWithRouter } from "@tests/setup"; import { describe, expect, it, vi } from "vitest"; @@ -23,7 +23,7 @@ async function renderForm(indexId?: number) { mockApiListIndexes([index]); mockListSubtractionsShortlist([]); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); await renderWithRouter( ", () => { it("should show analysis creation when user is full admin", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -45,7 +45,7 @@ describe("", () => { const account = createFakeAccount({ administrator_role: null }); sample.user.id = account.id; mockGetAccount(account); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -56,7 +56,7 @@ describe("", () => { sample.group = at(account.groups, 0); sample.group_write = true; mockGetAccount(account); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -66,7 +66,7 @@ describe("", () => { const account = createFakeAccount({ administrator_role: null }); sample.all_write = true; mockGetAccount(account); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -76,7 +76,7 @@ describe("", () => { sample.all_write = false; sample.group_write = false; mockGetAccount(createFakeAccount({ administrator_role: null })); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); renderList(); expect(await screen.findByText("Pathoscope")).toBeInTheDocument(); diff --git a/apps/web/src/samples/__tests__/queries.test.tsx b/apps/web/src/samples/__tests__/queries.test.tsx index 0ba8d5057..31471bcfe 100644 --- a/apps/web/src/samples/__tests__/queries.test.tsx +++ b/apps/web/src/samples/__tests__/queries.test.tsx @@ -1,30 +1,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; -import { mockApiCreateSample } from "@tests/api/samples"; +import { mockCreateSample } from "@tests/server-fn/samples"; import { fileQueryKeys } from "@uploads/keys"; -import nock from "nock"; import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { useCreateSample } from "../queries"; describe("useCreateSample()", () => { - afterEach(() => nock.cleanAll()); - it("invalidates the reads selector on success so reserved files leave it", async () => { const queryClient = new QueryClient(); const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); - const scope = mockApiCreateSample( - "Sample A", - "", - "", - "", - "normal", - [1], - [], - [], - null, - ); + mockCreateSample(); function wrapper({ children }: { children: ReactNode }) { return ( @@ -53,7 +40,5 @@ describe("useCreateSample()", () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: [...fileQueryKeys.infiniteLists(), "reads"], }); - - scope.done(); }); }); diff --git a/apps/web/src/samples/components/Create/CreateSample.tsx b/apps/web/src/samples/components/Create/CreateSample.tsx index 6731dd225..7b46c6ee5 100644 --- a/apps/web/src/samples/components/Create/CreateSample.tsx +++ b/apps/web/src/samples/components/Create/CreateSample.tsx @@ -174,7 +174,7 @@ export default function CreateSample({ labels }: CreateSampleProps) { Create Sample - {mutation.isError && mutation.error.response?.body.message} + {mutation.isError && mutation.error.message} diff --git a/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx b/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx index 769e593df..b753e087d 100644 --- a/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx +++ b/apps/web/src/samples/components/Create/CreateSampleFromFile.tsx @@ -145,7 +145,7 @@ function CreateSampleFromFileForm({ Create Sample - {mutation.isError && mutation.error.response?.body.message} + {mutation.isError && mutation.error.message} {(isErrorGroups && !groups) || (isErrorAccount && !account) ? ( diff --git a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx index 5c9bfb67d..f83f780f3 100644 --- a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx +++ b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx @@ -1,11 +1,11 @@ -import { screen, within } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiCreateSample } from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeFile } from "@tests/fake/files"; import { createFakeLabel } from "@tests/fake/labels"; import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { mockListGroups } from "@tests/server-fn/groups"; +import { mockCreateSample } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockFindUploads, uploadServerFnMocks } from "@tests/server-fn/uploads"; import { mockGetAccount } from "@tests/server-fn/users"; @@ -107,17 +107,7 @@ describe("", () => { mockFindUploads([file]); mockListSubtractionsShortlist([]); - const scope = mockApiCreateSample( - "Sample A", - "", - "", - "", - "normal", - [file.id], - [], - [], - null, - ); + const createSample = mockCreateSample(); await renderPage(); @@ -130,7 +120,18 @@ describe("", () => { await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Sample A", + library_type: "normal", + files: [file.id], + labels: [], + subtractions: [], + group: null, + }), + }), + ); }); it("should submit when all form fields complete", async () => { @@ -141,17 +142,7 @@ describe("", () => { mockFindUploads(files); mockListSubtractionsShortlist([subtractionShortlist]); - const scope = mockApiCreateSample( - "Sample T", - "Clone AB", - "Apple", - "Earth", - "normal", - [firstFile.id, secondFile.id], - [firstLabel.id], - [subtractionShortlist.id], - null, - ); + const createSample = mockCreateSample(); await renderPage(); @@ -194,7 +185,21 @@ describe("", () => { // Submit. await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Sample T", + isolate: "Clone AB", + host: "Apple", + locale: "Earth", + library_type: "normal", + files: [firstFile.id, secondFile.id], + labels: [firstLabel.id], + subtractions: [subtractionShortlist.id], + group: null, + }), + }), + ); }); it("should show and hide the metadata fields", async () => { @@ -349,17 +354,7 @@ describe("", () => { mockFindUploads([file]); mockListSubtractionsShortlist([]); - const scope = mockApiCreateSample( - "Sample A", - "", - "", - "", - "normal", - [file.id], - [], - [], - null, - ); + mockCreateSample(); await renderPage(); @@ -372,8 +367,6 @@ describe("", () => { expect(await screen.findByText("Sample created")).toBeInTheDocument(); expect(screen.getByLabelText("Name")).toHaveValue(""); - - scope.done(); }); it("should be able to swap read orientation", async () => { diff --git a/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx b/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx index 50ea302fe..3cd34f336 100644 --- a/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx +++ b/apps/web/src/samples/components/Create/__tests__/CreateSampleFromFile.test.tsx @@ -1,11 +1,11 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiCreateSample } from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeFile } from "@tests/fake/files"; import { createFakeLabel } from "@tests/fake/labels"; import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { mockListGroups } from "@tests/server-fn/groups"; +import { mockCreateSample } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockGetAccount } from "@tests/server-fn/users"; import { renderWithRouter } from "@tests/setup"; @@ -80,17 +80,7 @@ describe("", () => { const left = createFakeFile({ name: "sample_one_R1.fastq.gz" }); const right = createFakeFile({ name: "sample_one_R2.fastq.gz" }); - const scope = mockApiCreateSample( - "sample_one", - "", - "", - "", - "normal", - [left.id, right.id], - [], - [], - null, - ); + const createSample = mockCreateSample(); await renderDialog(right, [left, right]); @@ -103,23 +93,20 @@ describe("", () => { await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "sample_one", + files: [left.id, right.id], + }), + }), + ); }); it("should submit with labels and subtractions", async () => { const file = createFakeFile({ name: "sample_one.fastq.gz" }); - const scope = mockApiCreateSample( - "Sample A", - "Clone AB", - "Apple", - "Earth", - "normal", - [file.id], - [label.id], - [subtractionShortlist.id], - null, - ); + const createSample = mockCreateSample(); await renderDialog(file, [file]); @@ -148,6 +135,18 @@ describe("", () => { await submitForm(); - scope.done(); + await waitFor(() => + expect(createSample).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: "Sample A", + isolate: "Clone AB", + host: "Apple", + locale: "Earth", + files: [file.id], + labels: [label.id], + subtractions: [subtractionShortlist.id], + }), + }), + ); }); }); diff --git a/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx b/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx index 2cc974e6b..f607ca23b 100644 --- a/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx @@ -1,7 +1,7 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiRemoveSample } from "@tests/api/samples"; import { createFakeJobNested } from "@tests/fake/jobs"; +import { mockRemoveSample } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it } from "vitest"; @@ -49,7 +49,7 @@ describe("", () => { }); it("should handle submit when confirm button is clicked", async () => { - const scope = mockApiRemoveSample(props.id); + const deleteSample = mockRemoveSample(); await renderWithRouter(); await userEvent.click(screen.getByRole("button")); @@ -57,6 +57,6 @@ describe("", () => { await userEvent.click(screen.getByText("Confirm")); - scope.done(); + await waitFor(() => expect(deleteSample).toHaveBeenCalled()); }); }); diff --git a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx index a4ba3a94c..d70867c3b 100644 --- a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx @@ -1,18 +1,17 @@ import SampleRights from "@samples/components/Detail/SampleRights"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { - mockApiGetSampleDetail, - mockApiUpdateSampleRights, -} from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeGroup } from "@tests/fake/groups"; import { createFakeSample } from "@tests/fake/samples"; import { mockListGroups } from "@tests/server-fn/groups"; +import { + mockGetSampleDetail, + mockUpdateSampleRights, +} from "@tests/server-fn/samples"; import { mockGetAccount } from "@tests/server-fn/users"; import { renderWithProviders } from "@tests/setup"; -import nock from "nock"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; describe("", () => { let sample: ReturnType; @@ -26,12 +25,10 @@ describe("", () => { group_write: false, }); group = createFakeGroup(); - mockApiGetSampleDetail(sample); + mockGetSampleDetail(sample); mockListGroups([group]); }); - afterEach(() => nock.cleanAll()); - it("should render", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); renderWithProviders(); @@ -51,43 +48,49 @@ describe("", () => { it("should handle group change when input is changed", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - const scope = mockApiUpdateSampleRights(sample, { group: group.id }); + const updateSampleRights = mockUpdateSampleRights(sample); renderWithProviders(); expect(await screen.findByText("Sample Rights")).toBeInTheDocument(); await userEvent.click(screen.getByLabelText("Group")); await userEvent.click(screen.getByRole("option", { name: group.name })); - await scope.done(); + await waitFor(() => + expect(updateSampleRights).toHaveBeenCalledWith({ + data: expect.objectContaining({ group: group.id }), + }), + ); }); it("should handle group rights change when input is changed", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - const scope = mockApiUpdateSampleRights(sample, { - group_read: true, - group_write: true, - }); + const updateSampleRights = mockUpdateSampleRights(sample); renderWithProviders(); expect(await screen.findByText("Sample Rights")).toBeInTheDocument(); await userEvent.click(screen.getByLabelText("Group Rights")); await userEvent.click(screen.getByRole("option", { name: "Read & write" })); - await scope.done(); + await waitFor(() => + expect(updateSampleRights).toHaveBeenCalledWith({ + data: expect.objectContaining({ group_read: true, group_write: true }), + }), + ); }); it("should handle all users' rights change when input is changed", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - const scope = mockApiUpdateSampleRights(sample, { - all_read: true, - all_write: true, - }); + const updateSampleRights = mockUpdateSampleRights(sample); renderWithProviders(); expect(await screen.findByText("Sample Rights")).toBeInTheDocument(); await userEvent.click(screen.getByLabelText("All Users' Rights")); await userEvent.click(screen.getByRole("option", { name: "Read & write" })); - await scope.done(); + await waitFor(() => + expect(updateSampleRights).toHaveBeenCalledWith({ + data: expect.objectContaining({ all_read: true, all_write: true }), + }), + ); }); }); diff --git a/apps/web/src/samples/components/EditSample.tsx b/apps/web/src/samples/components/EditSample.tsx index 622b572ca..408c65a4d 100644 --- a/apps/web/src/samples/components/EditSample.tsx +++ b/apps/web/src/samples/components/EditSample.tsx @@ -71,8 +71,7 @@ export default function EditSample({ {...register("name")} /> - {mutation.isError && - (mutation.error.response.body.message || "Required Field")} + {mutation.isError && (mutation.error.message || "Required Field")} diff --git a/apps/web/src/samples/components/__tests__/EditSample.test.tsx b/apps/web/src/samples/components/__tests__/EditSample.test.tsx index 9b6d7a85a..f13da2b6a 100644 --- a/apps/web/src/samples/components/__tests__/EditSample.test.tsx +++ b/apps/web/src/samples/components/__tests__/EditSample.test.tsx @@ -1,7 +1,7 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { mockApiEditSample } from "@tests/api/samples"; import { createFakeSample } from "@tests/fake/samples"; +import { mockEditSample } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -60,14 +60,13 @@ describe("", () => { }); it("should update sample when form is submitted", async () => { - const scope = mockApiEditSample( - sample, - "newName", - "newIsolate", - "newHost", - "newLocale", - "newNotes", - ); + const updateSample = mockEditSample(sample, { + name: "newName", + isolate: "newIsolate", + host: "newHost", + locale: "newLocale", + notes: "newNotes", + }); await renderWithRouter(); const nameInput = screen.getByLabelText("Name"); @@ -91,6 +90,6 @@ describe("", () => { await userEvent.type(notesInput, "newNotes"); await userEvent.click(screen.getByText("Save")); - scope.done(); + await waitFor(() => expect(updateSample).toHaveBeenCalled()); }); }); diff --git a/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx b/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx index efb4ffa19..687f5d244 100644 --- a/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx +++ b/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx @@ -1,27 +1,18 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createFakeSampleMinimal } from "@tests/fake/samples"; +import { sampleServerFnMocks } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; -import nock from "nock"; import type { ComponentProps } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import SampleLabelsSelector from "../SampleLabelsSelector"; -/** - * Mocks the sample-update endpoint and captures the request body so tests can - * assert which labels were sent for each selected sample. - */ -function mockApiUpdateSampleLabels(sampleId: number) { - const captured: { body?: { labels: number[] } } = {}; - - nock("http://localhost") - .patch(`/api/samples/${sampleId}`) - .reply(200, (_uri, body: { labels: number[] }) => { - captured.body = body; - return {}; - }); - - return captured; +/** The labels sent to `updateSample` for the given sample, if it was called. */ +function labelsSentFor(sampleId: number): number[] | undefined { + const call = sampleServerFnMocks.updateSample.mock.calls.find( + ([arg]) => arg?.data?.sampleId === sampleId, + ); + return call?.[0]?.data?.labels; } describe("", () => { @@ -85,9 +76,11 @@ describe("", () => { }); describe("toggling a label with mixed labels across the selection", () => { - afterEach(() => nock.cleanAll()); - beforeEach(() => { + // The bulk toggle patches each sample through the updateSample server + // function; let it resolve so the mutation settles. + sampleServerFnMocks.updateSample.mockResolvedValue({}); + // "test" (id 1) is on both samples, "label" (id 2) is on one only, so // the selection has labels in mixed states. props.selectedSamples = [ @@ -137,38 +130,29 @@ describe("", () => { }); it("removes a fully-applied label from every sample", async () => { - const foo = mockApiUpdateSampleLabels(1); - const bar = mockApiUpdateSampleLabels(2); - await openSelectorAndClick("test"); await waitFor(() => { - expect(foo.body?.labels).toEqual([2]); - expect(bar.body?.labels).toEqual([]); + expect(labelsSentFor(1)).toEqual([2]); + expect(labelsSentFor(2)).toEqual([]); }); }); it("adds a partially-applied label to every sample", async () => { - const foo = mockApiUpdateSampleLabels(1); - const bar = mockApiUpdateSampleLabels(2); - await openSelectorAndClick("label"); await waitFor(() => { - expect(foo.body?.labels).toEqual([1, 2]); - expect(bar.body?.labels).toEqual([1, 2]); + expect(labelsSentFor(1)).toEqual([1, 2]); + expect(labelsSentFor(2)).toEqual([1, 2]); }); }); it("adds an unapplied label to every sample", async () => { - const foo = mockApiUpdateSampleLabels(1); - const bar = mockApiUpdateSampleLabels(2); - await openSelectorAndClick("bar"); await waitFor(() => { - expect(foo.body?.labels).toEqual([1, 2, 3]); - expect(bar.body?.labels).toEqual([1, 3]); + expect(labelsSentFor(1)).toEqual([1, 2, 3]); + expect(labelsSentFor(2)).toEqual([1, 3]); }); }); }); diff --git a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx index 8aa709c59..245831480 100644 --- a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx +++ b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx @@ -1,7 +1,6 @@ import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { mockApiListIndexes } from "@tests/api/indexes"; -import { mockApiGetSamples } from "@tests/api/samples"; import { createFakeAccount } from "@tests/fake/account"; import { createFakeHmmSearchResults } from "@tests/fake/hmm"; import { createFakeIndexMinimal } from "@tests/fake/indexes"; @@ -11,6 +10,7 @@ import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { createFakeUserNested } from "@tests/fake/user"; import { mockFindHmms } from "@tests/server-fn/hmm"; import { mockFindLabels } from "@tests/server-fn/labels"; +import { mockGetSamplePages, mockGetSamples } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockGetAccount, mockListUsers } from "@tests/server-fn/users"; import { at, renderWithRouter } from "@tests/setup"; @@ -65,22 +65,7 @@ function mockApiGetSamplePages() { nock.cleanAll(); - documents.forEach((document, index) => { - const page = index + 1; - - nock("http://localhost") - .persist() - .get("/api/samples") - .query((query) => Number(query.page ?? 1) === page) - .reply(200, { - page, - page_count: documents.length, - per_page: 1, - total_count: documents.length, - found_count: documents.length, - documents: [document], - }); - }); + mockGetSamplePages(documents.map((document) => [document])); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); @@ -103,7 +88,7 @@ function mockApiGetSampleRange(names: string[]) { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockApiGetSamples(documents); + mockGetSamples(documents); return documents; } @@ -125,7 +110,7 @@ describe("", () => { ]; mockListUsers(users); mockFindLabels(labels); - mockApiGetSamples(samples); + mockGetSamples(samples); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); @@ -144,9 +129,9 @@ describe("", () => { }); it("should call onChange when search input changes in toolbar", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -181,7 +166,7 @@ describe("", () => { describe("label filtering", () => { it("should show a chip for a label selected in the dropdown", async () => { - mockApiGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -202,8 +187,8 @@ describe("", () => { }); it("should remove every chip when the dropdown is cleared", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -230,8 +215,8 @@ describe("", () => { it("should show a chip for the search term", async () => { // One interceptor per samples fetch: the initial empty-term render plus // the single refetch the debounced toolbar commits for "Foo". - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -246,7 +231,7 @@ describe("", () => { describe("workflow filtering", () => { it("should show a chip for a workflow state selected in the dropdown", async () => { - mockApiGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -267,8 +252,8 @@ describe("", () => { }); it("should keep the menu open and check both states toggled for one workflow", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -292,8 +277,8 @@ describe("", () => { }); it("should remove every workflow chip when the dropdown is cleared", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -319,7 +304,7 @@ describe("", () => { describe("user filtering", () => { it("should show a chip for a user selected in the dropdown", async () => { - mockApiGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -337,8 +322,8 @@ describe("", () => { }); it("should show a chip for each of several users selected at once", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -399,8 +384,8 @@ describe("", () => { }); it("should remove every chip when the dropdown is cleared", async () => { - mockApiGetSamples(samples); - mockApiGetSamples(samples); + mockGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -445,7 +430,7 @@ describe("", () => { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockApiGetSamples(samples, { found_count: 2, total_count: 17 }); + mockGetSamples(samples, { found_count: 2, total_count: 17 }); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -552,7 +537,7 @@ describe("", () => { }); it("should clear the selection when a filter changes", async () => { - mockApiGetSamples(samples); + mockGetSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -840,7 +825,7 @@ describe("", () => { // The default samples interceptor is already registered, and it would // answer the request before any empty one added here. nock.cleanAll(); - mockApiGetSamples([]).persist(); + mockGetSamples([]); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); diff --git a/apps/web/src/samples/queries.ts b/apps/web/src/samples/queries.ts index bc84085b4..1d14bcfe3 100644 --- a/apps/web/src/samples/queries.ts +++ b/apps/web/src/samples/queries.ts @@ -1,6 +1,13 @@ -import { apiClient } from "@app/api"; import type { LabelNested } from "@labels/types"; import { samplesQueryKeys } from "@samples/keys"; +import { + createSample as createSampleFn, + deleteSample as deleteSampleFn, + findSamples as findSamplesFn, + getSample as getSampleFn, + updateSample as updateSampleFn, + updateSampleRights as updateSampleRightsFn, +} from "@server/samples/functions"; import { keepPreviousData, queryOptions, @@ -11,13 +18,12 @@ import { } from "@tanstack/react-query"; import { fileQueryKeys } from "@uploads/keys"; import { union } from "es-toolkit"; -import type { ErrorResponse } from "@/types/api"; import type { CreateSampleRequest, + LibraryType, Sample, SampleMinimal, SampleRightsUpdate, - SampleRightsUpdateReturn, SampleSearchResult, SampleUpdate, } from "./types"; @@ -34,10 +40,9 @@ export type SampleLabel = LabelNested & { * Shared by the single-sample update hook and the bulk label-update hook. */ function updateSample(sampleId: number, update: SampleUpdate): Promise { - return apiClient - .patch(`/samples/${sampleId}`) - .send(update) - .then((response) => response.body); + return updateSampleFn({ + data: { sampleId, ...update }, + }) as Promise; } /** @@ -58,7 +63,7 @@ export function useListSamples( workflows?: string[], users?: number[], ) { - return useQuery({ + return useQuery({ queryKey: samplesQueryKeys.list([ page, per_page, @@ -68,29 +73,24 @@ export function useListSamples( users, ]), queryFn: () => - apiClient - .get("/samples") - .query({ + findSamplesFn({ + data: { page, per_page, - find: term, - label: labels, - workflows, - user: users, - }) - .then((res) => { - const { documents, ...rest } = res.body; - return { ...rest, items: documents }; - }), + term: term ?? "", + labels: labels ?? [], + workflows: workflows ?? [], + users: users ?? [], + }, + }) as Promise, placeholderData: keepPreviousData, }); } export function sampleQueryOptions(sampleId: number) { - return queryOptions({ + return queryOptions({ queryKey: samplesQueryKeys.detail(sampleId), - queryFn: () => - apiClient.get(`/samples/${sampleId}`).then((res) => res.body), + queryFn: () => getSampleFn({ data: { sampleId } }) as Promise, }); } @@ -130,20 +130,19 @@ function createSample({ labels, group, }: CreateSampleRequest): Promise { - return apiClient - .post("/samples") - .send({ + return createSampleFn({ + data: { name, isolate, host, locale, + library_type: libraryType as LibraryType, subtractions, files, - library_type: libraryType, labels, group, - }) - .then((res) => res.body); + }, + }) as Promise; } /** @@ -154,7 +153,7 @@ function createSample({ export function useCreateSample() { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: createSample, onSuccess: () => { // The created sample reserves its read files, so the server stops @@ -175,7 +174,7 @@ export function useCreateSample() { export function useUpdateSample(sampleId: number) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ update }) => updateSample(sampleId, update), onSuccess: () => { queryClient.invalidateQueries({ @@ -191,11 +190,9 @@ export function useUpdateSample(sampleId: number) { * @returns A mutator for removing a sample */ export function useRemoveSample() { - return useMutation({ + return useMutation({ mutationFn: ({ sampleId }) => - apiClient - .delete(`/samples/${sampleId}`) - .then((response) => response.body), + deleteSampleFn({ data: { sampleId } }) as Promise, }); } @@ -205,16 +202,11 @@ export function useRemoveSample() { * @returns A mutator for updating a samples rights */ export function useUpdateSampleRights(sampleId: number) { - return useMutation< - SampleRightsUpdateReturn, - unknown, - { update: SampleRightsUpdate } - >({ + return useMutation({ mutationFn: ({ update }) => - apiClient - .patch(`/samples/${sampleId}/rights`) - .send(update) - .then((response) => response.body), + updateSampleRightsFn({ + data: { sampleId, ...update }, + }) as Promise, }); } @@ -235,7 +227,7 @@ export function useUpdateLabel( ) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: (labelId) => { const clicked = selectedLabels.find((label) => label.id === labelId); const allLabeled = clicked?.allLabeled === true; diff --git a/apps/web/src/samples/types.ts b/apps/web/src/samples/types.ts index 7c9fa7f1c..330321cbc 100644 --- a/apps/web/src/samples/types.ts +++ b/apps/web/src/samples/types.ts @@ -2,38 +2,24 @@ * Sample Types * * @remark - * Types in this file represent the data returned from the API, or are useful for working - * with sample data. + * The sample wire shapes live in `@virtool/contracts`, where the server data + * layer that produces them and the components that render them can both reach + * them. They are re-exported here so the feature's call sites import from one + * place. Client-only request shapes stay owned here. */ -import type { GroupMinimal } from "@groups/types"; -import type { ServerJobNested } from "@jobs/types"; -import type { LabelNested } from "@labels/types"; -import type { SubtractionNested } from "@subtraction/types"; -import type { UserNested } from "@users/types"; -import type { SearchResult } from "@/types/api"; - -/* All workflow states */ -export type WorkflowState = "complete" | "pending" | "none" | "incompatible"; - -/* All Library types */ -export type LibraryType = "amplicon" | "srna" | "other" | "normal"; - -/* All workflow states for a sample */ -export type SampleWorkflows = { - /** The state of Nuvs workflows */ - nuvs: WorkflowState; - - /** The state of Pathoscope workflows */ - pathoscope: WorkflowState; -}; - -export type SampleArtifact = { - id: number; - download_url: string; - name: string; - size: number; -}; +export type { + LibraryType, + Quality, + Read, + Sample, + SampleArtifact, + SampleMinimal, + SampleRightsUpdate, + SampleSearchResult, + SampleWorkflows, + WorkflowState, +} from "@virtool/contracts"; /* A Sample ID */ export type SampleID = { @@ -45,77 +31,6 @@ export type SampleNested = SampleID & { name: string; }; -/* Minimal Sample used for resource listings */ -export type SampleMinimal = SampleNested & { - created_at: string; - host: string; - isolate: string; - /* Information about the job associated with the sample */ - job?: ServerJobNested; - /* Label associated with the sample */ - labels: Array; - library_type: LibraryType; - notes: string; - nuvs: boolean | string; - pathoscope: boolean | string; - ready: boolean; - /* The user who created the sample */ - user: UserNested; - workflows: SampleWorkflows; -}; - -/* The quality charts associated with the sample */ -export type Quality = { - /* Data for bases chart */ - bases: Array>; - /* Data for composition chart */ - composition: Array>; - /* The read count of the sample */ - count: number; - encoding: string; - /* The GC content of the sample (percentage) */ - gc: number; - /* The length range */ - length: Array; - /* Data for sequences chart */ - sequences: Array; -}; - -/* The read file used to create the sample */ -export type Read = { - download_url: string; - id: number; - name: string; - name_on_disk: string; - sample: number; - size: number; - upload?: File; - uploaded_at: string; -}; - -/* A complete sample */ -export type Sample = SampleMinimal & { - all_read: boolean; - all_write: boolean; - artifacts: Array; - format: string; - group: GroupMinimal | null; - group_read: boolean; - group_write: boolean; - hold: boolean; - is_legacy: boolean; - locale: string; - paired: boolean; - quality: Quality | null; - reads: Array; - subtractions: Array; -}; - -/* Sample search results from the API */ -export type SampleSearchResult = SearchResult & { - items: Array; -}; - /** The fields sent to the API to create a sample */ export type CreateSampleRequest = { files: number[]; @@ -138,22 +53,3 @@ export type SampleUpdate = { notes?: string; subtractions?: number[]; }; - -/** Data returned from API on sample rights update */ -export type SampleRightsUpdateReturn = { - all_read: boolean; - all_write: boolean; - group: number | string | null; - group_read: boolean; - group_write: boolean; - user: { [key: string]: string }; -}; - -/** Fields that can be changed when updating a sample's rights */ -export type SampleRightsUpdate = { - group?: number | string | null; - group_read?: boolean; - group_write?: boolean; - all_read?: boolean; - all_write?: boolean; -}; diff --git a/apps/web/src/server/__tests__/authorization.test.ts b/apps/web/src/server/__tests__/authorization.test.ts index 7fc121322..2640996c7 100644 --- a/apps/web/src/server/__tests__/authorization.test.ts +++ b/apps/web/src/server/__tests__/authorization.test.ts @@ -113,6 +113,13 @@ const MODULES = [ "../root/functions.ts?tss-serverfn-split" )) as SplitServerFnModule, }, + { + path: "../samples/functions.ts", + fns: await import("../samples/functions"), + handlers: (await import( + "../samples/functions.ts?tss-serverfn-split" + )) as SplitServerFnModule, + }, { path: "../settings/functions.ts", fns: await import("../settings/functions"), diff --git a/apps/web/src/server/db/schema/analyses.ts b/apps/web/src/server/db/schema/analyses.ts index b8c5e8452..c43a25318 100644 --- a/apps/web/src/server/db/schema/analyses.ts +++ b/apps/web/src/server/db/schema/analyses.ts @@ -1,13 +1,16 @@ // Partial read-only mirror of the `analyses` table managed by the upstream -// Python service via Alembic. Only the columns needed to reconstruct a job's -// `args` are declared here — the analyses domain is not otherwise served from -// this side yet. Keep in sync with +// Python service via Alembic. Only the columns needed here are declared — to +// reconstruct a job's `args`, and to derive and filter samples by their +// workflow tags. Keep in sync with // `../../../../../../virtool/virtool/analyses/sql.py`. -import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core"; +import { bigint, boolean, integer, pgTable, text } from "drizzle-orm/pg-core"; export const analyses = pgTable("analyses", { id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), legacy_id: text("legacy_id").unique(), job_id: integer("job_id"), + sample_id: bigint("sample_id", { mode: "number" }), + workflow: text("workflow").notNull(), + ready: boolean("ready").notNull(), }); diff --git a/apps/web/src/server/db/schema/samples.ts b/apps/web/src/server/db/schema/samples.ts index d64f984dc..224da0cf2 100644 --- a/apps/web/src/server/db/schema/samples.ts +++ b/apps/web/src/server/db/schema/samples.ts @@ -1,19 +1,129 @@ -// Partial read-only mirror of the `legacy_samples` table and the -// `legacy_sample_subtractions` join table managed by the upstream Python service -// via Alembic. Only the columns needed from this side are declared. Keep in sync -// with `../../../../../../virtool/virtool/samples/sql.py`. +// Read-only mirror of the `legacy_samples` table and its join / file tables, +// managed by the upstream Python service via Alembic. Do not generate or push +// migrations from this side. Keep the columns in sync with +// `../../../../../../virtool/virtool/samples/sql.py`. -import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core"; +import { + bigint, + boolean, + integer, + jsonb, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; +/** A sample's FastQC quality report, stored as JSONB and never queried. */ +export type SampleQuality = { + bases: number[][]; + composition: number[][]; + count: number; + encoding: string; + gc: number; + length: number[]; + sequences: number[]; +}; + +// The columns Python's model gives a `default=` are mirrored with `$defaultFn`, +// never `.default()`: the real columns carry no server default, so the value has +// to be supplied on insert from this side too. `name`, `library_type`, and +// `created_at` have no Python default and stay required. export const legacySamples = pgTable("legacy_samples", { id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), - name: text("name"), + legacy_id: text("legacy_id").unique(), + name: text("name").notNull(), + host: text("host") + .$defaultFn(() => "") + .notNull(), + isolate: text("isolate") + .$defaultFn(() => "") + .notNull(), + locale: text("locale") + .$defaultFn(() => "") + .notNull(), + notes: text("notes") + .$defaultFn(() => "") + .notNull(), + library_type: text("library_type").notNull(), + format: text("format") + .$defaultFn(() => "fastq") + .notNull(), + group_id: integer("group_id"), + quality: jsonb("quality").$type(), + created_at: timestamp("created_at").notNull(), + paired: boolean("paired") + .$defaultFn(() => false) + .notNull(), + ready: boolean("ready") + .$defaultFn(() => false) + .notNull(), + hold: boolean("hold") + .$defaultFn(() => true) + .notNull(), + is_legacy: boolean("is_legacy") + .$defaultFn(() => false) + .notNull(), + all_read: boolean("all_read") + .$defaultFn(() => false) + .notNull(), + all_write: boolean("all_write") + .$defaultFn(() => false) + .notNull(), + group_read: boolean("group_read") + .$defaultFn(() => false) + .notNull(), + group_write: boolean("group_write") + .$defaultFn(() => false) + .notNull(), + user_id: integer("user_id"), job_id: integer("job_id").unique(), }); +// Join table linking a sample to its labels. +export const legacySampleLabels = pgTable("legacy_sample_labels", { + sample_id: bigint("sample_id", { mode: "number" }).notNull(), + label_id: integer("label_id").notNull(), +}); + // Join table linking a sample to its default subtractions. Read from the // subtraction side to compute a subtraction's `linked_samples`. export const legacySampleSubtractions = pgTable("legacy_sample_subtractions", { sample_id: bigint("sample_id", { mode: "number" }).notNull(), subtraction_id: bigint("subtraction_id", { mode: "number" }).notNull(), }); + +// Artifacts produced during sample creation. +export const sampleArtifacts = pgTable("sample_artifacts", { + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), + sample: text("sample").notNull(), + sample_id: bigint("sample_id", { mode: "number" }), + name: text("name").notNull(), + name_on_disk: text("name_on_disk"), + size: bigint("size", { mode: "number" }), + type: text("type").notNull(), + uploaded_at: timestamp("uploaded_at"), +}); + +// Reads files that make up a sample. +export const sampleReads = pgTable("sample_reads", { + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), + sample: text("sample").notNull(), + sample_id: bigint("sample_id", { mode: "number" }), + name: text("name").notNull(), + name_on_disk: text("name_on_disk").notNull(), + size: bigint("size", { mode: "number" }), + upload: integer("upload"), + uploaded_at: timestamp("uploaded_at"), +}); + +// Join table linking a sample to its input uploads, ordered by `index`. +export const sampleUploads = pgTable("sample_uploads", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + sample: text("sample").notNull(), + sample_id: bigint("sample_id", { mode: "number" }), + upload_id: integer("upload_id").notNull(), + index: integer("index").notNull(), +}); + +/** A row from the `legacy_samples` table. */ +export type LegacySampleRow = typeof legacySamples.$inferSelect; diff --git a/apps/web/src/server/jobs/data.test.ts b/apps/web/src/server/jobs/data.test.ts index f8b04431a..0e9b1b234 100644 --- a/apps/web/src/server/jobs/data.test.ts +++ b/apps/web/src/server/jobs/data.test.ts @@ -109,7 +109,7 @@ describe("getJob", () => { const [analysis] = await db .insert(analyses) - .values({ job_id: jobId }) + .values({ job_id: jobId, workflow: "nuvs", ready: false }) .returning({ id: analyses.id }); if (!analysis) { throw new Error("failed to seed analysis"); @@ -128,7 +128,12 @@ describe("getJob", () => { const [sample] = await db .insert(legacySamples) - .values({ job_id: jobId }) + .values({ + job_id: jobId, + name: "Sample A", + library_type: "normal", + created_at: new Date(), + }) .returning({ id: legacySamples.id }); if (!sample) { throw new Error("failed to seed sample"); diff --git a/apps/web/src/server/jobs/data.ts b/apps/web/src/server/jobs/data.ts index db64d2e1f..422ba9d24 100644 --- a/apps/web/src/server/jobs/data.ts +++ b/apps/web/src/server/jobs/data.ts @@ -1,5 +1,5 @@ import { count, desc, eq, inArray } from "drizzle-orm"; -import type { Db } from "../db/pg"; +import type { Db, DbOrTx } from "../db/pg"; import { takeFirstOrThrow } from "../db/rows"; import { analyses } from "../db/schema/analyses"; import { indexes } from "../db/schema/indexes"; @@ -271,3 +271,37 @@ export async function getJob(db: Db, jobId: number): Promise { return job; } + +/** + * Create a new job in the `pending` state and return its id. + * + * A pending job in Postgres is claimable by any workflow runner, so this is all + * that is needed to schedule work — no key, claim, or steps are set here; the + * runner writes those when it claims the job. A job's arguments are not stored: + * they are recomposed on read from the owning resource's reverse `job_id` + * foreign key, so the caller must create the owning row (e.g. the sample) in the + * same transaction, before the job becomes visible to a runner. + * + * Takes `DbOrTx` so it can participate in the caller's transaction; the caller + * commits. + */ +export async function createJob( + db: DbOrTx, + workflow: string, + userId: number, +): Promise { + const row = takeFirstOrThrow( + await db + .insert(jobs) + .values({ + acquired: false, + created_at: new Date(), + state: "pending", + user_id: userId, + workflow, + }) + .returning({ id: jobs.id }), + ); + + return row.id; +} diff --git a/apps/web/src/server/samples/data.test.ts b/apps/web/src/server/samples/data.test.ts new file mode 100644 index 000000000..2bf24998f --- /dev/null +++ b/apps/web/src/server/samples/data.test.ts @@ -0,0 +1,688 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { seedUser } from "../auth/test/fixtures"; +import type { Db } from "../db/pg"; +import { takeFirstOrThrow } from "../db/rows"; +import { analyses } from "../db/schema/analyses"; +import { groups, userGroups } from "../db/schema/groups"; +import { jobs } from "../db/schema/jobs"; +import { labels } from "../db/schema/labels"; +import { + legacySampleLabels, + legacySampleSubtractions, + legacySamples, + sampleArtifacts, + sampleReads, + sampleUploads, +} from "../db/schema/samples"; +import { subtractions } from "../db/schema/subtractions"; +import { uploads } from "../db/schema/uploads"; +import { users } from "../db/schema/users"; +import { createTestDatabase, type TestDatabase } from "../db/test/fixtures"; +import { addToGroup, seedGroup } from "../groups/test/fixtures"; +import { MemoryStorage } from "../storage"; +import { + type CreateSampleValues, + checkSampleRight, + createSample, + deleteSample, + findSamples, + getSample, + hasSampleRight, + resolveSampleActor, + type SampleActor, + SampleFileDuplicateError, + SampleGroupNotFoundError, + SampleLabelsNotFoundError, + SampleNameConflictError, + SampleNotFoundError, + sampleReadableFilter, + updateSample, + updateSampleRights, +} from "./data"; + +let database: TestDatabase; +let db: Db; +let ownerId: number; + +beforeAll(async () => { + database = await createTestDatabase(); + db = database.db; +}, 60_000); + +afterAll(async () => { + await database.drop(); +}); + +beforeEach(async () => { + await db.delete(analyses); + await db.delete(legacySampleLabels); + await db.delete(legacySampleSubtractions); + await db.delete(sampleUploads); + await db.delete(sampleArtifacts); + await db.delete(sampleReads); + await db.delete(legacySamples); + await db.delete(jobs); + await db.delete(labels); + await db.delete(subtractions); + await db.delete(uploads); + await db.delete(userGroups); + await db.delete(groups); + await db.delete(users); + + ownerId = await seedUser(db, { handle: "owner" }); +}); + +async function seedSample( + overrides: Partial = {}, +): Promise { + return takeFirstOrThrow( + await db + .insert(legacySamples) + .values({ + name: "Sample", + library_type: "normal", + created_at: new Date(), + user_id: ownerId, + ...overrides, + }) + .returning({ id: legacySamples.id }), + ).id; +} + +async function seedLabel(name: string): Promise { + return takeFirstOrThrow( + await db + .insert(labels) + .values({ name, color: "#000000", description: "" }) + .returning({ id: labels.id }), + ).id; +} + +async function seedSubtraction(name: string): Promise { + return takeFirstOrThrow( + await db + .insert(subtractions) + .values({ name }) + .returning({ id: subtractions.id }), + ).id; +} + +async function seedUpload(reserved = false): Promise { + return takeFirstOrThrow( + await db + .insert(uploads) + .values({ + createdAt: new Date(), + name: "reads.fq.gz", + nameOnDisk: `disk-${Math.random()}`, + type: "reads", + reserved, + userId: ownerId, + }) + .returning({ id: uploads.id }), + ).id; +} + +const adminActor: SampleActor = { userId: 999, groupIds: [], isAdmin: true }; + +describe("hasSampleRight", () => { + const row = { + all_read: false, + all_write: false, + group_read: false, + group_write: false, + group_id: 5, + user_id: 1, + }; + + it("grants an administrator every right", () => { + const actor: SampleActor = { userId: 2, groupIds: [], isAdmin: true }; + expect(hasSampleRight(row, actor, "read")).toBe(true); + expect(hasSampleRight(row, actor, "write")).toBe(true); + }); + + it("grants the owner every right", () => { + const actor: SampleActor = { userId: 1, groupIds: [], isAdmin: false }; + expect(hasSampleRight(row, actor, "read")).toBe(true); + expect(hasSampleRight(row, actor, "write")).toBe(true); + }); + + it("grants everyone read when all_read is set", () => { + const actor: SampleActor = { userId: 2, groupIds: [], isAdmin: false }; + expect(hasSampleRight({ ...row, all_read: true }, actor, "read")).toBe( + true, + ); + expect(hasSampleRight({ ...row, all_read: true }, actor, "write")).toBe( + false, + ); + }); + + it("grants a group member read only through group_read", () => { + const member: SampleActor = { userId: 2, groupIds: [5], isAdmin: false }; + const outsider: SampleActor = { userId: 2, groupIds: [6], isAdmin: false }; + const groupReadable = { ...row, group_read: true }; + expect(hasSampleRight(groupReadable, member, "read")).toBe(true); + expect(hasSampleRight(groupReadable, outsider, "read")).toBe(false); + }); + + it("denies a non-owner, non-member with no world rights", () => { + const actor: SampleActor = { userId: 2, groupIds: [], isAdmin: false }; + expect(hasSampleRight(row, actor, "read")).toBe(false); + }); +}); + +describe("checkSampleRight", () => { + it("allows a lookup for a nonexistent sample so the fetch 404s", async () => { + const actor = await resolveSampleActor(db, ownerId); + expect(await checkSampleRight(db, 123456, actor, "read")).toBe(true); + }); + + it("denies a non-owner without world or group rights", async () => { + const sampleId = await seedSample({ all_read: false }); + const other = await seedUser(db, { handle: "other" }); + const actor = await resolveSampleActor(db, other); + expect(await checkSampleRight(db, sampleId, actor, "read")).toBe(false); + }); +}); + +describe("findSamples", () => { + const options = { + page: 1, + perPage: 25, + term: "", + labels: [], + users: [], + workflows: [], + }; + + it("scopes a non-admin to their own, world-readable, and group-readable samples", async () => { + const groupId = await seedGroup(db, { name: "techs" }); + const other = await seedUser(db, { handle: "other" }); + await addToGroup(db, other, groupId); + + const own = await seedSample({ user_id: other, name: "Own" }); + const world = await seedSample({ + user_id: ownerId, + all_read: true, + name: "World", + }); + const groupReadable = await seedSample({ + user_id: ownerId, + group_id: groupId, + group_read: true, + name: "Group", + }); + await seedSample({ user_id: ownerId, name: "Hidden" }); + + const actor = await resolveSampleActor(db, other); + const result = await findSamples(db, options, actor); + + expect(new Set(result.items.map((s) => s.id))).toEqual( + new Set([own, world, groupReadable]), + ); + }); + + it("returns every sample for an administrator", async () => { + await seedSample({ name: "A" }); + await seedSample({ name: "B" }); + + const result = await findSamples(db, options, adminActor); + + expect(result.items).toHaveLength(2); + }); + + it("reports the unscoped total but the scoped found count", async () => { + await seedSample({ user_id: ownerId, name: "Mine" }); + const stranger = await seedUser(db, { handle: "stranger" }); + await seedSample({ user_id: stranger, name: "Theirs" }); + + const actor = await resolveSampleActor(db, ownerId); + const result = await findSamples(db, options, actor); + + expect(result.total_count).toBe(2); + expect(result.found_count).toBe(1); + }); + + it("filters by a case-insensitive name substring", async () => { + await seedSample({ name: "Apple" }); + await seedSample({ name: "Banana" }); + + const result = await findSamples( + db, + { ...options, term: "app" }, + adminActor, + ); + + expect(result.items.map((s) => s.name)).toEqual(["Apple"]); + }); + + it("filters by owner", async () => { + const other = await seedUser(db, { handle: "other" }); + await seedSample({ user_id: ownerId, name: "Mine" }); + await seedSample({ user_id: other, name: "Theirs" }); + + const result = await findSamples( + db, + { ...options, users: [other] }, + adminActor, + ); + + expect(result.items.map((s) => s.name)).toEqual(["Theirs"]); + }); + + it("filters by label", async () => { + const labelId = await seedLabel("important"); + const labelled = await seedSample({ name: "Labelled" }); + await seedSample({ name: "Plain" }); + await db + .insert(legacySampleLabels) + .values({ sample_id: labelled, label_id: labelId }); + + const result = await findSamples( + db, + { ...options, labels: [labelId] }, + adminActor, + ); + + expect(result.items.map((s) => s.id)).toEqual([labelled]); + }); +}); + +describe("workflow tags and filtering", () => { + it("derives tags from a sample's analyses", async () => { + const readyNuvs = await seedSample({ name: "Ready" }); + await db + .insert(analyses) + .values({ sample_id: readyNuvs, workflow: "nuvs", ready: true }); + + const pendingPatho = await seedSample({ name: "Pending" }); + await db.insert(analyses).values({ + sample_id: pendingPatho, + workflow: "pathoscope", + ready: false, + }); + + const noneSample = await seedSample({ name: "None" }); + + const result = await findSamples( + db, + { page: 1, perPage: 25, term: "", labels: [], users: [], workflows: [] }, + adminActor, + ); + const byId = new Map(result.items.map((s) => [s.id, s])); + + expect(byId.get(readyNuvs)?.nuvs).toBe(true); + expect(byId.get(readyNuvs)?.workflows.nuvs).toBe("complete"); + expect(byId.get(pendingPatho)?.pathoscope).toBe("ip"); + expect(byId.get(pendingPatho)?.workflows.pathoscope).toBe("pending"); + expect(byId.get(noneSample)?.nuvs).toBe(false); + expect(byId.get(noneSample)?.workflows.nuvs).toBe("none"); + }); + + it("filters by a workflow:condition pair", async () => { + const ready = await seedSample({ name: "Ready" }); + await db + .insert(analyses) + .values({ sample_id: ready, workflow: "nuvs", ready: true }); + await seedSample({ name: "NoAnalyses" }); + + const result = await findSamples( + db, + { + page: 1, + perPage: 25, + term: "", + labels: [], + users: [], + workflows: ["nuvs:ready"], + }, + adminActor, + ); + + expect(result.items.map((s) => s.id)).toEqual([ready]); + }); + + it("drops unrecognized workflow filters rather than matching everything", async () => { + await seedSample({ name: "A" }); + await seedSample({ name: "B" }); + + const result = await findSamples( + db, + { + page: 1, + perPage: 25, + term: "", + labels: [], + users: [], + workflows: ["bogus:none"], + }, + adminActor, + ); + + expect(result.items).toHaveLength(2); + }); +}); + +describe("getSample", () => { + it("returns the full sample shape", async () => { + const groupId = await seedGroup(db, { name: "g" }); + const labelId = await seedLabel("lab"); + const subtractionId = await seedSubtraction("sub"); + const sampleId = await seedSample({ + name: "Full", + group_id: groupId, + quality: { count: 1 } as never, + }); + + await db + .insert(legacySampleLabels) + .values({ sample_id: sampleId, label_id: labelId }); + await db + .insert(legacySampleSubtractions) + .values({ sample_id: sampleId, subtraction_id: subtractionId }); + await db.insert(sampleReads).values([ + { + sample: String(sampleId), + sample_id: sampleId, + name: "reads_1.fq.gz", + name_on_disk: "reads_1.fq.gz", + }, + { + sample: String(sampleId), + sample_id: sampleId, + name: "reads_2.fq.gz", + name_on_disk: "reads_2.fq.gz", + }, + ]); + + const sample = await getSample(db, sampleId); + + expect(sample.labels.map((l) => l.id)).toEqual([labelId]); + expect(sample.subtractions.map((s) => s.id)).toEqual([subtractionId]); + expect(sample.group?.id).toBe(groupId); + expect(sample.quality).toEqual({ count: 1 }); + expect(sample.reads).toHaveLength(2); + expect(sample.paired).toBe(true); + expect(sample.user.id).toBe(ownerId); + }); + + it("throws when the sample does not exist", async () => { + await expect(getSample(db, 123456)).rejects.toBeInstanceOf( + SampleNotFoundError, + ); + }); +}); + +describe("createSample", () => { + function values( + overrides: Partial = {}, + ): CreateSampleValues { + return { + name: "New Sample", + host: "", + isolate: "", + locale: "", + notes: "", + libraryType: "normal", + group: null, + subtractions: [], + labels: [], + files: [], + userId: ownerId, + ...overrides, + }; + } + + it("creates a pending job, reserves the uploads, and links them in order", async () => { + const first = await seedUpload(); + const second = await seedUpload(); + + const sample = await createSample(db, values({ files: [first, second] })); + + // `paired` is derived from the reads on read, which are not uploaded until + // the job runs, so the stored column is what create sets from the file count. + const [row] = await db + .select({ paired: legacySamples.paired }) + .from(legacySamples) + .where(eq(legacySamples.id, sample.id)); + expect(row?.paired).toBe(true); + + const [job] = await db + .select() + .from(jobs) + .where(eq(jobs.id, sample.job?.id ?? 0)); + expect(job?.workflow).toBe("create_sample"); + expect(job?.state).toBe("pending"); + + const reserved = await db + .select({ id: uploads.id, reserved: uploads.reserved }) + .from(uploads); + expect(reserved.every((u) => u.reserved)).toBe(true); + + const links = await db + .select() + .from(sampleUploads) + .where(eq(sampleUploads.sample_id, sample.id)) + .orderBy(sampleUploads.index); + expect(links.map((l) => l.upload_id)).toEqual([first, second]); + }); + + it("inserts the label and subtraction join rows", async () => { + const file = await seedUpload(); + const labelId = await seedLabel("lab"); + const subtractionId = await seedSubtraction("sub"); + + const sample = await createSample( + db, + values({ + files: [file], + labels: [labelId], + subtractions: [subtractionId], + }), + ); + + expect(sample.labels.map((l) => l.id)).toEqual([labelId]); + expect(sample.subtractions.map((s) => s.id)).toEqual([subtractionId]); + }); + + it("rejects a duplicate name", async () => { + await seedSample({ name: "Taken" }); + await expect( + createSample(db, values({ name: "Taken" })), + ).rejects.toBeInstanceOf(SampleNameConflictError); + }); + + it("rejects a duplicated upload", async () => { + const file = await seedUpload(); + await expect( + createSample(db, values({ files: [file, file] })), + ).rejects.toBeInstanceOf(SampleFileDuplicateError); + }); + + it("rejects a missing label", async () => { + const file = await seedUpload(); + await expect( + createSample(db, values({ files: [file], labels: [987654] })), + ).rejects.toBeInstanceOf(SampleLabelsNotFoundError); + }); +}); + +describe("updateSample", () => { + it("updates scalars and replaces the label set", async () => { + const first = await seedLabel("first"); + const second = await seedLabel("second"); + const sampleId = await seedSample({ name: "Old" }); + await db + .insert(legacySampleLabels) + .values({ sample_id: sampleId, label_id: first }); + + const sample = await updateSample(db, sampleId, { + name: "New", + labels: [second], + }); + + expect(sample.name).toBe("New"); + expect(sample.labels.map((l) => l.id)).toEqual([second]); + }); + + it("allows renaming a sample to its own name", async () => { + const sampleId = await seedSample({ name: "Same" }); + const sample = await updateSample(db, sampleId, { name: "Same" }); + expect(sample.name).toBe("Same"); + }); + + it("rejects a name already used by another sample", async () => { + await seedSample({ name: "Taken" }); + const sampleId = await seedSample({ name: "Mine" }); + await expect( + updateSample(db, sampleId, { name: "Taken" }), + ).rejects.toBeInstanceOf(SampleNameConflictError); + }); + + it("throws when the sample does not exist", async () => { + await expect( + updateSample(db, 123456, { name: "x" }), + ).rejects.toBeInstanceOf(SampleNotFoundError); + }); +}); + +describe("deleteSample", () => { + it("cascades across the sample's tables and releases its reserved uploads", async () => { + const uploadId = await seedUpload(true); + const sampleId = await seedSample({ name: "Doomed" }); + + await db.insert(sampleUploads).values({ + sample: String(sampleId), + sample_id: sampleId, + upload_id: uploadId, + index: 0, + }); + await db.insert(sampleReads).values({ + sample: String(sampleId), + sample_id: sampleId, + name: "reads_1.fq.gz", + name_on_disk: "reads_1.fq.gz", + }); + await db.insert(sampleArtifacts).values({ + sample: String(sampleId), + sample_id: sampleId, + name: "a.json", + type: "json", + }); + await db + .insert(analyses) + .values({ sample_id: sampleId, workflow: "nuvs", ready: true }); + + const deleted = await deleteSample(db, new MemoryStorage(), sampleId); + expect(deleted.id).toBe(sampleId); + + expect( + await db + .select() + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)), + ).toHaveLength(0); + expect( + await db + .select() + .from(sampleUploads) + .where(eq(sampleUploads.sample_id, sampleId)), + ).toHaveLength(0); + expect( + await db + .select() + .from(sampleReads) + .where(eq(sampleReads.sample_id, sampleId)), + ).toHaveLength(0); + expect( + await db + .select() + .from(sampleArtifacts) + .where(eq(sampleArtifacts.sample_id, sampleId)), + ).toHaveLength(0); + expect( + await db.select().from(analyses).where(eq(analyses.sample_id, sampleId)), + ).toHaveLength(0); + + const [upload] = await db + .select({ reserved: uploads.reserved }) + .from(uploads) + .where(eq(uploads.id, uploadId)); + expect(upload?.reserved).toBe(false); + }); + + it("throws when the sample does not exist", async () => { + await expect( + deleteSample(db, new MemoryStorage(), 123456), + ).rejects.toBeInstanceOf(SampleNotFoundError); + }); +}); + +describe("updateSampleRights", () => { + it("sets the rights flags", async () => { + const sampleId = await seedSample({ name: "Rights" }); + const sample = await updateSampleRights(db, sampleId, { + all_read: true, + group_write: true, + }); + expect(sample.all_read).toBe(true); + expect(sample.group_write).toBe(true); + }); + + it("resolves and assigns a group by id", async () => { + const groupId = await seedGroup(db, { name: "grp" }); + const sampleId = await seedSample({ name: "Rights" }); + const sample = await updateSampleRights(db, sampleId, { group: groupId }); + expect(sample.group?.id).toBe(groupId); + }); + + it("clears the group when passed null", async () => { + const groupId = await seedGroup(db, { name: "grp" }); + const sampleId = await seedSample({ name: "Rights", group_id: groupId }); + const sample = await updateSampleRights(db, sampleId, { group: null }); + expect(sample.group).toBeNull(); + }); + + it("rejects a group that does not exist", async () => { + const sampleId = await seedSample({ name: "Rights" }); + await expect( + updateSampleRights(db, sampleId, { group: 987654 }), + ).rejects.toBeInstanceOf(SampleGroupNotFoundError); + }); + + it("throws when the sample does not exist", async () => { + await expect( + updateSampleRights(db, 123456, { all_read: true }), + ).rejects.toBeInstanceOf(SampleNotFoundError); + }); +}); + +describe("sampleReadableFilter", () => { + it("returns undefined for an administrator", () => { + expect(sampleReadableFilter(adminActor)).toBeUndefined(); + }); + + it("returns a predicate for a non-administrator", () => { + const actor: SampleActor = { userId: 1, groupIds: [2], isAdmin: false }; + expect(sampleReadableFilter(actor)).toBeDefined(); + }); +}); + +describe("resolveSampleActor", () => { + it("resolves group membership and the admin flag", async () => { + const groupId = await seedGroup(db, { name: "g" }); + const adminId = await seedUser(db, { + handle: "admin", + administratorRole: "full", + }); + await addToGroup(db, adminId, groupId); + + const actor = await resolveSampleActor(db, adminId); + expect(actor.isAdmin).toBe(true); + expect(actor.groupIds).toEqual([groupId]); + + const plain = await resolveSampleActor(db, ownerId); + expect(plain.isAdmin).toBe(false); + }); +}); diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts new file mode 100644 index 000000000..6334dcfe1 --- /dev/null +++ b/apps/web/src/server/samples/data.ts @@ -0,0 +1,1229 @@ +import type { + GroupMinimal, + LabelNested, + LibraryType, + Quality, + Read, + Sample, + SampleArtifact, + SampleJobNested, + SampleJobState, + SampleJobWorkflow, + SampleMinimal, + SampleRightsUpdate, + SampleSearchResult, + SampleWorkflows, + SubtractionNested, + UserNested, + WorkflowState, +} from "@virtool/contracts"; +import { + and, + asc, + count, + desc, + eq, + exists, + ilike, + inArray, + not, + or, + type SQL, + sql, +} from "drizzle-orm"; +import type { Db, DbOrTx } from "../db/pg"; +import { takeFirstOrThrow } from "../db/rows"; +import { analyses } from "../db/schema/analyses"; +import { groups, userGroups } from "../db/schema/groups"; +import { labels } from "../db/schema/labels"; +import { + type LegacySampleRow, + legacySampleLabels, + legacySampleSubtractions, + legacySamples, + sampleArtifacts, + sampleReads, + sampleUploads, +} from "../db/schema/samples"; +import { subtractions } from "../db/schema/subtractions"; +import { uploads } from "../db/schema/uploads"; +import { users } from "../db/schema/users"; +import { AppError } from "../errors"; +import { emit } from "../events/emit"; +import { createJob, getJobs } from "../jobs/data"; +import { logger } from "../logger"; +import { getSettings } from "../settings/data"; +import { + deletePrefix, + type StorageBackend, + samplePrefix, + sampleStorageId, +} from "../storage"; +import { reserveUploads } from "../uploads/data"; + +/** A read or write right on a sample. */ +export type SampleRight = "read" | "write"; + +/** The caller resolved to the identity used for per-sample authorization. */ +export type SampleActor = { + userId: number; + groupIds: number[]; + isAdmin: boolean; +}; + +/** Filters and pagination accepted by {@link findSamples}. */ +export type FindSamplesOptions = { + page: number; + perPage: number; + term: string; + labels: number[]; + users: number[]; + workflows: string[]; +}; + +/** The fields a sample is created from, plus the creating user. */ +export type CreateSampleValues = { + name: string; + host: string; + isolate: string; + locale: string; + notes: string; + libraryType: string; + group: number | null; + subtractions: number[]; + labels: number[]; + files: number[]; + userId: number; +}; + +/** The fields that can be changed when updating a sample. */ +export type UpdateSampleValues = { + name?: string; + host?: string; + isolate?: string; + locale?: string; + notes?: string; + labels?: number[]; + subtractions?: number[]; +}; + +/** Thrown when a requested sample does not exist. */ +export class SampleNotFoundError extends AppError {} + +/** Thrown when a sample name is already taken. */ +export class SampleNameConflictError extends AppError {} + +/** Thrown when a create or update references a label that does not exist. */ +export class SampleLabelsNotFoundError extends AppError {} + +/** Thrown when a create or update references a subtraction that does not exist. */ +export class SampleSubtractionsNotFoundError extends AppError {} + +/** Thrown when a rights or create request names a group that does not exist. */ +export class SampleGroupNotFoundError extends AppError {} + +/** Thrown when the `force_choice` policy requires a group and none was given. */ +export class SampleGroupRequiredError extends AppError {} + +/** Thrown when the same upload is supplied more than once to create a sample. */ +export class SampleFileDuplicateError extends AppError {} + +const WORKFLOW_NAMES = ["nuvs", "pathoscope"] as const; +const WORKFLOW_CONDITIONS = ["none", "pending", "ready"] as const; + +type WorkflowName = (typeof WORKFLOW_NAMES)[number]; + +/** A sample's derived workflow tags, keyed as the client consumes them. */ +type WorkflowTags = { + nuvs: boolean | string; + pathoscope: boolean | string; + workflows: SampleWorkflows; +}; + +// Escape LIKE wildcards so a user's `%` or `_` matches literally. +function escapeLike(term: string): string { + return term.replace(/[\\%_]/g, (char) => `\\${char}`); +} + +// `None` (no analyses) is a `false` tag, a ready analysis is `true`, and an +// unfinished analysis is `"ip"` — the legacy top-level tag encoding. +function encodeTag(ready: boolean | undefined): boolean | string { + if (ready === undefined) { + return false; + } + return ready ? true : "ip"; +} + +function encodeWorkflows(readyByWorkflow: Map): WorkflowTags { + const workflows: SampleWorkflows = { nuvs: "none", pathoscope: "none" }; + + for (const name of WORKFLOW_NAMES) { + const ready = readyByWorkflow.get(name); + if (ready !== undefined) { + workflows[name] = ready ? "complete" : ("pending" as WorkflowState); + } + } + + return { + nuvs: encodeTag(readyByWorkflow.get("nuvs")), + pathoscope: encodeTag(readyByWorkflow.get("pathoscope")), + workflows, + }; +} + +const EMPTY_TAGS: WorkflowTags = encodeWorkflows(new Map()); + +// One `GROUP BY workflow, bool_or(ready)` query bounded to the page's sample +// ids, so the aggregation never scans the whole analyses table. +async function getWorkflowTagsBySample( + db: DbOrTx, + sampleIds: number[], +): Promise> { + if (sampleIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + sampleId: analyses.sample_id, + workflow: analyses.workflow, + ready: sql`bool_or(${analyses.ready})`, + }) + .from(analyses) + .where(inArray(analyses.sample_id, sampleIds)) + .groupBy(analyses.sample_id, analyses.workflow); + + const readyBySample = new Map>(); + + for (const row of rows) { + if (row.sampleId == null) { + continue; + } + const forSample = readyBySample.get(row.sampleId) ?? new Map(); + forSample.set(row.workflow, row.ready); + readyBySample.set(row.sampleId, forSample); + } + + return new Map( + sampleIds.map((id) => [ + id, + encodeWorkflows(readyBySample.get(id) ?? new Map()), + ]), + ); +} + +async function getLabelsBySample( + db: DbOrTx, + sampleIds: number[], +): Promise> { + if (sampleIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + sampleId: legacySampleLabels.sample_id, + id: labels.id, + name: labels.name, + color: labels.color, + description: labels.description, + }) + .from(legacySampleLabels) + .innerJoin(labels, eq(labels.id, legacySampleLabels.label_id)) + .where(inArray(legacySampleLabels.sample_id, sampleIds)) + .orderBy(asc(legacySampleLabels.label_id)); + + const bySample = new Map(); + + for (const row of rows) { + const list = bySample.get(row.sampleId) ?? []; + list.push({ + id: row.id, + name: row.name ?? "", + color: row.color ?? "", + description: row.description ?? "", + }); + bySample.set(row.sampleId, list); + } + + return bySample; +} + +async function getUsersByIds( + db: DbOrTx, + userIds: number[], +): Promise> { + const ids = [...new Set(userIds)]; + + if (ids.length === 0) { + return new Map(); + } + + const rows = await db + .select({ id: users.id, handle: users.handle }) + .from(users) + .where(inArray(users.id, ids)); + + return new Map(rows.map((row) => [row.id, row])); +} + +// The sample's creation job, reduced to the embedded shape. Reuses the jobs +// data layer so a sample's job never disagrees with the jobs endpoints. +async function getSampleJobs( + db: Db, + jobIds: number[], +): Promise> { + const jobs = await getJobs(db, jobIds); + + return new Map( + jobs.map((job) => [ + job.id, + { + created_at: job.created_at.toISOString(), + id: job.id, + progress: job.progress, + // The mirror stores states and workflows as free text; the columns + // only ever hold the enumerated values. + state: job.state as SampleJobState, + user: job.user, + workflow: job.workflow as SampleJobWorkflow, + }, + ]), + ); +} + +async function getSubtractionsBySample( + db: DbOrTx, + sampleId: number, +): Promise { + return db + .select({ id: subtractions.id, name: subtractions.name }) + .from(legacySampleSubtractions) + .innerJoin( + subtractions, + eq(subtractions.id, legacySampleSubtractions.subtraction_id), + ) + .where(eq(legacySampleSubtractions.sample_id, sampleId)) + .orderBy(asc(legacySampleSubtractions.subtraction_id)); +} + +async function getArtifacts( + db: DbOrTx, + sampleId: number, +): Promise { + const rows = await db + .select() + .from(sampleArtifacts) + .where(eq(sampleArtifacts.sample_id, sampleId)) + .orderBy(asc(sampleArtifacts.id)); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + size: row.size ?? 0, + download_url: `/samples/${sampleId}/artifacts/${row.name_on_disk ?? ""}`, + })); +} + +async function getReads(db: DbOrTx, sampleId: number): Promise { + const rows = await db + .select({ + read: sampleReads, + upload: uploads, + uploadUser: { id: users.id, handle: users.handle }, + }) + .from(sampleReads) + .leftJoin(uploads, eq(uploads.id, sampleReads.upload)) + .leftJoin(users, eq(users.id, uploads.userId)) + .where(eq(sampleReads.sample_id, sampleId)) + .orderBy(asc(sampleReads.id)); + + return rows.map(({ read, upload, uploadUser }) => ({ + download_url: `/samples/${sampleId}/reads/${read.name}`, + id: read.id, + name: read.name, + name_on_disk: read.name_on_disk, + sample: sampleId, + size: read.size ?? 0, + upload: + upload == null + ? null + : { + id: upload.id, + name: upload.name ?? "", + size: upload.size, + uploaded_at: upload.uploadedAt?.toISOString() ?? null, + user: uploadUser?.id != null ? uploadUser : null, + }, + uploaded_at: read.uploaded_at?.toISOString() ?? "", + })); +} + +async function getGroup( + db: DbOrTx, + groupId: number, +): Promise { + const [row] = await db + .select({ id: groups.id, name: groups.name, legacy_id: groups.legacyId }) + .from(groups) + .where(eq(groups.id, groupId)) + .limit(1); + + return row ?? null; +} + +function mapMinimal( + row: LegacySampleRow, + sampleLabels: LabelNested[], + tags: WorkflowTags, + job: SampleJobNested | undefined, + usersById: Map, +): SampleMinimal { + return { + created_at: row.created_at.toISOString(), + host: row.host, + id: row.id, + isolate: row.isolate, + job, + labels: sampleLabels, + library_type: row.library_type as LibraryType, + name: row.name, + notes: row.notes, + nuvs: tags.nuvs, + pathoscope: tags.pathoscope, + ready: row.ready, + user: + row.user_id != null + ? (usersById.get(row.user_id) ?? { id: row.user_id, handle: "" }) + : { id: 0, handle: "" }, + workflows: tags.workflows, + }; +} + +/** Resolve a user id to the identity used for per-sample authorization. */ +export async function resolveSampleActor( + db: Db, + userId: number, +): Promise { + const [userRows, groupRows] = await Promise.all([ + db + .select({ role: users.administratorRole }) + .from(users) + .where(eq(users.id, userId)) + .limit(1), + db + .select({ groupId: userGroups.groupId }) + .from(userGroups) + .where(eq(userGroups.userId, userId)), + ]); + + return { + userId, + groupIds: groupRows.map((row) => row.groupId), + isAdmin: userRows[0]?.role === "full", + }; +} + +/** The `legacy_samples` columns {@link hasSampleRight} needs to resolve a right. */ +type SampleRightsRow = { + all_read: boolean; + all_write: boolean; + group_read: boolean; + group_write: boolean; + group_id: number | null; + user_id: number | null; +}; + +/** + * Whether `actor` holds `right` on a sample row. Shared by the samples and + * analyses domains so a sample and the analyses on it never disagree about who + * may read or write them. + * + * A full administrator and the sample's owner always hold every right; + * otherwise `all_read`/`all_write` grant everyone and `group_read`/`group_write` + * grant only members of the sample's group. + */ +export function hasSampleRight( + row: SampleRightsRow, + actor: SampleActor, + right: SampleRight, +): boolean { + if (actor.isAdmin || row.user_id === actor.userId) { + return true; + } + + const isGroupMember = + row.group_id != null && actor.groupIds.includes(row.group_id); + + if (right === "read") { + return row.all_read || (isGroupMember && row.group_read); + } + + return row.all_write || (isGroupMember && row.group_write); +} + +/** + * The Postgres predicate scoping a list to the samples `actor` may read — the + * WHERE-clause form of {@link hasSampleRight}. A full administrator sees every + * sample. Returns `undefined` for an administrator so callers can omit the + * clause entirely. + */ +export function sampleReadableFilter(actor: SampleActor): SQL | undefined { + if (actor.isAdmin) { + return undefined; + } + + const clauses = [ + eq(legacySamples.all_read, true), + eq(legacySamples.user_id, actor.userId), + ]; + + if (actor.groupIds.length > 0) { + clauses.push( + and( + eq(legacySamples.group_read, true), + inArray(legacySamples.group_id, actor.groupIds), + ) as SQL, + ); + } + + return or(...clauses); +} + +// A correlated EXISTS on analyses for the enclosing sample row, optionally +// restricted to a ready state. +function existsAnalysis(db: Db, workflow: string, ready?: boolean): SQL { + const conditions = [ + eq(analyses.sample_id, legacySamples.id), + eq(analyses.workflow, workflow), + ]; + + if (ready !== undefined) { + conditions.push(eq(analyses.ready, ready)); + } + + return exists( + db + .select({ one: sql`1` }) + .from(analyses) + .where(and(...conditions)), + ); +} + +function composeWorkflowConditionFilter( + db: Db, + workflow: string, + condition: string, +): SQL { + if (condition === "ready") { + return existsAnalysis(db, workflow, true); + } + + if (condition === "pending") { + return and( + existsAnalysis(db, workflow), + not(existsAnalysis(db, workflow, true)), + ) as SQL; + } + + return not(existsAnalysis(db, workflow)); +} + +// Each `workflow:condition` pair becomes a correlated semi-join on analyses. +// Conditions for one workflow are ORed, different workflows ANDed. Pairs with an +// unknown workflow or condition are dropped, matching the old Mongo query. +function composeWorkflowFilter(db: Db, workflows: string[]): SQL | undefined { + const conditionsByWorkflow = new Map>(); + + for (const value of workflows) { + for (const pair of value.split(" ")) { + const [workflow, condition] = pair.split(":"); + + if ( + workflow !== undefined && + condition !== undefined && + (WORKFLOW_NAMES as readonly string[]).includes(workflow) && + (WORKFLOW_CONDITIONS as readonly string[]).includes(condition) + ) { + const key = workflow as WorkflowName; + const set = conditionsByWorkflow.get(key) ?? new Set(); + set.add(condition); + conditionsByWorkflow.set(key, set); + } + } + } + + if (conditionsByWorkflow.size === 0) { + return undefined; + } + + const clauses = [...conditionsByWorkflow].map( + ([workflow, conditions]) => + or( + ...[...conditions].map((condition) => + composeWorkflowConditionFilter(db, workflow, condition), + ), + ) as SQL, + ); + + return and(...clauses); +} + +export async function findSamples( + db: Db, + options: FindSamplesOptions, + actor: SampleActor, +): Promise { + const filters: SQL[] = []; + + const readable = sampleReadableFilter(actor); + if (readable) { + filters.push(readable); + } + + if (options.term) { + filters.push(ilike(legacySamples.name, `%${escapeLike(options.term)}%`)); + } + + if (options.users.length > 0) { + filters.push(inArray(legacySamples.user_id, options.users)); + } + + if (options.labels.length > 0) { + filters.push( + inArray( + legacySamples.id, + db + .select({ id: legacySampleLabels.sample_id }) + .from(legacySampleLabels) + .where(inArray(legacySampleLabels.label_id, options.labels)), + ), + ); + } + + const workflowFilter = composeWorkflowFilter(db, options.workflows); + if (workflowFilter) { + filters.push(workflowFilter); + } + + const where = filters.length > 0 ? and(...filters) : undefined; + + const [totalRows, foundRows, rows] = await Promise.all([ + // Python's `total_count` is the unscoped grand total of samples, not the + // count visible to the caller. Match it exactly. + db.select({ value: count() }).from(legacySamples), + db.select({ value: count() }).from(legacySamples).where(where), + db + .select() + .from(legacySamples) + .where(where) + .orderBy(desc(legacySamples.created_at), asc(legacySamples.id)) + .offset((options.page - 1) * options.perPage) + .limit(options.perPage), + ]); + + const totalCount = totalRows[0]?.value ?? 0; + const foundCount = foundRows[0]?.value ?? 0; + + const sampleIds = rows.map((row) => row.id); + const jobIds = [ + ...new Set( + rows.map((row) => row.job_id).filter((id): id is number => id != null), + ), + ]; + const userIds = rows + .map((row) => row.user_id) + .filter((id): id is number => id != null); + + const [labelsBySample, tagsBySample, jobsById, usersById] = await Promise.all( + [ + getLabelsBySample(db, sampleIds), + getWorkflowTagsBySample(db, sampleIds), + getSampleJobs(db, jobIds), + getUsersByIds(db, userIds), + ], + ); + + return { + found_count: foundCount, + total_count: totalCount, + page: options.page, + per_page: options.perPage, + page_count: foundCount ? Math.ceil(foundCount / options.perPage) : 0, + items: rows.map((row) => + mapMinimal( + row, + labelsBySample.get(row.id) ?? [], + tagsBySample.get(row.id) ?? EMPTY_TAGS, + row.job_id != null + ? (jobsById.get(row.job_id) ?? undefined) + : undefined, + usersById, + ), + ), + }; +} + +export async function getSample(db: Db, sampleId: number): Promise { + const [row] = await db + .select() + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + throw new SampleNotFoundError(); + } + + const jobIds = row.job_id != null ? [row.job_id] : []; + const userIds = row.user_id != null ? [row.user_id] : []; + + const [ + labelsBySample, + tagsBySample, + jobsById, + usersById, + sampleSubtractions, + artifacts, + reads, + group, + ] = await Promise.all([ + getLabelsBySample(db, [sampleId]), + getWorkflowTagsBySample(db, [sampleId]), + getSampleJobs(db, jobIds), + getUsersByIds(db, userIds), + getSubtractionsBySample(db, sampleId), + getArtifacts(db, sampleId), + getReads(db, sampleId), + row.group_id != null ? getGroup(db, row.group_id) : Promise.resolve(null), + ]); + + const minimal = mapMinimal( + row, + labelsBySample.get(sampleId) ?? [], + tagsBySample.get(sampleId) ?? EMPTY_TAGS, + row.job_id != null ? (jobsById.get(row.job_id) ?? undefined) : undefined, + usersById, + ); + + return { + ...minimal, + all_read: row.all_read, + all_write: row.all_write, + artifacts, + format: row.format, + group, + group_read: row.group_read, + group_write: row.group_write, + hold: row.hold, + is_legacy: row.is_legacy, + locale: row.locale, + paired: reads.length === 2, + quality: (row.quality as Quality | null) ?? null, + reads, + subtractions: sampleSubtractions, + }; +} + +/** + * The owner user id of a sample, or `null` when the sample does not exist or has + * no owner. The rights-mutation gate treats both as "not found". + */ +export async function getSampleOwnerId( + db: Db, + sampleId: number, +): Promise { + const [row] = await db + .select({ userId: legacySamples.user_id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + return row ? row.userId : null; +} + +/** + * Whether `actor` holds `right` on the sample, resolving the row first. + * + * A lookup for a nonexistent sample returns `true`, so the caller's subsequent + * fetch produces the 404 rather than this returning a misleading 403. + */ +export async function checkSampleRight( + db: Db, + sampleId: number, + actor: SampleActor, + right: SampleRight, +): Promise { + const [row] = await db + .select({ + all_read: legacySamples.all_read, + all_write: legacySamples.all_write, + group_read: legacySamples.group_read, + group_write: legacySamples.group_write, + group_id: legacySamples.group_id, + user_id: legacySamples.user_id, + }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + return true; + } + + return hasSampleRight(row, actor, right); +} + +async function checkNameInUse( + db: DbOrTx, + name: string, + excludeId?: number, +): Promise { + const [row] = await db + .select({ id: legacySamples.id }) + .from(legacySamples) + .where( + excludeId === undefined + ? eq(legacySamples.name, name) + : and( + eq(legacySamples.name, name), + not(eq(legacySamples.id, excludeId)), + ), + ) + .limit(1); + + if (row) { + throw new SampleNameConflictError("Sample name is already in use"); + } +} + +async function checkLabelsExist(db: DbOrTx, labelIds: number[]): Promise { + if (labelIds.length === 0) { + return; + } + + const rows = await db + .select({ id: labels.id }) + .from(labels) + .where(inArray(labels.id, labelIds)); + + const found = new Set(rows.map((row) => row.id)); + const missing = labelIds.filter((id) => !found.has(id)); + + if (missing.length > 0) { + throw new SampleLabelsNotFoundError( + `Labels do not exist: ${missing.join(", ")}`, + ); + } +} + +async function checkSubtractionsExist( + db: DbOrTx, + subtractionIds: number[], +): Promise { + if (subtractionIds.length === 0) { + return; + } + + const rows = await db + .select({ id: subtractions.id }) + .from(subtractions) + .where(inArray(subtractions.id, subtractionIds)); + + const found = new Set(rows.map((row) => row.id)); + const missing = subtractionIds.filter((id) => !found.has(id)); + + if (missing.length > 0) { + throw new SampleSubtractionsNotFoundError( + `Subtractions do not exist: ${missing.join(",")}`, + ); + } +} + +async function resolveCreateGroup( + db: Db, + values: CreateSampleValues, + sampleGroup: string, +): Promise { + if (sampleGroup === "force_choice") { + if (values.group == null) { + throw new SampleGroupRequiredError( + "Group value required for sample creation", + ); + } + + const [group] = await db + .select({ id: groups.id }) + .from(groups) + .where(eq(groups.id, values.group)) + .limit(1); + + if (!group) { + throw new SampleGroupNotFoundError("Group does not exist"); + } + + return group.id; + } + + if (sampleGroup === "users_primary_group") { + const [row] = await db + .select({ groupId: userGroups.groupId }) + .from(userGroups) + .where( + and(eq(userGroups.userId, values.userId), eq(userGroups.primary, true)), + ) + .limit(1); + + return row?.groupId ?? null; + } + + return null; +} + +export async function createSample( + db: Db, + values: CreateSampleValues, +): Promise { + const settings = await getSettings(db); + + await Promise.all([ + checkNameInUse(db, values.name), + checkLabelsExist(db, values.labels), + checkSubtractionsExist(db, values.subtractions), + ]); + + if (new Set(values.files).size !== values.files.length) { + throw new SampleFileDuplicateError("File is duplicated"); + } + + const groupId = await resolveCreateGroup(db, values, settings.sampleGroup); + + const { sampleId, jobId } = await db.transaction(async (tx) => { + // Reserve uploads and create the job inside the sample's transaction so + // everything commits atomically: a runner must not claim the job before + // the sample row it derives its arguments from exists. + await reserveUploads(tx, values.files); + + const jobId = await createJob(tx, "create_sample", values.userId); + + const sample = takeFirstOrThrow( + await tx + .insert(legacySamples) + .values({ + all_read: settings.sampleAllRead, + all_write: settings.sampleAllWrite, + created_at: new Date(), + format: "fastq", + group_id: groupId, + group_read: settings.sampleGroupRead, + group_write: settings.sampleGroupWrite, + hold: true, + host: values.host, + is_legacy: false, + isolate: values.isolate, + job_id: jobId, + library_type: values.libraryType, + locale: values.locale, + name: values.name, + notes: values.notes, + paired: values.files.length === 2, + quality: null, + ready: false, + user_id: values.userId, + }) + .returning({ id: legacySamples.id }), + ); + + const sampleId = sample.id; + + if (values.labels.length > 0) { + await tx.insert(legacySampleLabels).values( + values.labels.map((labelId) => ({ + sample_id: sampleId, + label_id: labelId, + })), + ); + } + + if (values.subtractions.length > 0) { + await tx.insert(legacySampleSubtractions).values( + values.subtractions.map((subtractionId) => ({ + sample_id: sampleId, + subtraction_id: subtractionId, + })), + ); + } + + if (values.files.length > 0) { + await tx.insert(sampleUploads).values( + values.files.map((uploadId, index) => ({ + sample: String(sampleId), + sample_id: sampleId, + upload_id: uploadId, + index, + })), + ); + } + + return { sampleId, jobId }; + }); + + await emit("jobs", jobId, "create"); + await emit("samples", sampleId, "create"); + + return getSample(db, sampleId); +} + +export async function updateSample( + db: Db, + sampleId: number, + values: UpdateSampleValues, +): Promise { + const [existing] = await db + .select({ id: legacySamples.id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!existing) { + throw new SampleNotFoundError(); + } + + const checks: Promise[] = []; + + if (values.name !== undefined) { + checks.push(checkNameInUse(db, values.name, sampleId)); + } + if (values.labels !== undefined) { + checks.push(checkLabelsExist(db, values.labels)); + } + if (values.subtractions !== undefined) { + checks.push(checkSubtractionsExist(db, values.subtractions)); + } + + await Promise.all(checks); + + const scalars: Partial< + Pick + > = {}; + + if (values.name !== undefined) { + scalars.name = values.name; + } + if (values.host !== undefined) { + scalars.host = values.host; + } + if (values.isolate !== undefined) { + scalars.isolate = values.isolate; + } + if (values.locale !== undefined) { + scalars.locale = values.locale; + } + if (values.notes !== undefined) { + scalars.notes = values.notes; + } + + await db.transaction(async (tx) => { + if (Object.keys(scalars).length > 0) { + await tx + .update(legacySamples) + .set(scalars) + .where(eq(legacySamples.id, sampleId)); + } + + if (values.labels !== undefined) { + await tx + .delete(legacySampleLabels) + .where(eq(legacySampleLabels.sample_id, sampleId)); + + if (values.labels.length > 0) { + await tx.insert(legacySampleLabels).values( + values.labels.map((labelId) => ({ + sample_id: sampleId, + label_id: labelId, + })), + ); + } + } + + if (values.subtractions !== undefined) { + await tx + .delete(legacySampleSubtractions) + .where(eq(legacySampleSubtractions.sample_id, sampleId)); + + if (values.subtractions.length > 0) { + await tx.insert(legacySampleSubtractions).values( + values.subtractions.map((subtractionId) => ({ + sample_id: sampleId, + subtraction_id: subtractionId, + })), + ); + } + } + }); + + await emit("samples", sampleId, "update"); + + return getSample(db, sampleId); +} + +export async function updateSampleRights( + db: Db, + sampleId: number, + data: SampleRightsUpdate, +): Promise { + const [existing] = await db + .select({ id: legacySamples.id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!existing) { + throw new SampleNotFoundError(); + } + + const scalars: Partial< + Pick< + LegacySampleRow, + "all_read" | "all_write" | "group_read" | "group_write" | "group_id" + > + > = {}; + + if (data.all_read !== undefined) { + scalars.all_read = data.all_read; + } + if (data.all_write !== undefined) { + scalars.all_write = data.all_write; + } + if (data.group_read !== undefined) { + scalars.group_read = data.group_read; + } + if (data.group_write !== undefined) { + scalars.group_write = data.group_write; + } + + if ("group" in data) { + let groupId: number | null = null; + + if (data.group != null && data.group !== "none") { + const [group] = await db + .select({ id: groups.id }) + .from(groups) + .where( + typeof data.group === "number" + ? eq(groups.id, data.group) + : eq(groups.legacyId, data.group), + ) + .limit(1); + + if (!group) { + throw new SampleGroupNotFoundError("Group does not exist"); + } + + groupId = group.id; + } + + scalars.group_id = groupId; + } + + if (Object.keys(scalars).length > 0) { + await db + .update(legacySamples) + .set(scalars) + .where(eq(legacySamples.id, sampleId)); + } + + await emit("samples", sampleId, "update"); + + return getSample(db, sampleId); +} + +export async function deleteSample( + db: Db, + storage: StorageBackend, + sampleId: number, +): Promise { + const [row] = await db + .select({ id: legacySamples.id, legacy_id: legacySamples.legacy_id }) + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .limit(1); + + if (!row) { + throw new SampleNotFoundError(); + } + + // Capture the full sample for the return value before its rows are removed. + const sample = await getSample(db, sampleId); + + const storageId = sampleStorageId(sampleId, row.legacy_id); + + const uploadRows = await db + .select({ uploadId: sampleUploads.upload_id }) + .from(sampleUploads) + .where( + or( + eq(sampleUploads.sample_id, sampleId), + eq(sampleUploads.sample, storageId), + ), + ); + const uploadIds = [...new Set(uploadRows.map((r) => r.uploadId))]; + + // The FK cascade only covers two of these relationships, so every table is + // deleted explicitly. Rows may be keyed by the integer id or the legacy + // storage string depending on when the sample was created. + await db.transaction(async (tx) => { + if (uploadIds.length > 0) { + await tx + .update(uploads) + .set({ reserved: false }) + .where(inArray(uploads.id, uploadIds)); + } + + await tx.delete(analyses).where(eq(analyses.sample_id, sampleId)); + await tx + .delete(sampleUploads) + .where( + or( + eq(sampleUploads.sample_id, sampleId), + eq(sampleUploads.sample, storageId), + ), + ); + await tx + .delete(sampleArtifacts) + .where( + or( + eq(sampleArtifacts.sample_id, sampleId), + eq(sampleArtifacts.sample, storageId), + ), + ); + await tx + .delete(sampleReads) + .where( + or( + eq(sampleReads.sample_id, sampleId), + eq(sampleReads.sample, storageId), + ), + ); + + const deleted = await tx + .delete(legacySamples) + .where(eq(legacySamples.id, sampleId)) + .returning({ id: legacySamples.id }); + + if (deleted.length === 0) { + throw new SampleNotFoundError(); + } + }); + + for (const failure of await deletePrefix(storage, samplePrefix(storageId))) { + logger.error( + { sampleId, key: failure.key, err: failure.error }, + "storage cleanup failed; file orphaned", + ); + } + + await emit("samples", sampleId, "delete"); + + return sample; +} diff --git a/apps/web/src/server/samples/functions.test.ts b/apps/web/src/server/samples/functions.test.ts new file mode 100644 index 000000000..7ede490d6 --- /dev/null +++ b/apps/web/src/server/samples/functions.test.ts @@ -0,0 +1,238 @@ +import { eq } from "drizzle-orm"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { Db } from "../db/pg"; +import { takeFirstOrThrow } from "../db/rows"; +import { jobs } from "../db/schema/jobs"; +import { legacySamples } from "../db/schema/samples"; +import { sessions } from "../db/schema/sessions"; +import { users } from "../db/schema/users"; +import { createTestDatabase, type TestDatabase } from "../db/test/fixtures"; +import { callServerFn, type SplitServerFnModule } from "../test/serverFn"; + +const getRequest = vi.fn(); +const setResponseStatus = vi.fn(); + +vi.mock("@tanstack/react-start/server", () => ({ + deleteCookie: vi.fn(), + getCookie: vi.fn(), + getRequest, + setCookie: vi.fn(), + setResponseStatus, +})); + +vi.mock("@sentry/tanstackstart-react", () => ({ + captureException: vi.fn(), + setUser: vi.fn(), +})); + +let db: Db; +vi.mock("../db/pg", () => ({ + client: {}, + get db() { + return db; + }, +})); + +vi.mock("../events/emit", () => ({ emit: vi.fn() })); + +const handlers = (await import( + "./functions.ts?tss-serverfn-split" +)) as SplitServerFnModule; +const { ForbiddenError } = await import("../auth/middleware"); +const { SESSION_ID_COOKIE, SESSION_TOKEN_COOKIE } = await import( + "../auth/cookies" +); +const { seedSession, seedUser } = await import("../auth/test/fixtures"); + +let database: TestDatabase; + +beforeAll(async () => { + database = await createTestDatabase(); + db = database.db; +}, 60_000); + +afterAll(async () => { + await database.drop(); +}); + +beforeEach(async () => { + vi.clearAllMocks(); + await db.delete(legacySamples); + await db.delete(jobs); + await db.delete(sessions); + await db.delete(users); + getRequest.mockReturnValue( + new Request("https://virtool.test/_serverFn/test"), + ); +}); + +/** Authenticate the next call as a user with the given administrator role. */ +async function signIn( + administratorRole: "full" | "base" | null = null, +): Promise { + const userId = await seedUser(db, { administratorRole }); + const { sessionId, token } = await seedSession(db, userId); + + getRequest.mockReturnValue( + new Request("https://virtool.test/_serverFn/test", { + headers: { + cookie: `${SESSION_ID_COOKIE}=${sessionId}; ${SESSION_TOKEN_COOKIE}=${token}`, + }, + }), + ); + + return userId; +} + +async function seedSampleRow( + overrides: Partial = {}, +): Promise { + return takeFirstOrThrow( + await db + .insert(legacySamples) + .values({ + name: "Sample", + library_type: "normal", + created_at: new Date(), + ...overrides, + }) + .returning({ id: legacySamples.id }), + ).id; +} + +function call(name: string, data?: unknown) { + return callServerFn(handlers, name, data); +} + +describe("getSample", () => { + it("returns 403 when the caller may not read the sample", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner, all_read: false }); + + await signIn(null); + + await expect(call("getSample", { sampleId })).rejects.toBeInstanceOf( + ForbiddenError, + ); + expect(setResponseStatus).toHaveBeenCalledWith(403); + }); + + it("returns 404, not 403, for a sample that does not exist", async () => { + await signIn(null); + + await expect(call("getSample", { sampleId: 123456 })).rejects.toThrow(); + expect(setResponseStatus).toHaveBeenCalledWith(404); + expect(setResponseStatus).not.toHaveBeenCalledWith(403); + }); + + it("returns the sample the owner may read", async () => { + const userId = await signIn(null); + const sampleId = await seedSampleRow({ user_id: userId }); + + const sample = (await call("getSample", { sampleId })) as { id: number }; + expect(sample.id).toBe(sampleId); + }); +}); + +describe("updateSampleRights", () => { + it("returns 404 for a sample that does not exist", async () => { + await signIn("full"); + + await expect( + call("updateSampleRights", { sampleId: 123456, all_read: true }), + ).rejects.toThrow(); + expect(setResponseStatus).toHaveBeenCalledWith(404); + }); + + it("refuses a non-owner who is not a full administrator", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner }); + + await signIn(null); + + await expect( + call("updateSampleRights", { sampleId, all_read: true }), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(setResponseStatus).toHaveBeenCalledWith(403); + }); + + it("allows the sample owner", async () => { + const userId = await signIn(null); + const sampleId = await seedSampleRow({ user_id: userId }); + + const sample = (await call("updateSampleRights", { + sampleId, + all_read: true, + })) as { all_read: boolean }; + expect(sample.all_read).toBe(true); + }); + + it("allows a full administrator who is not the owner", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner }); + + await signIn("full"); + + const sample = (await call("updateSampleRights", { + sampleId, + all_write: true, + })) as { all_write: boolean }; + expect(sample.all_write).toBe(true); + }); +}); + +describe("deleteSample", () => { + it("refuses a caller without write rights", async () => { + const owner = await seedUser(db, { handle: "owner" }); + const sampleId = await seedSampleRow({ user_id: owner, all_write: false }); + + await signIn(null); + + await expect(call("deleteSample", { sampleId })).rejects.toBeInstanceOf( + ForbiddenError, + ); + expect(setResponseStatus).toHaveBeenCalledWith(403); + }); + + it("returns 400 when an unfinished sample's job is still running", async () => { + const userId = await signIn(null); + + const jobId = takeFirstOrThrow( + await db + .insert(jobs) + .values({ + acquired: true, + created_at: new Date(), + state: "running", + user_id: userId, + workflow: "create_sample", + }) + .returning({ id: jobs.id }), + ).id; + + const sampleId = await seedSampleRow({ + user_id: userId, + ready: false, + job_id: jobId, + }); + + await expect(call("deleteSample", { sampleId })).rejects.toThrow(); + expect(setResponseStatus).toHaveBeenCalledWith(400); + + // The sample must survive the rejected delete. + expect( + await db + .select() + .from(legacySamples) + .where(eq(legacySamples.id, sampleId)), + ).toHaveLength(1); + }); +}); diff --git a/apps/web/src/server/samples/functions.ts b/apps/web/src/server/samples/functions.ts new file mode 100644 index 000000000..b919f12f7 --- /dev/null +++ b/apps/web/src/server/samples/functions.ts @@ -0,0 +1,279 @@ +import { createServerFn, createServerOnlyFn } from "@tanstack/react-start"; +import { setResponseStatus } from "@tanstack/react-start/server"; +import { z } from "zod"; +import { ForbiddenError } from "../auth/middleware"; +import { authenticated, permission } from "../auth/policy"; +import { db } from "../db/pg"; +import { ClientError } from "../errors"; +import { storage } from "../storage"; +import { UploadNotFoundError, UploadReservedError } from "../uploads/data"; +import { pageSchema, perPageSchema, rowIdSchema } from "../validation"; +import { + checkSampleRight, + createSample as createSampleImpl, + deleteSample as deleteSampleImpl, + findSamples as findSamplesImpl, + getSample as getSampleImpl, + getSampleOwnerId, + resolveSampleActor, + SampleFileDuplicateError, + SampleGroupNotFoundError, + SampleGroupRequiredError, + SampleLabelsNotFoundError, + SampleNameConflictError, + SampleNotFoundError, + type SampleRight, + SampleSubtractionsNotFoundError, + updateSample as updateSampleImpl, + updateSampleRights as updateSampleRightsImpl, +} from "./data"; + +const sampleIdSchema = z.object({ + sampleId: rowIdSchema, +}); + +const findSamplesSchema = z.object({ + page: pageSchema, + per_page: perPageSchema, + term: z.string().default(""), + labels: z.array(rowIdSchema).default([]), + workflows: z.array(z.string()).default([]), + users: z.array(rowIdSchema).default([]), +}); + +const libraryTypeSchema = z.enum(["amplicon", "srna", "other", "normal"]); + +// The group id (or legacy string), or null when none applies. `""` and `"none"` +// mean "no group", matching the Python request validator. +const groupSchema = z.union([z.number().int(), z.string(), z.null()]); + +const createSampleSchema = z.object({ + name: z.string().trim().min(1), + host: z.string().trim().default(""), + isolate: z.string().trim().default(""), + locale: z.string().trim().default(""), + notes: z.string().default(""), + library_type: libraryTypeSchema.default("normal"), + group: groupSchema.default(null), + subtractions: z.array(rowIdSchema).default([]), + labels: z.array(rowIdSchema).default([]), + files: z.array(rowIdSchema).min(1).max(2), +}); + +const updateSampleSchema = sampleIdSchema.extend({ + name: z.string().trim().min(1).optional(), + host: z.string().trim().optional(), + isolate: z.string().trim().optional(), + locale: z.string().trim().optional(), + notes: z.string().trim().optional(), + labels: z.array(rowIdSchema).optional(), + subtractions: z.array(rowIdSchema).optional(), +}); + +const updateRightsSchema = sampleIdSchema.extend({ + all_read: z.boolean().optional(), + all_write: z.boolean().optional(), + group: groupSchema.optional(), + group_read: z.boolean().optional(), + group_write: z.boolean().optional(), +}); + +// Job states from which a sample may be deleted: the terminal states, where the +// creation job is finished and will not resume. +const DELETABLE_JOB_STATES = new Set(["cancelled", "failed", "succeeded"]); + +// Wrapped in createServerOnlyFn so the compiler can strip this body — and the +// ./data imports it references — from the client bundle. A plain top-level +// helper would pin ./data and its postgres transitive dependency in the client +// graph. +const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { + if (err instanceof SampleNotFoundError) { + setResponseStatus(404); + throw new ClientError("Sample not found.", 404); + } + if (err instanceof SampleNameConflictError) { + setResponseStatus(400); + throw new ClientError("Sample name is already in use.", 400); + } + if ( + err instanceof SampleLabelsNotFoundError || + err instanceof SampleSubtractionsNotFoundError || + err instanceof SampleGroupNotFoundError || + err instanceof SampleGroupRequiredError || + err instanceof SampleFileDuplicateError + ) { + setResponseStatus(400); + throw new ClientError(err.message, 400); + } + if (err instanceof UploadNotFoundError) { + setResponseStatus(400); + throw new ClientError("File does not exist.", 400); + } + if (err instanceof UploadReservedError) { + setResponseStatus(400); + throw new ClientError("File is already reserved.", 400); + } + throw err; +}); + +// The `authenticated()` floor guarantees a signed-in caller; this enforces the +// per-sample right the operation needs on top of it. A lookup for a nonexistent +// sample returns "allowed", so the subsequent fetch surfaces the 404 rather than +// a misleading 403. +const authorizeSample = createServerOnlyFn( + async ( + sampleId: number, + userId: number, + right: SampleRight, + ): Promise => { + const actor = await resolveSampleActor(db, userId); + + if (!(await checkSampleRight(db, sampleId, actor, right))) { + setResponseStatus(403); + throw new ForbiddenError(); + } + }, +); + +function coerceGroup(group: string | number | null | undefined): number | null { + if (group == null || group === "" || group === "none") { + return null; + } + return typeof group === "number" ? group : Number(group); +} + +export const findSamples = createServerFn({ method: "GET" }) + .middleware([authenticated()]) + .validator(findSamplesSchema) + .handler(async ({ context, data }) => { + const actor = await resolveSampleActor(db, context.session.userId); + + return findSamplesImpl( + db, + { + page: data.page, + perPage: data.per_page, + term: data.term, + labels: data.labels, + users: data.users, + workflows: data.workflows, + }, + actor, + ); + }); + +export const getSample = createServerFn({ method: "GET" }) + .middleware([authenticated()]) + .validator(sampleIdSchema) + .handler(async ({ context, data }) => { + try { + await authorizeSample(data.sampleId, context.session.userId, "read"); + return await getSampleImpl(db, data.sampleId); + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const createSample = createServerFn({ method: "POST" }) + .middleware([permission("create_sample")]) + .validator(createSampleSchema) + .handler(async ({ context, data }) => { + try { + const sample = await createSampleImpl(db, { + name: data.name, + host: data.host, + isolate: data.isolate, + locale: data.locale, + notes: data.notes, + libraryType: data.library_type, + group: coerceGroup(data.group), + subtractions: data.subtractions, + labels: data.labels, + files: data.files, + userId: context.session.userId, + }); + setResponseStatus(201); + return sample; + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const updateSample = createServerFn({ method: "POST" }) + .middleware([authenticated()]) + .validator(updateSampleSchema) + .handler(async ({ context, data }) => { + const { sampleId, ...values } = data; + try { + await authorizeSample(sampleId, context.session.userId, "write"); + return await updateSampleImpl(db, sampleId, values); + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const deleteSample = createServerFn({ method: "POST" }) + .middleware([authenticated()]) + .validator(sampleIdSchema) + .handler(async ({ context, data }) => { + try { + await authorizeSample(data.sampleId, context.session.userId, "write"); + + const sample = await getSampleImpl(db, data.sampleId); + + // A sample whose creation job is still running cannot be deleted. + if (!sample.ready) { + if (!sample.job) { + setResponseStatus(400); + throw new ClientError( + "Unfinalized samples without jobs cannot be deleted.", + 400, + ); + } + if (!DELETABLE_JOB_STATES.has(sample.job.state)) { + setResponseStatus(400); + throw new ClientError( + `Cannot delete sample with active job (current state: ${sample.job.state}).`, + 400, + ); + } + } + + await deleteSampleImpl(db, storage, data.sampleId); + setResponseStatus(204); + return null; + } catch (err) { + return rethrowAsHttp(err); + } + }); + +export const updateSampleRights = createServerFn({ method: "POST" }) + .middleware([authenticated()]) + .validator(updateRightsSchema) + .handler(async ({ context, data }) => { + const { sampleId, ...values } = data; + + // Rights mutation is gated more tightly than read/write: only the owner or + // a full administrator may change them. A missing sample is a 404, not a + // 403 — the owner lookup returns null for both. + const ownerId = await getSampleOwnerId(db, sampleId); + + if (ownerId === null) { + setResponseStatus(404); + throw new ClientError("Sample not found.", 404); + } + + const actor = await resolveSampleActor(db, context.session.userId); + + // Only the owner or a full administrator may change a sample's rights. + if (!actor.isAdmin && actor.userId !== ownerId) { + setResponseStatus(403); + throw new ForbiddenError(); + } + + try { + return await updateSampleRightsImpl(db, sampleId, values); + } catch (err) { + return rethrowAsHttp(err); + } + }); diff --git a/apps/web/src/server/subtraction/data.test.ts b/apps/web/src/server/subtraction/data.test.ts index b4661f701..88be63ec0 100644 --- a/apps/web/src/server/subtraction/data.test.ts +++ b/apps/web/src/server/subtraction/data.test.ts @@ -226,7 +226,11 @@ describe("getSubtraction", () => { const sampleId = takeFirstOrThrow( await db .insert(legacySamples) - .values({ name: "Sample A" }) + .values({ + name: "Sample A", + library_type: "normal", + created_at: new Date(), + }) .returning({ id: legacySamples.id }), ).id; @@ -336,7 +340,11 @@ describe("deleteSubtraction", () => { const sampleId = takeFirstOrThrow( await db .insert(legacySamples) - .values({ name: "Sample A" }) + .values({ + name: "Sample A", + library_type: "normal", + created_at: new Date(), + }) .returning({ id: legacySamples.id }), ).id; diff --git a/apps/web/src/server/uploads/data.ts b/apps/web/src/server/uploads/data.ts index ebfff6035..67b93e341 100644 --- a/apps/web/src/server/uploads/data.ts +++ b/apps/web/src/server/uploads/data.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq } from "drizzle-orm"; +import { and, count, desc, eq, inArray } from "drizzle-orm"; import type { DbOrTx } from "../db/pg"; import { takeFirstOrThrow } from "../db/rows"; import { type UploadRow, uploads as uploadsTable } from "../db/schema/uploads"; @@ -190,6 +190,51 @@ export async function createUpload( return toUpload(row, await fetchUser(db, row.userId)); } +/** + * Reserve the given uploads so they cannot be used for another sample. + * + * Every upload is validated before any is reserved: if one is missing a + * {@link UploadNotFoundError} is thrown, and if one is already reserved a + * {@link UploadReservedError} is thrown — in both cases none is reserved. The + * final update is conditional on `reserved = false` and its row count checked, + * so a request that loses a race to reserve one of these uploads fails rather + * than double-reserving. Takes `DbOrTx` to run inside the caller's transaction; + * the caller commits. + */ +export async function reserveUploads( + db: DbOrTx, + uploadIds: number[], +): Promise { + const ids = [...new Set(uploadIds)]; + + if (ids.length === 0) { + return; + } + + const existing = await db + .select({ id: uploadsTable.id, reserved: uploadsTable.reserved }) + .from(uploadsTable) + .where(inArray(uploadsTable.id, ids)); + + if (existing.length !== ids.length) { + throw new UploadNotFoundError(); + } + + if (existing.some((row) => row.reserved)) { + throw new UploadReservedError(); + } + + const reserved = await db + .update(uploadsTable) + .set({ reserved: true }) + .where(and(inArray(uploadsTable.id, ids), eq(uploadsTable.reserved, false))) + .returning({ id: uploadsTable.id }); + + if (reserved.length !== ids.length) { + throw new UploadReservedError(); + } +} + export async function deleteUpload( db: DbOrTx, storage: StorageBackend, diff --git a/apps/web/src/tests/api/samples.ts b/apps/web/src/tests/api/samples.ts deleted file mode 100644 index c4f50d4f9..000000000 --- a/apps/web/src/tests/api/samples.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { faker } from "@faker-js/faker"; -import type { - LibraryType, - Sample, - SampleMinimal, - SampleRightsUpdate, -} from "@samples/types"; -import nock from "nock"; -import { createFakeSample } from "../fake/samples"; -import { createFakeSubtractionNested } from "../fake/subtractions"; - -/** - * Creates a mocked API call for getting a paginated list of samples - * - * @param samples - The sample documents - * @param counts - Overrides for the counts, which otherwise both match the - * number of documents. ``total_count`` is every sample the user may see and - * ``found_count`` is only those matching the filters. - * @returns The nock scope for the mocked API call - */ -export function mockApiGetSamples( - samples: SampleMinimal[], - counts: { found_count?: number; total_count?: number } = {}, -) { - return nock("http://localhost") - .get("/api/samples") - .query(true) - .reply(200, { - page: 1, - page_count: 1, - per_page: 5, - total_count: counts.total_count ?? samples.length, - found_count: counts.found_count ?? samples.length, - documents: samples, - }); -} - -/** - * Sets up a mocked API route for fetching a single sample - * - * @param sampleDetail - The sample detail to be returned from the mocked API call - * @param statusCode - The HTTP status code to simulate in the response - * @returns The nock scope for the mocked API call - */ -export function mockApiGetSampleDetail( - sampleDetail: Sample, - statusCode?: number, -) { - return nock("http://localhost") - .get(`/api/samples/${sampleDetail.id}`) - .query(true) - .reply(statusCode || 200, sampleDetail); -} - -/** - * Sets up a mocked API route for updating the sample details - * - * @param sample - The sample details - * @param name - The updated name - * @param isolate - The updated isolate - * @param host - The updated host - * @param locale - The updated locale - * @param notes - The updated notes - * @returns A nock scope for the mocked API call - */ -export function mockApiEditSample( - sample: Sample, - name: string, - isolate: string, - host: string, - locale: string, - notes: string, -) { - const sampleDetail = { ...sample, name, isolate, host, locale, notes }; - - return nock("http://localhost") - .patch(`/api/samples/${sample.id}`) - .reply(200, sampleDetail); -} - -/** - * Sets up a mocked API route for updating a samples rights - * - * @param sample - The sample details - * @param update - The update to be applied - * @returns A nock scope for the mocked API call - */ -export function mockApiUpdateSampleRights( - sample: Sample, - update: SampleRightsUpdate, -) { - const sampleRightsUpdate = { - all_read: faker.datatype.boolean(), - all_write: faker.datatype.boolean(), - group: null, - group_read: faker.datatype.boolean(), - group_write: faker.datatype.boolean(), - user: { id: sample.user.id }, - ...update, - }; - - return nock("http://localhost") - .patch(`/api/samples/${sample.id}/rights`) - .reply(200, sampleRightsUpdate); -} - -/** - * Creates a mocked API call for creating a sample - * - * @param name - The name of the sample - * @param isolate - The sample isolate - * @param host - The sample host - * @param locale - The locale of the sample - * @param library_type - The library type of the sample - * @param files - The sample uploads used to create the sample - * @param labels - The labels associated with the sample - * @param subtractions - The subtractions associated with the sample - * @param group - The group associated with the sample - * @returns The nock scope for the mocked API call - */ -export function mockApiCreateSample( - name: string, - isolate: string, - host: string, - locale: string, - library_type: LibraryType, - files: number[], - labels: number[], - subtractions: number[], - group: string | null, -) { - const sample = createFakeSample({ - name, - isolate, - host, - locale, - library_type, - subtractions: [createFakeSubtractionNested({ id: subtractions[0] })], - }); - - return nock("http://localhost") - .post("/api/samples", { - name, - isolate, - host, - locale, - library_type, - files, - labels, - subtractions, - group, - }) - .reply(201, sample); -} - -/** - * Creates a mocked API call for removing a sample - * - * @param sampleId - The id of the sample being removed - * @returns The nock scope for the mocked API call - */ -export function mockApiRemoveSample(sampleId: number) { - return nock("http://localhost").delete(`/api/samples/${sampleId}`).reply(200); -} diff --git a/apps/web/src/tests/fake/samples.ts b/apps/web/src/tests/fake/samples.ts index cf8625937..44cab1afd 100644 --- a/apps/web/src/tests/fake/samples.ts +++ b/apps/web/src/tests/fake/samples.ts @@ -19,7 +19,9 @@ export function createFakeSampleMinimal( created_at: faker.date.past().toISOString(), host: faker.word.noun({ strategy: "any-length" }), isolate: faker.word.noun({ strategy: "any-length" }), - job: createFakeServerJobNested({ workflow: "create_sample" }), + job: createFakeServerJobNested({ + workflow: "create_sample", + }) as SampleMinimal["job"], labels: [createFakeLabelNested()], library_type: "normal", notes: faker.lorem.lines(5), diff --git a/apps/web/src/tests/server-fn/samples.ts b/apps/web/src/tests/server-fn/samples.ts new file mode 100644 index 000000000..03250d029 --- /dev/null +++ b/apps/web/src/tests/server-fn/samples.ts @@ -0,0 +1,105 @@ +import type { Sample, SampleMinimal } from "@samples/types"; +import { type Mock, vi } from "vitest"; +import { createFakeSample } from "../fake/samples"; + +/** + * Mock handles for the `@server/samples/functions` server-fn module. Wired in + * globally from `tests/setup.tsx` so any test importing this helper can stub the + * sample server functions without per-file `vi.mock` boilerplate. + */ +export const sampleServerFnMocks = { + findSamples: vi.fn(), + getSample: vi.fn(), + createSample: vi.fn(), + updateSample: vi.fn(), + deleteSample: vi.fn(), + updateSampleRights: vi.fn(), +}; + +/** + * Sets up findSamples to resolve with a single page of the given samples. + * + * @param samples - the samples on the page + * @param counts - overrides for the counts, which otherwise both match the + * number of samples. `total_count` is every sample the user may see and + * `found_count` is only those matching the filters. + */ +export function mockGetSamples( + samples: SampleMinimal[], + counts: { found_count?: number; total_count?: number } = {}, +): Mock { + sampleServerFnMocks.findSamples.mockResolvedValue({ + page: 1, + page_count: 1, + per_page: 5, + total_count: counts.total_count ?? samples.length, + found_count: counts.found_count ?? samples.length, + items: samples, + }); + return sampleServerFnMocks.findSamples; +} + +/** + * Sets up findSamples to serve one page per entry in `pages`, selecting by the + * requested `page`, so a selection made on one page can be asserted from + * another. + * + * @param pages - the samples on each page, in page order + */ +export function mockGetSamplePages(pages: SampleMinimal[][]): Mock { + sampleServerFnMocks.findSamples.mockImplementation( + async ({ data }: { data?: { page?: number } }) => { + const page = data?.page ?? 1; + return { + page, + page_count: pages.length, + per_page: 1, + total_count: pages.length, + found_count: pages.length, + items: pages[page - 1] ?? [], + }; + }, + ); + return sampleServerFnMocks.findSamples; +} + +/** Sets up getSample to resolve with the given sample. */ +export function mockGetSampleDetail(sample: Sample): Mock { + sampleServerFnMocks.getSample.mockResolvedValue(sample); + return sampleServerFnMocks.getSample; +} + +/** Sets up createSample to resolve with the given (or a fake) sample. */ +export function mockCreateSample(sample?: Sample): Mock { + sampleServerFnMocks.createSample.mockResolvedValue( + sample ?? createFakeSample(), + ); + return sampleServerFnMocks.createSample; +} + +/** Sets up updateSample to resolve with the given sample, patched with the fields. */ +export function mockEditSample( + sample: Sample, + update: Partial = {}, +): Mock { + sampleServerFnMocks.updateSample.mockResolvedValue({ ...sample, ...update }); + return sampleServerFnMocks.updateSample; +} + +/** Sets up deleteSample to resolve. */ +export function mockRemoveSample(): Mock { + sampleServerFnMocks.deleteSample.mockResolvedValue(null); + return sampleServerFnMocks.deleteSample; +} + +/** Sets up updateSampleRights to resolve with the given sample, patched with the fields. */ +export function mockUpdateSampleRights( + sample: Sample, + update: Partial = {}, +): Mock { + sampleServerFnMocks.updateSampleRights.mockResolvedValue({ + ...sample, + ...update, + }); + return sampleServerFnMocks.updateSampleRights; +} diff --git a/apps/web/src/tests/setup.tsx b/apps/web/src/tests/setup.tsx index 25d5ac85d..fad1e410f 100644 --- a/apps/web/src/tests/setup.tsx +++ b/apps/web/src/tests/setup.tsx @@ -30,6 +30,7 @@ import { jobServerFnMocks } from "./server-fn/jobs"; import { labelServerFnMocks } from "./server-fn/labels"; import { referenceServerFnMocks } from "./server-fn/references"; import { rootServerFnMocks } from "./server-fn/root"; +import { sampleServerFnMocks } from "./server-fn/samples"; import { mockGetPasswordPolicy, settingsServerFnMocks, @@ -89,6 +90,11 @@ vi.mock("@server/references/functions", async () => { return referenceServerFnMocks; }); +vi.mock("@server/samples/functions", async () => { + const { sampleServerFnMocks } = await import("./server-fn/samples"); + return sampleServerFnMocks; +}); + beforeEach(() => { for (const fn of Object.values(groupServerFnMocks)) { fn.mockReset(); @@ -108,6 +114,8 @@ beforeEach(() => { subtractionServerFnMocks.listSubtractionsShortlist, referenceServerFnMocks.findReferences, referenceServerFnMocks.getReference, + sampleServerFnMocks.findSamples, + sampleServerFnMocks.getSample, settingsServerFnMocks.getSettings, ]) { fn.mockReset(); @@ -130,6 +138,10 @@ beforeEach(() => { referenceServerFnMocks.updateReferenceGroup, referenceServerFnMocks.removeReferenceUser, referenceServerFnMocks.removeReferenceGroup, + sampleServerFnMocks.createSample, + sampleServerFnMocks.updateSample, + sampleServerFnMocks.deleteSample, + sampleServerFnMocks.updateSampleRights, settingsServerFnMocks.updateSettings, ]) { fn.mockReset(); diff --git a/docs/database.md b/docs/database.md index 3fb339209..9e4bc550f 100644 --- a/docs/database.md +++ b/docs/database.md @@ -78,7 +78,7 @@ states: | API keys | `api_keys` | Built | | Analyses | `analyses` | Partial mirror | | Indexes | `indexes` | Partial mirror | -| Samples | `legacy_samples` | Partial mirror | +| Samples | `legacy_samples`, `legacy_sample_*`, `sample_*` | Built | | Subtractions | `subtractions`, `subtraction_files` | Built | | References | `legacy_references`, `legacy_reference_*` | Built | | Uploads | `uploads` | Not started | @@ -96,9 +96,13 @@ reverse `job_id` foreign keys on `analyses`, `indexes`, and read path adds three more: `legacy_otus` (an OTU count and the clone manifest), `legacy_history` (contributors and the unbuilt-change count), and the extra `indexes` columns that resolve a reference's latest build. -Each partial mirror declares just the columns its consumer needs. The -`subtractions` mirror is now full — the subtraction domain is served -from this repo — but jobs still reaches it through the same reverse +The samples read path adds three columns to the `analyses` mirror — +`sample_id`, `workflow`, and `ready` — to derive a sample's workflow tags +(a `GROUP BY workflow, bool_or(ready)`) and to power the `workflows=` +filter (a correlated `EXISTS`); the analyses domain itself is still only a +partial mirror. Each partial mirror declares just the columns its consumer +needs. The `subtractions` mirror is now full — the subtraction domain is +served from this repo — but jobs still reaches it through the same reverse `job_id` foreign key. A `legacy_` table prefix marks a table that carries the Mongo-era row diff --git a/packages/contracts/src/groups.ts b/packages/contracts/src/groups.ts new file mode 100644 index 000000000..419529e85 --- /dev/null +++ b/packages/contracts/src/groups.ts @@ -0,0 +1,11 @@ +/** A group reduced to the fields embedded in other resources. */ +export type GroupMinimal = { + /** The unique identifier */ + id: number; + + /** The Mongo-era string id, or null for a Postgres-native group */ + legacy_id: string | null; + + /** The display name */ + name: string; +}; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6e0781893..0769ece5d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,9 +4,11 @@ export * from "./artifacts"; export * from "./auth"; export * from "./banners"; export * from "./errors"; +export * from "./groups"; export * from "./hmms"; export * from "./indexes"; export * from "./jobs"; +export * from "./labels"; export * from "./permissions"; export * from "./permissionsSchema"; export * from "./references"; diff --git a/packages/contracts/src/labels.ts b/packages/contracts/src/labels.ts new file mode 100644 index 000000000..4c0d58069 --- /dev/null +++ b/packages/contracts/src/labels.ts @@ -0,0 +1,14 @@ +/** A label reduced to the fields embedded in other resources. */ +export type LabelNested = { + /** The hex encoded color */ + color: string; + + /** The detailed description */ + description: string; + + /** The unique identifier */ + id: number; + + /** The display name */ + name: string; +}; diff --git a/packages/contracts/src/samples.ts b/packages/contracts/src/samples.ts index 53ab5a99f..e07ecb6dd 100644 --- a/packages/contracts/src/samples.ts +++ b/packages/contracts/src/samples.ts @@ -1,17 +1,153 @@ -import { z } from "zod"; +import type { GroupMinimal } from "./groups"; +import type { LabelNested } from "./labels"; +import type { SubtractionNested } from "./subtractions"; +import type { UserNested } from "./users"; -/** Library type used by sample-trimming heuristics; mirrors the Python `LibraryType` enum. */ -export const LibraryType = z.enum(["normal", "srna"]); +/** The library preparation used to create a sample. */ +export type LibraryType = "amplicon" | "srna" | "other" | "normal"; -export type LibraryType = z.infer; +/** The state of a single workflow for a sample. */ +export type WorkflowState = "complete" | "pending" | "none" | "incompatible"; -/** Sample metadata returned by `GET /samples/{id}`. Provisional shape — fields land as the runner needs them. */ -export const Sample = z.object({ - id: z.int(), - name: z.string(), - paired: z.boolean(), - library_type: LibraryType, - max_length: z.number().int().nonnegative(), -}); +/** The state of each workflow tracked for a sample. */ +export type SampleWorkflows = { + /** The state of the NuVs workflow */ + nuvs: WorkflowState; -export type Sample = z.infer; + /** The state of the Pathoscope workflow */ + pathoscope: WorkflowState; +}; + +/** A lifecycle state a sample's creation job can be in. */ +export type SampleJobState = + | "cancelled" + | "failed" + | "pending" + | "running" + | "succeeded"; + +/** A workflow a sample's creation job can run. */ +export type SampleJobWorkflow = + | "build_index" + | "create_sample" + | "create_subtraction" + | "nuvs" + | "pathoscope"; + +/** The creation job embedded in a sample. */ +export type SampleJobNested = { + created_at: string; + id: number; + progress: number; + state: SampleJobState; + user: UserNested; + workflow: SampleJobWorkflow; +}; + +/** An artifact produced while creating a sample. */ +export type SampleArtifact = { + download_url: string; + id: number; + name: string; + size: number; +}; + +/** An input upload embedded in a sample reads file. */ +export type SampleReadUpload = { + id: number; + name: string; + size: number | null; + uploaded_at: string | null; + user: UserNested | null; +}; + +/** A reads file that makes up a sample. */ +export type Read = { + download_url: string; + id: number; + name: string; + name_on_disk: string; + sample: number; + size: number; + upload?: SampleReadUpload | null; + uploaded_at: string; +}; + +/** The FastQC quality charts associated with a sample. */ +export type Quality = { + /** Data for the per-base quality chart */ + bases: number[][]; + + /** Data for the composition chart */ + composition: number[][]; + + /** The read count of the sample */ + count: number; + + /** The quality-score encoding */ + encoding: string; + + /** The GC content of the sample as a percentage */ + gc: number; + + /** The read-length range */ + length: number[]; + + /** Data for the sequences chart */ + sequences: number[]; +}; + +/** A sample reduced to the fields shown in resource listings. */ +export type SampleMinimal = { + created_at: string; + host: string; + id: number; + isolate: string; + job?: SampleJobNested; + labels: LabelNested[]; + library_type: LibraryType; + name: string; + notes: string; + nuvs: boolean | string; + pathoscope: boolean | string; + ready: boolean; + user: UserNested; + workflows: SampleWorkflows; +}; + +/** A complete sample, as returned by the detail endpoint. */ +export type Sample = SampleMinimal & { + all_read: boolean; + all_write: boolean; + artifacts: SampleArtifact[]; + format: string; + group: GroupMinimal | null; + group_read: boolean; + group_write: boolean; + hold: boolean; + is_legacy: boolean; + locale: string; + paired: boolean; + quality: Quality | null; + reads: Read[]; + subtractions: SubtractionNested[]; +}; + +/** A page of samples with pagination metadata. */ +export type SampleSearchResult = { + found_count: number; + items: SampleMinimal[]; + page: number; + page_count: number; + per_page: number; + total_count: number; +}; + +/** The rights fields that can be changed on a sample. */ +export type SampleRightsUpdate = { + all_read?: boolean; + all_write?: boolean; + group?: number | string | null; + group_read?: boolean; + group_write?: boolean; +}; diff --git a/packages/contracts/src/subtractions.ts b/packages/contracts/src/subtractions.ts index 06b9e7309..4b9e38b5d 100644 --- a/packages/contracts/src/subtractions.ts +++ b/packages/contracts/src/subtractions.ts @@ -6,3 +6,12 @@ export const Subtraction = z.object({ }); export type Subtraction = z.infer; + +/** A subtraction reduced to the fields embedded in other resources. */ +export type SubtractionNested = { + /** The unique identifier */ + id: number; + + /** The display name */ + name: string; +}; From 6b2edc3b0d727074ef938cc345e7931994bbb731 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 11:55:41 -0700 Subject: [PATCH 02/11] fix: show reads and artifacts for legacy samples The sample detail queries for reads and artifacts filtered on sample_id only, so a legacy sample whose file rows are keyed by the text storage id (with sample_id null) appeared to have no read or artifact files. Match the delete path and OR against the storage id resolved from legacy_id. --- apps/web/src/server/samples/data.test.ts | 24 ++++++++++++++++++++++ apps/web/src/server/samples/data.ts | 26 +++++++++++++++++++----- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/web/src/server/samples/data.test.ts b/apps/web/src/server/samples/data.test.ts index 2bf24998f..bdbb4bdb0 100644 --- a/apps/web/src/server/samples/data.test.ts +++ b/apps/web/src/server/samples/data.test.ts @@ -407,6 +407,30 @@ describe("getSample", () => { expect(sample.user.id).toBe(ownerId); }); + it("loads reads and artifacts keyed by the legacy storage id", async () => { + const sampleId = await seedSample({ legacy_id: "abc123" }); + + // A legacy row is keyed only by the text `sample` storage id, with + // `sample_id` left null. + await db.insert(sampleReads).values({ + sample: "abc123", + sample_id: null, + name: "reads_1.fq.gz", + name_on_disk: "reads_1.fq.gz", + }); + await db.insert(sampleArtifacts).values({ + sample: "abc123", + sample_id: null, + name: "fastqc.txt", + type: "fastqc", + }); + + const sample = await getSample(db, sampleId); + + expect(sample.reads.map((r) => r.name)).toEqual(["reads_1.fq.gz"]); + expect(sample.artifacts.map((a) => a.name)).toEqual(["fastqc.txt"]); + }); + it("throws when the sample does not exist", async () => { await expect(getSample(db, 123456)).rejects.toBeInstanceOf( SampleNotFoundError, diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts index 6334dcfe1..2d04cbec4 100644 --- a/apps/web/src/server/samples/data.ts +++ b/apps/web/src/server/samples/data.ts @@ -310,11 +310,17 @@ async function getSubtractionsBySample( async function getArtifacts( db: DbOrTx, sampleId: number, + storageId: string, ): Promise { const rows = await db .select() .from(sampleArtifacts) - .where(eq(sampleArtifacts.sample_id, sampleId)) + .where( + or( + eq(sampleArtifacts.sample_id, sampleId), + eq(sampleArtifacts.sample, storageId), + ), + ) .orderBy(asc(sampleArtifacts.id)); return rows.map((row) => ({ @@ -325,7 +331,11 @@ async function getArtifacts( })); } -async function getReads(db: DbOrTx, sampleId: number): Promise { +async function getReads( + db: DbOrTx, + sampleId: number, + storageId: string, +): Promise { const rows = await db .select({ read: sampleReads, @@ -335,7 +345,12 @@ async function getReads(db: DbOrTx, sampleId: number): Promise { .from(sampleReads) .leftJoin(uploads, eq(uploads.id, sampleReads.upload)) .leftJoin(users, eq(users.id, uploads.userId)) - .where(eq(sampleReads.sample_id, sampleId)) + .where( + or( + eq(sampleReads.sample_id, sampleId), + eq(sampleReads.sample, storageId), + ), + ) .orderBy(asc(sampleReads.id)); return rows.map(({ read, upload, uploadUser }) => ({ @@ -677,6 +692,7 @@ export async function getSample(db: Db, sampleId: number): Promise { const jobIds = row.job_id != null ? [row.job_id] : []; const userIds = row.user_id != null ? [row.user_id] : []; + const storageId = sampleStorageId(sampleId, row.legacy_id); const [ labelsBySample, @@ -693,8 +709,8 @@ export async function getSample(db: Db, sampleId: number): Promise { getSampleJobs(db, jobIds), getUsersByIds(db, userIds), getSubtractionsBySample(db, sampleId), - getArtifacts(db, sampleId), - getReads(db, sampleId), + getArtifacts(db, sampleId, storageId), + getReads(db, sampleId, storageId), row.group_id != null ? getGroup(db, row.group_id) : Promise.resolve(null), ]); From 3c5259d858af39860ad3149736c102694ef156e8 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 11:55:41 -0700 Subject: [PATCH 03/11] fix: reject non-reads files when creating a sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reserveUploads only checked that each supplied upload existed and was unreserved, so a create-sample request could reserve reference or subtraction uploads, unfinished uploads, or removed ones — consuming unrelated files and scheduling a job that cannot process its inputs. Require each id to resolve to a visible reads upload (type reads, ready, not removed) before reserving. --- apps/web/src/server/samples/data.test.ts | 49 ++++++++++++++++++++++++ apps/web/src/server/uploads/data.ts | 27 +++++++++---- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/apps/web/src/server/samples/data.test.ts b/apps/web/src/server/samples/data.test.ts index bdbb4bdb0..284960490 100644 --- a/apps/web/src/server/samples/data.test.ts +++ b/apps/web/src/server/samples/data.test.ts @@ -21,6 +21,7 @@ import { users } from "../db/schema/users"; import { createTestDatabase, type TestDatabase } from "../db/test/fixtures"; import { addToGroup, seedGroup } from "../groups/test/fixtures"; import { MemoryStorage } from "../storage"; +import { UploadNotFoundError } from "../uploads/data"; import { type CreateSampleValues, checkSampleRight, @@ -117,6 +118,7 @@ async function seedUpload(reserved = false): Promise { name: "reads.fq.gz", nameOnDisk: `disk-${Math.random()}`, type: "reads", + ready: true, reserved, userId: ownerId, }) @@ -530,6 +532,53 @@ describe("createSample", () => { createSample(db, values({ files: [file], labels: [987654] })), ).rejects.toBeInstanceOf(SampleLabelsNotFoundError); }); + + it("rejects a file that is not a visible reads upload", async () => { + const reference = takeFirstOrThrow( + await db + .insert(uploads) + .values({ + createdAt: new Date(), + name: "ref.fa.gz", + nameOnDisk: `disk-ref-${Math.random()}`, + type: "reference", + ready: true, + userId: ownerId, + }) + .returning({ id: uploads.id }), + ).id; + + await expect( + createSample(db, values({ files: [reference] })), + ).rejects.toBeInstanceOf(UploadNotFoundError); + + // The reference upload is left untouched by the failed reservation. + const [row] = await db + .select({ reserved: uploads.reserved }) + .from(uploads) + .where(eq(uploads.id, reference)); + expect(row?.reserved).toBe(false); + }); + + it("rejects an unfinished reads upload", async () => { + const pending = takeFirstOrThrow( + await db + .insert(uploads) + .values({ + createdAt: new Date(), + name: "reads.fq.gz", + nameOnDisk: `disk-pending-${Math.random()}`, + type: "reads", + ready: false, + userId: ownerId, + }) + .returning({ id: uploads.id }), + ).id; + + await expect( + createSample(db, values({ files: [pending] })), + ).rejects.toBeInstanceOf(UploadNotFoundError); + }); }); describe("updateSample", () => { diff --git a/apps/web/src/server/uploads/data.ts b/apps/web/src/server/uploads/data.ts index 67b93e341..1a2e5647a 100644 --- a/apps/web/src/server/uploads/data.ts +++ b/apps/web/src/server/uploads/data.ts @@ -193,13 +193,17 @@ export async function createUpload( /** * Reserve the given uploads so they cannot be used for another sample. * - * Every upload is validated before any is reserved: if one is missing a - * {@link UploadNotFoundError} is thrown, and if one is already reserved a - * {@link UploadReservedError} is thrown — in both cases none is reserved. The - * final update is conditional on `reserved = false` and its row count checked, - * so a request that loses a race to reserve one of these uploads fails rather - * than double-reserving. Takes `DbOrTx` to run inside the caller's transaction; - * the caller commits. + * Only a visible reads upload is a valid sample input, so every id must resolve + * to a `reads` upload that is `ready` and not `removed`; any id that does not — + * a `reference`/`subtraction` upload, an unfinished one, or a removed one — is + * rejected with {@link UploadNotFoundError}, exactly as a missing id is. This + * runs before any reservation, so a bad batch reserves none. + * + * If one is already reserved a {@link UploadReservedError} is thrown. The final + * update is conditional on `reserved = false` and its row count checked, so a + * request that loses a race to reserve one of these uploads fails rather than + * double-reserving. Takes `DbOrTx` to run inside the caller's transaction; the + * caller commits. */ export async function reserveUploads( db: DbOrTx, @@ -214,7 +218,14 @@ export async function reserveUploads( const existing = await db .select({ id: uploadsTable.id, reserved: uploadsTable.reserved }) .from(uploadsTable) - .where(inArray(uploadsTable.id, ids)); + .where( + and( + inArray(uploadsTable.id, ids), + eq(uploadsTable.type, "reads"), + eq(uploadsTable.ready, true), + eq(uploadsTable.removed, false), + ), + ); if (existing.length !== ids.length) { throw new UploadNotFoundError(); From 03b5f429e895c59026e54894e74b32103e43be5a Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 12:36:29 -0700 Subject: [PATCH 04/11] refactor: camelCase the samples wire shape and share job types The samples server functions were the last to use snake_case wire fields, inconsistent with the camelCase shapes every other migrated feature returns (references, jobs). Rename the sample, read, artifact, and rights fields to camelCase across the contract, the server data/functions layer, and the client consumers, and fold the search envelope onto the shared SearchResultV2. Consolidate the sample-specific job types too: a shared JobNested and JobState now live in @virtool/contracts, and a sample's embedded job is JobNested narrowed to the create_sample workflow that built it. --- .../__tests__/AnalysisList.test.tsx | 8 +-- .../_authenticated/samples/$sampleId.tsx | 7 +- .../Create/__tests__/CreateSample.test.tsx | 4 +- .../components/Detail/SampleDetailGeneral.tsx | 9 ++- .../components/Detail/SampleRights.tsx | 10 +-- .../Detail/__tests__/SampleRights.test.tsx | 12 ++-- .../src/samples/components/Files/ReadItem.tsx | 6 +- .../components/Files/SampleDetailFiles.tsx | 2 +- .../samples/components/Files/SampleReads.tsx | 2 +- .../samples/components/Item/SampleItem.tsx | 10 ++- .../src/samples/components/SampleQuality.tsx | 2 +- .../src/samples/components/SamplesList.tsx | 6 +- .../components/__tests__/SamplesList.test.tsx | 2 +- apps/web/src/samples/hooks.ts | 4 +- apps/web/src/samples/queries.ts | 4 +- apps/web/src/samples/types.ts | 1 + apps/web/src/samples/utils.ts | 25 ++++++- apps/web/src/server/samples/data.test.ts | 14 ++-- apps/web/src/server/samples/data.ts | 60 ++++++++-------- apps/web/src/server/samples/functions.test.ts | 16 ++--- apps/web/src/server/samples/functions.ts | 16 ++--- apps/web/src/tests/fake/samples.ts | 30 ++++---- apps/web/src/tests/server-fn/samples.ts | 22 +++--- packages/contracts/src/jobs.ts | 27 +++++++ packages/contracts/src/samples.ts | 71 +++++++------------ 25 files changed, 204 insertions(+), 166 deletions(-) diff --git a/apps/web/src/analyses/components/__tests__/AnalysisList.test.tsx b/apps/web/src/analyses/components/__tests__/AnalysisList.test.tsx index bd4611645..a13cce97b 100644 --- a/apps/web/src/analyses/components/__tests__/AnalysisList.test.tsx +++ b/apps/web/src/analyses/components/__tests__/AnalysisList.test.tsx @@ -54,7 +54,7 @@ describe("", () => { it("should show analysis creation when user is in the correct group and write is enabled", async () => { const account = createFakeAccount({ administrator_role: null }); sample.group = at(account.groups, 0); - sample.group_write = true; + sample.groupWrite = true; mockGetAccount(account); mockGetSampleDetail(sample); renderList(); @@ -64,7 +64,7 @@ describe("", () => { it("should show analysis creation when all users editing a sample is permitted", async () => { const account = createFakeAccount({ administrator_role: null }); - sample.all_write = true; + sample.allWrite = true; mockGetAccount(account); mockGetSampleDetail(sample); renderList(); @@ -73,8 +73,8 @@ describe("", () => { }); it("should not render analysis creation option when user has no permissions", async () => { - sample.all_write = false; - sample.group_write = false; + sample.allWrite = false; + sample.groupWrite = false; mockGetAccount(createFakeAccount({ administrator_role: null })); mockGetSampleDetail(sample); renderList(); diff --git a/apps/web/src/routes/_authenticated/samples/$sampleId.tsx b/apps/web/src/routes/_authenticated/samples/$sampleId.tsx index 3b74456ce..f908559d2 100644 --- a/apps/web/src/routes/_authenticated/samples/$sampleId.tsx +++ b/apps/web/src/routes/_authenticated/samples/$sampleId.tsx @@ -12,6 +12,7 @@ import DeleteSample from "@samples/components/Detail/DeleteSample"; import EditSample from "@samples/components/EditSample"; import { useCheckCanEditSample } from "@samples/hooks"; import { useSuspenseSample } from "@samples/queries"; +import { toServerJobNested } from "@samples/utils"; import { createFileRoute, notFound, @@ -54,8 +55,8 @@ function SampleDetailLayout() { const { hasPermission: canModify } = useCheckCanEditSample(numericSampleId); const [editOpen, setEditOpen] = useState(false); - const { created_at, name, user } = data; - const job = data.job && JobNestedSchema.parse(data.job); + const { createdAt, name, user } = data; + const job = data.job && JobNestedSchema.parse(toServerJobNested(data.job)); return ( <> @@ -81,7 +82,7 @@ function SampleDetailLayout() { )} - + diff --git a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx index f83f780f3..d266862c3 100644 --- a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx +++ b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx @@ -124,7 +124,7 @@ describe("", () => { expect(createSample).toHaveBeenCalledWith({ data: expect.objectContaining({ name: "Sample A", - library_type: "normal", + libraryType: "normal", files: [file.id], labels: [], subtractions: [], @@ -192,7 +192,7 @@ describe("", () => { isolate: "Clone AB", host: "Apple", locale: "Earth", - library_type: "normal", + libraryType: "normal", files: [firstFile.id, secondFile.id], labels: [firstLabel.id], subtractions: [subtractionShortlist.id], diff --git a/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx b/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx index 1ab206d10..368979747 100644 --- a/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx +++ b/apps/web/src/samples/components/Detail/SampleDetailGeneral.tsx @@ -8,7 +8,7 @@ import JobItem from "@jobs/components/JobItem"; import { useFetchJob } from "@jobs/queries"; import type { Label } from "@labels/types"; import { useSuspenseSample } from "@samples/queries"; -import { getLibraryTypeDisplayName } from "@samples/utils"; +import { getLibraryTypeDisplayName, toServerJobNested } from "@samples/utils"; /** * The general view in sample details */ @@ -34,7 +34,10 @@ export default function SampleDetailGeneral({ }: SampleDetailGeneralProps) { const sampleId = Number(routeApi.useParams().sampleId); const { data } = useSuspenseSample(sampleId); - const { data: job } = useFetchJob(data.job?.id ?? Number.NaN, data.job); + const { data: job } = useFetchJob( + data.job?.id ?? Number.NaN, + data.job && toServerJobNested(data.job), + ); const { quality } = data; @@ -97,7 +100,7 @@ export default function SampleDetailGeneral({ Library Type - {getLibraryTypeDisplayName(data.library_type)} + {getLibraryTypeDisplayName(data.libraryType)} Length Range diff --git a/apps/web/src/samples/components/Detail/SampleRights.tsx b/apps/web/src/samples/components/Detail/SampleRights.tsx index 772b88e6c..63fd6e35d 100644 --- a/apps/web/src/samples/components/Detail/SampleRights.tsx +++ b/apps/web/src/samples/components/Detail/SampleRights.tsx @@ -75,7 +75,7 @@ export default function SampleRights({ sampleId }: SampleRightsProps) { const canModifyRights = hasPermission || sample.user.id === account.id; - const { group, group_read, group_write, all_read, all_write } = sample; + const { group, groupRead, groupWrite, allRead, allWrite } = sample; function handleChangeGroup(value: string) { const group = value === "" ? null : parseInt(value, 10); @@ -95,8 +95,8 @@ export default function SampleRights({ sampleId }: SampleRightsProps) { mutation.mutate( { update: { - [`${scope}_read`]: value.includes("r"), - [`${scope}_write`]: value.includes("w"), + [`${scope}Read`]: value.includes("r"), + [`${scope}Write`]: value.includes("w"), }, }, { @@ -113,8 +113,8 @@ export default function SampleRights({ sampleId }: SampleRightsProps) { return Not allowed; } - const groupRights = (group_read ? "r" : "") + (group_write ? "w" : ""); - const allRights = (all_read ? "r" : "") + (all_write ? "w" : ""); + const groupRights = (groupRead ? "r" : "") + (groupWrite ? "w" : ""); + const allRights = (allRead ? "r" : "") + (allWrite ? "w" : ""); const selectedGroupId: string = group ? group.id.toString() : ""; diff --git a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx index d70867c3b..05aaeb7f6 100644 --- a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx @@ -19,10 +19,10 @@ describe("", () => { beforeEach(() => { sample = createFakeSample({ - all_read: false, - all_write: false, - group_read: false, - group_write: false, + allRead: false, + allWrite: false, + groupRead: false, + groupWrite: false, }); group = createFakeGroup(); mockGetSampleDetail(sample); @@ -73,7 +73,7 @@ describe("", () => { await waitFor(() => expect(updateSampleRights).toHaveBeenCalledWith({ - data: expect.objectContaining({ group_read: true, group_write: true }), + data: expect.objectContaining({ groupRead: true, groupWrite: true }), }), ); }); @@ -89,7 +89,7 @@ describe("", () => { await waitFor(() => expect(updateSampleRights).toHaveBeenCalledWith({ - data: expect.objectContaining({ all_read: true, all_write: true }), + data: expect.objectContaining({ allRead: true, allWrite: true }), }), ); }); diff --git a/apps/web/src/samples/components/Files/ReadItem.tsx b/apps/web/src/samples/components/Files/ReadItem.tsx index 3645d5ec8..841e55189 100644 --- a/apps/web/src/samples/components/Files/ReadItem.tsx +++ b/apps/web/src/samples/components/Files/ReadItem.tsx @@ -10,7 +10,7 @@ function sanitizeFileName(name: string): string { } type ReadItemProps = { - download_url: string; + downloadUrl: string; sampleName: string; side: number; /** The size of the read file in bytes */ @@ -21,7 +21,7 @@ type ReadItemProps = { * A condensed read item for use in a list of reads */ export default function ReadItem({ - download_url, + downloadUrl, sampleName, side, size, @@ -32,7 +32,7 @@ export default function ReadItem({
diff --git a/apps/web/src/samples/components/Files/SampleDetailFiles.tsx b/apps/web/src/samples/components/Files/SampleDetailFiles.tsx index 43cfaacfc..f6e5fac15 100644 --- a/apps/web/src/samples/components/Files/SampleDetailFiles.tsx +++ b/apps/web/src/samples/components/Files/SampleDetailFiles.tsx @@ -17,7 +17,7 @@ export default function SampleDetailFiles() { return ( - + ); diff --git a/apps/web/src/samples/components/Files/SampleReads.tsx b/apps/web/src/samples/components/Files/SampleReads.tsx index b4caaea68..326b64f8e 100644 --- a/apps/web/src/samples/components/Files/SampleReads.tsx +++ b/apps/web/src/samples/components/Files/SampleReads.tsx @@ -25,7 +25,7 @@ export default function SampleReads({ reads, sampleName }: SampleReadsProps) { const fileComponents = reads.map((file) => ( {sample.name} - +
{sample.ready && ( @@ -67,7 +71,7 @@ export default function SampleItem({ />
- + {sample.labels.map((label) => ( ))} diff --git a/apps/web/src/samples/components/SampleQuality.tsx b/apps/web/src/samples/components/SampleQuality.tsx index 47e623d13..c1d68e07b 100644 --- a/apps/web/src/samples/components/SampleQuality.tsx +++ b/apps/web/src/samples/components/SampleQuality.tsx @@ -14,7 +14,7 @@ export default function SampleQuality() { return (
- + {data.quality && ( ; } - const { found_count, items, page, page_count } = samples; + const { foundCount, items, page, pageCount } = samples; // An empty list means "nothing created yet" only when nothing is narrowing // it. Otherwise the samples exist and the filters are hiding them. @@ -242,13 +242,13 @@ export default function SamplesList({ setSearch({ page })} rowsClassName="pb-0 rounded-sm border-1 border-gray-300 overflow-hidden [&>*:not(:first-child)]:border-t-1 [&>*:not(:first-child)]:border-gray-300" > selection.toggleVisible(items)} diff --git a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx index 245831480..05f11e6b3 100644 --- a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx +++ b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx @@ -430,7 +430,7 @@ describe("", () => { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockGetSamples(samples, { found_count: 2, total_count: 17 }); + mockGetSamples(samples, { foundCount: 2, totalCount: 17 }); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); diff --git a/apps/web/src/samples/hooks.ts b/apps/web/src/samples/hooks.ts index ccaa9d763..541461aac 100644 --- a/apps/web/src/samples/hooks.ts +++ b/apps/web/src/samples/hooks.ts @@ -30,9 +30,9 @@ export function useCheckCanEditSample(sampleId: number) { const hasPermission = hasSufficientAdminRole("full", account.administrator_role) || - sample.all_write || + sample.allWrite || sample.user.id === account.id || - (sample.group_write && + (sample.groupWrite && account.groups.some((g) => g.id === sample.group?.id)); return { hasPermission, isPending: false }; diff --git a/apps/web/src/samples/queries.ts b/apps/web/src/samples/queries.ts index 1d14bcfe3..a00e4a65f 100644 --- a/apps/web/src/samples/queries.ts +++ b/apps/web/src/samples/queries.ts @@ -76,7 +76,7 @@ export function useListSamples( findSamplesFn({ data: { page, - per_page, + perPage: per_page, term: term ?? "", labels: labels ?? [], workflows: workflows ?? [], @@ -136,7 +136,7 @@ function createSample({ isolate, host, locale, - library_type: libraryType as LibraryType, + libraryType: libraryType as LibraryType, subtractions, files, labels, diff --git a/apps/web/src/samples/types.ts b/apps/web/src/samples/types.ts index 330321cbc..a4ff2ecb5 100644 --- a/apps/web/src/samples/types.ts +++ b/apps/web/src/samples/types.ts @@ -14,6 +14,7 @@ export type { Read, Sample, SampleArtifact, + SampleJobNested, SampleMinimal, SampleRightsUpdate, SampleSearchResult, diff --git a/apps/web/src/samples/utils.ts b/apps/web/src/samples/utils.ts index 679266351..b1d280c4d 100644 --- a/apps/web/src/samples/utils.ts +++ b/apps/web/src/samples/utils.ts @@ -1,7 +1,28 @@ -import type { JobNested } from "@jobs/types"; +import type { JobNested, ServerJobNested } from "@jobs/types"; import { stripMateToken } from "@uploads/pairing"; import type { Upload } from "@uploads/types"; -import type { CreateSampleRequest, LibraryType } from "./types"; +import type { + CreateSampleRequest, + LibraryType, + SampleJobNested, +} from "./types"; + +/** + * Adapt a sample's embedded job to the snake_case shape the jobs feature's + * schema parses. The samples wire shape is camelCase, but the jobs feature is + * still served from the Python API, so its `useFetchJob` seed and + * `JobNestedSchema` expect the snake_case job. + */ +export function toServerJobNested(job: SampleJobNested): ServerJobNested { + return { + created_at: job.createdAt, + id: job.id, + progress: job.progress, + state: job.state, + user: job.user, + workflow: job.workflow, + }; +} /** The workflows that samples can be filtered by. */ export const filterableWorkflows = ["pathoscope", "nuvs"]; diff --git a/apps/web/src/server/samples/data.test.ts b/apps/web/src/server/samples/data.test.ts index 284960490..07867a6c6 100644 --- a/apps/web/src/server/samples/data.test.ts +++ b/apps/web/src/server/samples/data.test.ts @@ -242,8 +242,8 @@ describe("findSamples", () => { const actor = await resolveSampleActor(db, ownerId); const result = await findSamples(db, options, actor); - expect(result.total_count).toBe(2); - expect(result.found_count).toBe(1); + expect(result.totalCount).toBe(2); + expect(result.foundCount).toBe(1); }); it("filters by a case-insensitive name substring", async () => { @@ -696,11 +696,11 @@ describe("updateSampleRights", () => { it("sets the rights flags", async () => { const sampleId = await seedSample({ name: "Rights" }); const sample = await updateSampleRights(db, sampleId, { - all_read: true, - group_write: true, + allRead: true, + groupWrite: true, }); - expect(sample.all_read).toBe(true); - expect(sample.group_write).toBe(true); + expect(sample.allRead).toBe(true); + expect(sample.groupWrite).toBe(true); }); it("resolves and assigns a group by id", async () => { @@ -726,7 +726,7 @@ describe("updateSampleRights", () => { it("throws when the sample does not exist", async () => { await expect( - updateSampleRights(db, 123456, { all_read: true }), + updateSampleRights(db, 123456, { allRead: true }), ).rejects.toBeInstanceOf(SampleNotFoundError); }); }); diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts index 2d04cbec4..399c00e55 100644 --- a/apps/web/src/server/samples/data.ts +++ b/apps/web/src/server/samples/data.ts @@ -1,5 +1,6 @@ import type { GroupMinimal, + JobState, LabelNested, LibraryType, Quality, @@ -7,8 +8,6 @@ import type { Sample, SampleArtifact, SampleJobNested, - SampleJobState, - SampleJobWorkflow, SampleMinimal, SampleRightsUpdate, SampleSearchResult, @@ -279,14 +278,15 @@ async function getSampleJobs( jobs.map((job) => [ job.id, { - created_at: job.created_at.toISOString(), + createdAt: job.created_at.toISOString(), id: job.id, progress: job.progress, // The mirror stores states and workflows as free text; the columns - // only ever hold the enumerated values. - state: job.state as SampleJobState, + // only ever hold the enumerated values, and a sample's job is always + // the `create_sample` job that built it. + state: job.state as JobState, user: job.user, - workflow: job.workflow as SampleJobWorkflow, + workflow: job.workflow as "create_sample", }, ]), ); @@ -327,7 +327,7 @@ async function getArtifacts( id: row.id, name: row.name, size: row.size ?? 0, - download_url: `/samples/${sampleId}/artifacts/${row.name_on_disk ?? ""}`, + downloadUrl: `/samples/${sampleId}/artifacts/${row.name_on_disk ?? ""}`, })); } @@ -354,10 +354,10 @@ async function getReads( .orderBy(asc(sampleReads.id)); return rows.map(({ read, upload, uploadUser }) => ({ - download_url: `/samples/${sampleId}/reads/${read.name}`, + downloadUrl: `/samples/${sampleId}/reads/${read.name}`, id: read.id, name: read.name, - name_on_disk: read.name_on_disk, + nameOnDisk: read.name_on_disk, sample: sampleId, size: read.size ?? 0, upload: @@ -367,10 +367,10 @@ async function getReads( id: upload.id, name: upload.name ?? "", size: upload.size, - uploaded_at: upload.uploadedAt?.toISOString() ?? null, + uploadedAt: upload.uploadedAt?.toISOString() ?? null, user: uploadUser?.id != null ? uploadUser : null, }, - uploaded_at: read.uploaded_at?.toISOString() ?? "", + uploadedAt: read.uploaded_at?.toISOString() ?? "", })); } @@ -395,13 +395,13 @@ function mapMinimal( usersById: Map, ): SampleMinimal { return { - created_at: row.created_at.toISOString(), + createdAt: row.created_at.toISOString(), host: row.host, id: row.id, isolate: row.isolate, job, labels: sampleLabels, - library_type: row.library_type as LibraryType, + libraryType: row.library_type as LibraryType, name: row.name, notes: row.notes, nuvs: tags.nuvs, @@ -660,11 +660,11 @@ export async function findSamples( ); return { - found_count: foundCount, - total_count: totalCount, + foundCount, + totalCount, page: options.page, - per_page: options.perPage, - page_count: foundCount ? Math.ceil(foundCount / options.perPage) : 0, + perPage: options.perPage, + pageCount: foundCount ? Math.ceil(foundCount / options.perPage) : 0, items: rows.map((row) => mapMinimal( row, @@ -724,15 +724,15 @@ export async function getSample(db: Db, sampleId: number): Promise { return { ...minimal, - all_read: row.all_read, - all_write: row.all_write, + allRead: row.all_read, + allWrite: row.all_write, artifacts, format: row.format, group, - group_read: row.group_read, - group_write: row.group_write, + groupRead: row.group_read, + groupWrite: row.group_write, hold: row.hold, - is_legacy: row.is_legacy, + isLegacy: row.is_legacy, locale: row.locale, paired: reads.length === 2, quality: (row.quality as Quality | null) ?? null, @@ -1105,17 +1105,17 @@ export async function updateSampleRights( > > = {}; - if (data.all_read !== undefined) { - scalars.all_read = data.all_read; + if (data.allRead !== undefined) { + scalars.all_read = data.allRead; } - if (data.all_write !== undefined) { - scalars.all_write = data.all_write; + if (data.allWrite !== undefined) { + scalars.all_write = data.allWrite; } - if (data.group_read !== undefined) { - scalars.group_read = data.group_read; + if (data.groupRead !== undefined) { + scalars.group_read = data.groupRead; } - if (data.group_write !== undefined) { - scalars.group_write = data.group_write; + if (data.groupWrite !== undefined) { + scalars.group_write = data.groupWrite; } if ("group" in data) { diff --git a/apps/web/src/server/samples/functions.test.ts b/apps/web/src/server/samples/functions.test.ts index 7ede490d6..33ccfce00 100644 --- a/apps/web/src/server/samples/functions.test.ts +++ b/apps/web/src/server/samples/functions.test.ts @@ -147,7 +147,7 @@ describe("updateSampleRights", () => { await signIn("full"); await expect( - call("updateSampleRights", { sampleId: 123456, all_read: true }), + call("updateSampleRights", { sampleId: 123456, allRead: true }), ).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(404); }); @@ -159,7 +159,7 @@ describe("updateSampleRights", () => { await signIn(null); await expect( - call("updateSampleRights", { sampleId, all_read: true }), + call("updateSampleRights", { sampleId, allRead: true }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); }); @@ -170,9 +170,9 @@ describe("updateSampleRights", () => { const sample = (await call("updateSampleRights", { sampleId, - all_read: true, - })) as { all_read: boolean }; - expect(sample.all_read).toBe(true); + allRead: true, + })) as { allRead: boolean }; + expect(sample.allRead).toBe(true); }); it("allows a full administrator who is not the owner", async () => { @@ -183,9 +183,9 @@ describe("updateSampleRights", () => { const sample = (await call("updateSampleRights", { sampleId, - all_write: true, - })) as { all_write: boolean }; - expect(sample.all_write).toBe(true); + allWrite: true, + })) as { allWrite: boolean }; + expect(sample.allWrite).toBe(true); }); }); diff --git a/apps/web/src/server/samples/functions.ts b/apps/web/src/server/samples/functions.ts index b919f12f7..ea9f56fca 100644 --- a/apps/web/src/server/samples/functions.ts +++ b/apps/web/src/server/samples/functions.ts @@ -34,7 +34,7 @@ const sampleIdSchema = z.object({ const findSamplesSchema = z.object({ page: pageSchema, - per_page: perPageSchema, + perPage: perPageSchema, term: z.string().default(""), labels: z.array(rowIdSchema).default([]), workflows: z.array(z.string()).default([]), @@ -53,7 +53,7 @@ const createSampleSchema = z.object({ isolate: z.string().trim().default(""), locale: z.string().trim().default(""), notes: z.string().default(""), - library_type: libraryTypeSchema.default("normal"), + libraryType: libraryTypeSchema.default("normal"), group: groupSchema.default(null), subtractions: z.array(rowIdSchema).default([]), labels: z.array(rowIdSchema).default([]), @@ -71,11 +71,11 @@ const updateSampleSchema = sampleIdSchema.extend({ }); const updateRightsSchema = sampleIdSchema.extend({ - all_read: z.boolean().optional(), - all_write: z.boolean().optional(), + allRead: z.boolean().optional(), + allWrite: z.boolean().optional(), group: groupSchema.optional(), - group_read: z.boolean().optional(), - group_write: z.boolean().optional(), + groupRead: z.boolean().optional(), + groupWrite: z.boolean().optional(), }); // Job states from which a sample may be deleted: the terminal states, where the @@ -152,7 +152,7 @@ export const findSamples = createServerFn({ method: "GET" }) db, { page: data.page, - perPage: data.per_page, + perPage: data.perPage, term: data.term, labels: data.labels, users: data.users, @@ -185,7 +185,7 @@ export const createSample = createServerFn({ method: "POST" }) isolate: data.isolate, locale: data.locale, notes: data.notes, - libraryType: data.library_type, + libraryType: data.libraryType, group: coerceGroup(data.group), subtractions: data.subtractions, labels: data.labels, diff --git a/apps/web/src/tests/fake/samples.ts b/apps/web/src/tests/fake/samples.ts index 44cab1afd..d28236948 100644 --- a/apps/web/src/tests/fake/samples.ts +++ b/apps/web/src/tests/fake/samples.ts @@ -1,6 +1,5 @@ import { faker } from "@faker-js/faker"; import type { Quality, Read, Sample, SampleMinimal } from "@samples/types"; -import { createFakeServerJobNested } from "./jobs"; import { createFakeLabelNested } from "./labels"; import { createFakeSubtractionNested } from "./subtractions"; import { createFakeUserNested } from "./user"; @@ -16,14 +15,19 @@ export function createFakeSampleMinimal( const defaultSampleMinimal: SampleMinimal = { id: faker.number.int(), name: `${faker.word.noun({ strategy: "any-length" })} ${faker.number.int()}`, - created_at: faker.date.past().toISOString(), + createdAt: faker.date.past().toISOString(), host: faker.word.noun({ strategy: "any-length" }), isolate: faker.word.noun({ strategy: "any-length" }), - job: createFakeServerJobNested({ + job: { + createdAt: faker.date.past().toISOString(), + id: faker.number.int(), + progress: 100, + state: "succeeded", + user: createFakeUserNested(), workflow: "create_sample", - }) as SampleMinimal["job"], + }, labels: [createFakeLabelNested()], - library_type: "normal", + libraryType: "normal", notes: faker.lorem.lines(5), nuvs: faker.datatype.boolean(), pathoscope: faker.datatype.boolean(), @@ -43,13 +47,13 @@ export function createFakeSampleMinimal( */ export function createFakeSampleRead(overrides?: Partial): Read { const defaultRead = { - download_url: faker.word.noun({ strategy: "any-length" }), + downloadUrl: faker.word.noun({ strategy: "any-length" }), id: faker.number.int(), name: faker.word.noun({ strategy: "any-length" }), - name_on_disk: faker.word.noun({ strategy: "any-length" }), + nameOnDisk: faker.word.noun({ strategy: "any-length" }), sample: faker.number.int(), size: faker.number.int(), - uploaded_at: faker.date.past().toISOString(), + uploadedAt: faker.date.past().toISOString(), }; return { ...defaultRead, ...overrides }; @@ -73,15 +77,15 @@ export function createFakeSampleQuality(): Quality { export function createFakeSample(overrides?: Partial): Sample { const defaultSample = { ...createFakeSampleMinimal(), - all_read: faker.datatype.boolean(), - all_write: faker.datatype.boolean(), + allRead: faker.datatype.boolean(), + allWrite: faker.datatype.boolean(), artifacts: [], format: "fastq", group: null, - group_read: faker.datatype.boolean(), - group_write: faker.datatype.boolean(), + groupRead: faker.datatype.boolean(), + groupWrite: faker.datatype.boolean(), hold: faker.datatype.boolean(), - is_legacy: faker.datatype.boolean(), + isLegacy: faker.datatype.boolean(), locale: faker.location.country(), paired: faker.datatype.boolean(), quality: createFakeSampleQuality(), diff --git a/apps/web/src/tests/server-fn/samples.ts b/apps/web/src/tests/server-fn/samples.ts index 03250d029..12fa1486f 100644 --- a/apps/web/src/tests/server-fn/samples.ts +++ b/apps/web/src/tests/server-fn/samples.ts @@ -21,19 +21,19 @@ export const sampleServerFnMocks = { * * @param samples - the samples on the page * @param counts - overrides for the counts, which otherwise both match the - * number of samples. `total_count` is every sample the user may see and - * `found_count` is only those matching the filters. + * number of samples. `totalCount` is every sample the user may see and + * `foundCount` is only those matching the filters. */ export function mockGetSamples( samples: SampleMinimal[], - counts: { found_count?: number; total_count?: number } = {}, + counts: { foundCount?: number; totalCount?: number } = {}, ): Mock { sampleServerFnMocks.findSamples.mockResolvedValue({ page: 1, - page_count: 1, - per_page: 5, - total_count: counts.total_count ?? samples.length, - found_count: counts.found_count ?? samples.length, + pageCount: 1, + perPage: 5, + totalCount: counts.totalCount ?? samples.length, + foundCount: counts.foundCount ?? samples.length, items: samples, }); return sampleServerFnMocks.findSamples; @@ -52,10 +52,10 @@ export function mockGetSamplePages(pages: SampleMinimal[][]): Mock { const page = data?.page ?? 1; return { page, - page_count: pages.length, - per_page: 1, - total_count: pages.length, - found_count: pages.length, + pageCount: pages.length, + perPage: 1, + totalCount: pages.length, + foundCount: pages.length, items: pages[page - 1] ?? [], }; }, diff --git a/packages/contracts/src/jobs.ts b/packages/contracts/src/jobs.ts index dd548f78e..0437832d1 100644 --- a/packages/contracts/src/jobs.ts +++ b/packages/contracts/src/jobs.ts @@ -1,6 +1,33 @@ import { z } from "zod"; +import type { UserNested } from "./users"; import { WorkflowName } from "./workflowName"; +/** A job's lifecycle state. Shared by every resource that embeds a job. */ +export type JobState = + | "cancelled" + | "failed" + | "pending" + | "running" + | "succeeded"; + +/** A workflow a job can run. */ +export type JobWorkflow = + | "build_index" + | "create_sample" + | "create_subtraction" + | "nuvs" + | "pathoscope"; + +/** A job embedded in another resource, e.g. a sample's creation job. */ +export type JobNested = { + createdAt: string; + id: number; + progress: number; + state: JobState; + user: UserNested; + workflow: JobWorkflow; +}; + /** Job row returned by the lifecycle endpoints. Provisional shape. */ export const Job = z.object({ id: z.int(), diff --git a/packages/contracts/src/samples.ts b/packages/contracts/src/samples.ts index e07ecb6dd..bf79495ed 100644 --- a/packages/contracts/src/samples.ts +++ b/packages/contracts/src/samples.ts @@ -1,5 +1,7 @@ import type { GroupMinimal } from "./groups"; +import type { JobNested } from "./jobs"; import type { LabelNested } from "./labels"; +import type { SearchResultV2 } from "./search"; import type { SubtractionNested } from "./subtractions"; import type { UserNested } from "./users"; @@ -18,35 +20,15 @@ export type SampleWorkflows = { pathoscope: WorkflowState; }; -/** A lifecycle state a sample's creation job can be in. */ -export type SampleJobState = - | "cancelled" - | "failed" - | "pending" - | "running" - | "succeeded"; - -/** A workflow a sample's creation job can run. */ -export type SampleJobWorkflow = - | "build_index" - | "create_sample" - | "create_subtraction" - | "nuvs" - | "pathoscope"; - -/** The creation job embedded in a sample. */ -export type SampleJobNested = { - created_at: string; - id: number; - progress: number; - state: SampleJobState; - user: UserNested; - workflow: SampleJobWorkflow; -}; +/** + * The creation job embedded in a sample. A job nested in a sample is always the + * `create_sample` job that built it. + */ +export type SampleJobNested = JobNested & { workflow: "create_sample" }; /** An artifact produced while creating a sample. */ export type SampleArtifact = { - download_url: string; + downloadUrl: string; id: number; name: string; size: number; @@ -57,20 +39,20 @@ export type SampleReadUpload = { id: number; name: string; size: number | null; - uploaded_at: string | null; + uploadedAt: string | null; user: UserNested | null; }; /** A reads file that makes up a sample. */ export type Read = { - download_url: string; + downloadUrl: string; id: number; name: string; - name_on_disk: string; + nameOnDisk: string; sample: number; size: number; upload?: SampleReadUpload | null; - uploaded_at: string; + uploadedAt: string; }; /** The FastQC quality charts associated with a sample. */ @@ -99,13 +81,13 @@ export type Quality = { /** A sample reduced to the fields shown in resource listings. */ export type SampleMinimal = { - created_at: string; + createdAt: string; host: string; id: number; isolate: string; job?: SampleJobNested; labels: LabelNested[]; - library_type: LibraryType; + libraryType: LibraryType; name: string; notes: string; nuvs: boolean | string; @@ -117,15 +99,15 @@ export type SampleMinimal = { /** A complete sample, as returned by the detail endpoint. */ export type Sample = SampleMinimal & { - all_read: boolean; - all_write: boolean; + allRead: boolean; + allWrite: boolean; artifacts: SampleArtifact[]; format: string; group: GroupMinimal | null; - group_read: boolean; - group_write: boolean; + groupRead: boolean; + groupWrite: boolean; hold: boolean; - is_legacy: boolean; + isLegacy: boolean; locale: string; paired: boolean; quality: Quality | null; @@ -134,20 +116,15 @@ export type Sample = SampleMinimal & { }; /** A page of samples with pagination metadata. */ -export type SampleSearchResult = { - found_count: number; +export type SampleSearchResult = SearchResultV2 & { items: SampleMinimal[]; - page: number; - page_count: number; - per_page: number; - total_count: number; }; /** The rights fields that can be changed on a sample. */ export type SampleRightsUpdate = { - all_read?: boolean; - all_write?: boolean; + allRead?: boolean; + allWrite?: boolean; group?: number | string | null; - group_read?: boolean; - group_write?: boolean; + groupRead?: boolean; + groupWrite?: boolean; }; From ffe9351a19fef3d4dc6b565b68178b1b59399374 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 12:41:08 -0700 Subject: [PATCH 05/11] refactor: resolve sample owner and group with joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSample and findSamples resolved the owner's handle in a second pass — a select over users keyed by the ids collected from the page — and getSample fetched the group with its own query. Both are one-to-one relationships, so join them onto the sample row instead, dropping getUsersByIds and getGroup. The collection relationships (labels, subtractions, reads, artifacts, and the workflow tags) stay as separate result sets. --- apps/web/src/server/samples/data.ts | 144 +++++++++++++--------------- 1 file changed, 64 insertions(+), 80 deletions(-) diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts index 399c00e55..e2d546191 100644 --- a/apps/web/src/server/samples/data.ts +++ b/apps/web/src/server/samples/data.ts @@ -1,5 +1,4 @@ import type { - GroupMinimal, JobState, LabelNested, LibraryType, @@ -248,24 +247,6 @@ async function getLabelsBySample( return bySample; } -async function getUsersByIds( - db: DbOrTx, - userIds: number[], -): Promise> { - const ids = [...new Set(userIds)]; - - if (ids.length === 0) { - return new Map(); - } - - const rows = await db - .select({ id: users.id, handle: users.handle }) - .from(users) - .where(inArray(users.id, ids)); - - return new Map(rows.map((row) => [row.id, row])); -} - // The sample's creation job, reduced to the embedded shape. Reuses the jobs // data layer so a sample's job never disagrees with the jobs endpoints. async function getSampleJobs( @@ -374,25 +355,12 @@ async function getReads( })); } -async function getGroup( - db: DbOrTx, - groupId: number, -): Promise { - const [row] = await db - .select({ id: groups.id, name: groups.name, legacy_id: groups.legacyId }) - .from(groups) - .where(eq(groups.id, groupId)) - .limit(1); - - return row ?? null; -} - function mapMinimal( row: LegacySampleRow, sampleLabels: LabelNested[], tags: WorkflowTags, job: SampleJobNested | undefined, - usersById: Map, + user: UserNested, ): SampleMinimal { return { createdAt: row.created_at.toISOString(), @@ -407,14 +375,24 @@ function mapMinimal( nuvs: tags.nuvs, pathoscope: tags.pathoscope, ready: row.ready, - user: - row.user_id != null - ? (usersById.get(row.user_id) ?? { id: row.user_id, handle: "" }) - : { id: 0, handle: "" }, + user, workflows: tags.workflows, }; } +// The sample's owner, resolved from the joined `users` row. `user_id` is nullable +// in the schema; an ownerless sample maps to the sentinel owner. +function resolveOwner( + userId: number | null, + handle: string | null, +): UserNested { + if (userId == null) { + return { id: 0, handle: "" }; + } + + return { id: userId, handle: handle ?? "" }; +} + /** Resolve a user id to the identity used for per-sample authorization. */ export async function resolveSampleActor( db: Db, @@ -628,9 +606,12 @@ export async function findSamples( // count visible to the caller. Match it exactly. db.select({ value: count() }).from(legacySamples), db.select({ value: count() }).from(legacySamples).where(where), + // The owner joins onto the sample row; the collection relationships fan + // out below, batched by the page's sample ids. db - .select() + .select({ sample: legacySamples, ownerHandle: users.handle }) .from(legacySamples) + .leftJoin(users, eq(users.id, legacySamples.user_id)) .where(where) .orderBy(desc(legacySamples.created_at), asc(legacySamples.id)) .offset((options.page - 1) * options.perPage) @@ -640,24 +621,20 @@ export async function findSamples( const totalCount = totalRows[0]?.value ?? 0; const foundCount = foundRows[0]?.value ?? 0; - const sampleIds = rows.map((row) => row.id); + const sampleIds = rows.map(({ sample }) => sample.id); const jobIds = [ ...new Set( - rows.map((row) => row.job_id).filter((id): id is number => id != null), + rows + .map(({ sample }) => sample.job_id) + .filter((id): id is number => id != null), ), ]; - const userIds = rows - .map((row) => row.user_id) - .filter((id): id is number => id != null); - - const [labelsBySample, tagsBySample, jobsById, usersById] = await Promise.all( - [ - getLabelsBySample(db, sampleIds), - getWorkflowTagsBySample(db, sampleIds), - getSampleJobs(db, jobIds), - getUsersByIds(db, userIds), - ], - ); + + const [labelsBySample, tagsBySample, jobsById] = await Promise.all([ + getLabelsBySample(db, sampleIds), + getWorkflowTagsBySample(db, sampleIds), + getSampleJobs(db, jobIds), + ]); return { foundCount, @@ -665,24 +642,33 @@ export async function findSamples( page: options.page, perPage: options.perPage, pageCount: foundCount ? Math.ceil(foundCount / options.perPage) : 0, - items: rows.map((row) => + items: rows.map(({ sample, ownerHandle }) => mapMinimal( - row, - labelsBySample.get(row.id) ?? [], - tagsBySample.get(row.id) ?? EMPTY_TAGS, - row.job_id != null - ? (jobsById.get(row.job_id) ?? undefined) + sample, + labelsBySample.get(sample.id) ?? [], + tagsBySample.get(sample.id) ?? EMPTY_TAGS, + sample.job_id != null + ? (jobsById.get(sample.job_id) ?? undefined) : undefined, - usersById, + resolveOwner(sample.user_id, ownerHandle), ), ), }; } export async function getSample(db: Db, sampleId: number): Promise { + // The owner and group are one-to-one, so they join onto the sample row; the + // collection relationships (labels, subtractions, reads, artifacts, analyses) + // are separate result sets that fan out below. const [row] = await db - .select() + .select({ + sample: legacySamples, + ownerHandle: users.handle, + group: { id: groups.id, name: groups.name, legacy_id: groups.legacyId }, + }) .from(legacySamples) + .leftJoin(users, eq(users.id, legacySamples.user_id)) + .leftJoin(groups, eq(groups.id, legacySamples.group_id)) .where(eq(legacySamples.id, sampleId)) .limit(1); @@ -690,52 +676,50 @@ export async function getSample(db: Db, sampleId: number): Promise { throw new SampleNotFoundError(); } - const jobIds = row.job_id != null ? [row.job_id] : []; - const userIds = row.user_id != null ? [row.user_id] : []; - const storageId = sampleStorageId(sampleId, row.legacy_id); + const { sample, ownerHandle, group: groupRow } = row; + const jobIds = sample.job_id != null ? [sample.job_id] : []; + const storageId = sampleStorageId(sampleId, sample.legacy_id); const [ labelsBySample, tagsBySample, jobsById, - usersById, sampleSubtractions, artifacts, reads, - group, ] = await Promise.all([ getLabelsBySample(db, [sampleId]), getWorkflowTagsBySample(db, [sampleId]), getSampleJobs(db, jobIds), - getUsersByIds(db, userIds), getSubtractionsBySample(db, sampleId), getArtifacts(db, sampleId, storageId), getReads(db, sampleId, storageId), - row.group_id != null ? getGroup(db, row.group_id) : Promise.resolve(null), ]); const minimal = mapMinimal( - row, + sample, labelsBySample.get(sampleId) ?? [], tagsBySample.get(sampleId) ?? EMPTY_TAGS, - row.job_id != null ? (jobsById.get(row.job_id) ?? undefined) : undefined, - usersById, + sample.job_id != null + ? (jobsById.get(sample.job_id) ?? undefined) + : undefined, + resolveOwner(sample.user_id, ownerHandle), ); return { ...minimal, - allRead: row.all_read, - allWrite: row.all_write, + allRead: sample.all_read, + allWrite: sample.all_write, artifacts, - format: row.format, - group, - groupRead: row.group_read, - groupWrite: row.group_write, - hold: row.hold, - isLegacy: row.is_legacy, - locale: row.locale, + format: sample.format, + group: groupRow, + groupRead: sample.group_read, + groupWrite: sample.group_write, + hold: sample.hold, + isLegacy: sample.is_legacy, + locale: sample.locale, paired: reads.length === 2, - quality: (row.quality as Quality | null) ?? null, + quality: (sample.quality as Quality | null) ?? null, reads, subtractions: sampleSubtractions, }; From 3ba72377a0feeb38b36c970e00b9cf11e3870d84 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 12:43:24 -0700 Subject: [PATCH 06/11] fix: surface an error when a sample has no owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user_id is nullable on legacy_samples, so a sample can exist with no creating user — an invalid state that getSample and getSampleOwnerId silently degraded to a blank, id-0 owner. Reading one now throws so the integrity violation surfaces instead of propagating a bogus owner. The sample list stays tolerant, so a single corrupt row cannot fail a whole page. --- apps/web/src/server/samples/data.test.ts | 8 +++++++ apps/web/src/server/samples/data.ts | 27 +++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/web/src/server/samples/data.test.ts b/apps/web/src/server/samples/data.test.ts index 07867a6c6..5e9e7fa77 100644 --- a/apps/web/src/server/samples/data.test.ts +++ b/apps/web/src/server/samples/data.test.ts @@ -37,6 +37,7 @@ import { SampleLabelsNotFoundError, SampleNameConflictError, SampleNotFoundError, + SampleOwnerlessError, sampleReadableFilter, updateSample, updateSampleRights, @@ -438,6 +439,13 @@ describe("getSample", () => { SampleNotFoundError, ); }); + + it("throws when the sample has no owner", async () => { + const sampleId = await seedSample({ user_id: null }); + await expect(getSample(db, sampleId)).rejects.toBeInstanceOf( + SampleOwnerlessError, + ); + }); }); describe("createSample", () => { diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts index e2d546191..a3983d55b 100644 --- a/apps/web/src/server/samples/data.ts +++ b/apps/web/src/server/samples/data.ts @@ -126,6 +126,13 @@ export class SampleGroupRequiredError extends AppError {} /** Thrown when the same upload is supplied more than once to create a sample. */ export class SampleFileDuplicateError extends AppError {} +/** + * Thrown when a sample has no owner. `user_id` is nullable in the schema, but a + * sample without a creating user is a data-integrity violation, not a routine + * outcome — reading one surfaces it rather than degrading to a blank owner. + */ +export class SampleOwnerlessError extends AppError {} + const WORKFLOW_NAMES = ["nuvs", "pathoscope"] as const; const WORKFLOW_CONDITIONS = ["none", "pending", "ready"] as const; @@ -677,6 +684,11 @@ export async function getSample(db: Db, sampleId: number): Promise { } const { sample, ownerHandle, group: groupRow } = row; + + if (sample.user_id == null) { + throw new SampleOwnerlessError(); + } + const jobIds = sample.job_id != null ? [sample.job_id] : []; const storageId = sampleStorageId(sampleId, sample.legacy_id); @@ -726,8 +738,9 @@ export async function getSample(db: Db, sampleId: number): Promise { } /** - * The owner user id of a sample, or `null` when the sample does not exist or has - * no owner. The rights-mutation gate treats both as "not found". + * The owner user id of a sample, or `null` when the sample does not exist — the + * rights-mutation gate treats that as "not found". A sample that exists but has + * no owner is an invalid state and throws {@link SampleOwnerlessError}. */ export async function getSampleOwnerId( db: Db, @@ -739,7 +752,15 @@ export async function getSampleOwnerId( .where(eq(legacySamples.id, sampleId)) .limit(1); - return row ? row.userId : null; + if (!row) { + return null; + } + + if (row.userId == null) { + throw new SampleOwnerlessError(); + } + + return row.userId; } /** From 7b871dd8697f21b24fbc4daedf5a0f04e7cb9ae2 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Thu, 23 Jul 2026 14:56:26 -0700 Subject: [PATCH 07/11] refactor: make sample request schemas authoritative in contracts --- apps/web/src/server/samples/data.ts | 29 ++++++------------- apps/web/src/server/samples/functions.ts | 28 ++---------------- packages/contracts/src/samples.ts | 36 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/apps/web/src/server/samples/data.ts b/apps/web/src/server/samples/data.ts index a3983d55b..248677f37 100644 --- a/apps/web/src/server/samples/data.ts +++ b/apps/web/src/server/samples/data.ts @@ -6,10 +6,12 @@ import type { Read, Sample, SampleArtifact, + SampleCreateRequest, SampleJobNested, SampleMinimal, SampleRightsUpdate, SampleSearchResult, + SampleUpdateRequest, SampleWorkflows, SubtractionNested, UserNested, @@ -79,31 +81,18 @@ export type FindSamplesOptions = { workflows: string[]; }; -/** The fields a sample is created from, plus the creating user. */ -export type CreateSampleValues = { - name: string; - host: string; - isolate: string; - locale: string; - notes: string; - libraryType: string; +/** + * The fields a sample is created from, plus the creating user. The handler + * coerces the request's `group` (id, legacy string, or null) to an id or null + * before it reaches here. + */ +export type CreateSampleValues = Omit & { group: number | null; - subtractions: number[]; - labels: number[]; - files: number[]; userId: number; }; /** The fields that can be changed when updating a sample. */ -export type UpdateSampleValues = { - name?: string; - host?: string; - isolate?: string; - locale?: string; - notes?: string; - labels?: number[]; - subtractions?: number[]; -}; +export type UpdateSampleValues = SampleUpdateRequest; /** Thrown when a requested sample does not exist. */ export class SampleNotFoundError extends AppError {} diff --git a/apps/web/src/server/samples/functions.ts b/apps/web/src/server/samples/functions.ts index ea9f56fca..c0c78042a 100644 --- a/apps/web/src/server/samples/functions.ts +++ b/apps/web/src/server/samples/functions.ts @@ -1,5 +1,6 @@ import { createServerFn, createServerOnlyFn } from "@tanstack/react-start"; import { setResponseStatus } from "@tanstack/react-start/server"; +import { SampleCreateRequest, SampleUpdateRequest } from "@virtool/contracts"; import { z } from "zod"; import { ForbiddenError } from "../auth/middleware"; import { authenticated, permission } from "../auth/policy"; @@ -41,34 +42,11 @@ const findSamplesSchema = z.object({ users: z.array(rowIdSchema).default([]), }); -const libraryTypeSchema = z.enum(["amplicon", "srna", "other", "normal"]); - // The group id (or legacy string), or null when none applies. `""` and `"none"` // mean "no group", matching the Python request validator. const groupSchema = z.union([z.number().int(), z.string(), z.null()]); -const createSampleSchema = z.object({ - name: z.string().trim().min(1), - host: z.string().trim().default(""), - isolate: z.string().trim().default(""), - locale: z.string().trim().default(""), - notes: z.string().default(""), - libraryType: libraryTypeSchema.default("normal"), - group: groupSchema.default(null), - subtractions: z.array(rowIdSchema).default([]), - labels: z.array(rowIdSchema).default([]), - files: z.array(rowIdSchema).min(1).max(2), -}); - -const updateSampleSchema = sampleIdSchema.extend({ - name: z.string().trim().min(1).optional(), - host: z.string().trim().optional(), - isolate: z.string().trim().optional(), - locale: z.string().trim().optional(), - notes: z.string().trim().optional(), - labels: z.array(rowIdSchema).optional(), - subtractions: z.array(rowIdSchema).optional(), -}); +const updateSampleSchema = sampleIdSchema.extend(SampleUpdateRequest.shape); const updateRightsSchema = sampleIdSchema.extend({ allRead: z.boolean().optional(), @@ -176,7 +154,7 @@ export const getSample = createServerFn({ method: "GET" }) export const createSample = createServerFn({ method: "POST" }) .middleware([permission("create_sample")]) - .validator(createSampleSchema) + .validator(SampleCreateRequest) .handler(async ({ context, data }) => { try { const sample = await createSampleImpl(db, { diff --git a/packages/contracts/src/samples.ts b/packages/contracts/src/samples.ts index bf79495ed..c0636506d 100644 --- a/packages/contracts/src/samples.ts +++ b/packages/contracts/src/samples.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { GroupMinimal } from "./groups"; import type { JobNested } from "./jobs"; import type { LabelNested } from "./labels"; @@ -128,3 +129,38 @@ export type SampleRightsUpdate = { groupRead?: boolean; groupWrite?: boolean; }; + +/** + * Fields accepted when creating a sample. A name is required; the rest default + * to empty. `group` is a group id, a legacy string, or null (`""` and `"none"` + * mean "no group"); `files` is one or two reads uploads. + */ +export const SampleCreateRequest = z.object({ + name: z.string().trim().min(1), + host: z.string().trim().default(""), + isolate: z.string().trim().default(""), + locale: z.string().trim().default(""), + notes: z.string().default(""), + libraryType: z + .enum(["amplicon", "srna", "other", "normal"]) + .default("normal"), + group: z.union([z.number().int(), z.string(), z.null()]).default(null), + subtractions: z.array(z.number().int().positive()).default([]), + labels: z.array(z.number().int().positive()).default([]), + files: z.array(z.number().int().positive()).min(1).max(2), +}); + +export type SampleCreateRequest = z.infer; + +/** Fields accepted when updating a sample. Only those present are changed. */ +export const SampleUpdateRequest = z.object({ + name: z.string().trim().min(1).optional(), + host: z.string().trim().optional(), + isolate: z.string().trim().optional(), + locale: z.string().trim().optional(), + notes: z.string().trim().optional(), + labels: z.array(z.number().int().positive()).optional(), + subtractions: z.array(z.number().int().positive()).optional(), +}); + +export type SampleUpdateRequest = z.infer; From a21fc957cbb0591eb3b5d693bbe46adc2a42222f Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Mon, 27 Jul 2026 09:12:52 -0700 Subject: [PATCH 08/11] refactor: import domain wire shapes from @virtool/contracts directly Drop the per-feature `types.ts` re-export shim for samples and references wire shapes; components now import Sample, Reference, and friends straight from @virtool/contracts instead of through a feature re-export. The shim only bought call-site stability during the earlier migration and cost a second place every contract type had to be listed. Also: - rename sample test mock helpers to match the server functions they stub (mockUpdateSample, mockDeleteSample, mockFindSamples, mockFindSamplePages, mockGetSample) - drop the dead `patchSample` wrapper and single-use `SampleID` type in samples/queries.ts and samples/types.ts - update docs/architecture.md and AGENTS.md to describe the new convention --- AGENTS.md | 18 +-- .../components/Create/QuickAnalyze.tsx | 2 +- .../components/Create/SelectedSamples.tsx | 2 +- .../__tests__/CreateAnalysisForm.test.tsx | 4 +- .../analyses/components/Nuvs/NuvsViewer.tsx | 2 +- .../components/Pathoscope/PathoscopeList.tsx | 2 +- .../Pathoscope/PathoscopeViewer.tsx | 2 +- .../__tests__/AnalysisList.test.tsx | 12 +- .../src/otus/components/CurrentOtuContext.tsx | 2 +- .../references/components/CloneReference.tsx | 2 +- .../components/Detail/AddReferenceGroup.tsx | 2 +- .../components/Detail/AddReferenceUser.tsx | 2 +- .../components/Detail/ArchiveReference.tsx | 2 +- .../components/Detail/EditMember.tsx | 4 +- .../components/Detail/EditReference.tsx | 2 +- .../components/Detail/LatestBuild.tsx | 2 +- .../Detail/ReferenceDetailHeader.tsx | 2 +- .../components/Detail/ReferenceMembers.tsx | 2 +- .../components/Detail/ReferenceRight.tsx | 2 +- .../references/components/ReferenceItem.tsx | 2 +- apps/web/src/references/hooks.ts | 2 +- apps/web/src/references/queries.ts | 6 +- apps/web/src/references/types.ts | 18 --- .../components/Create/CreateSample.tsx | 2 +- .../Detail/SampleFileSizeWarning.tsx | 2 +- .../Detail/__tests__/DeleteSample.test.tsx | 4 +- .../Detail/__tests__/SampleRights.test.tsx | 4 +- .../web/src/samples/components/EditSample.tsx | 2 +- .../samples/components/Files/SampleReads.tsx | 2 +- .../samples/components/Item/SampleItem.tsx | 2 +- .../Label/SampleLibraryTypeLabel.tsx | 2 +- .../components/SampleLabelsSelector.tsx | 2 +- .../samples/components/SampleListHeader.tsx | 2 +- .../src/samples/components/SamplesList.tsx | 2 +- .../samples/components/Tag/WorkflowTag.tsx | 2 +- .../samples/components/Tag/WorkflowTags.tsx | 2 +- .../components/__tests__/EditSample.test.tsx | 4 +- .../components/__tests__/SamplesList.test.tsx | 54 ++++----- apps/web/src/samples/labels.ts | 2 +- apps/web/src/samples/queries.ts | 108 ++++++++---------- apps/web/src/samples/types.ts | 32 +----- apps/web/src/samples/utils.ts | 7 +- apps/web/src/tests/fake/references.ts | 7 +- apps/web/src/tests/fake/samples.ts | 2 +- apps/web/src/tests/server-fn/references.ts | 2 +- apps/web/src/tests/server-fn/samples.ts | 12 +- docs/architecture.md | 21 ++-- 47 files changed, 158 insertions(+), 219 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab6eadd97..9fc7e2b2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -441,14 +441,16 @@ re-exports them so its own call sites are undisturbed. **A domain's wire shapes belong in `@virtool/contracts`, not in `data.ts`.** What a server function returns is read by both sides, so -`data.ts` imports those types from the package and the client feature's -`types.ts` re-exports them (`references/types.ts` is the worked example). -A client `types.ts` must never `import type ... from "@server/*"` — that -points the client at the server's emitted declarations for a shape the -server does not own, and drags a data-layer module into the browser's -type graph to get it. `data.ts` still owns what only it uses: its -`*Values` and `*Options` argument types, its `AppError` subclasses, and -its row mappers. +`data.ts` imports those types from the package and components import the +same names straight from `@virtool/contracts` — no feature `types.ts` +re-export (`references/types.ts` and `samples/types.ts` are the worked +examples; each keeps only its genuinely client-only shapes). A client +`types.ts` must never `import type ... from "@server/*"` — that points +the client at the server's emitted declarations for a shape the server +does not own, and drags a data-layer module into the browser's type +graph to get it. `data.ts` still owns what only it uses: its `*Values` +and `*Options` argument types, its `AppError` subclasses, and its row +mappers. ### Every server function declares an authorization policy diff --git a/apps/web/src/analyses/components/Create/QuickAnalyze.tsx b/apps/web/src/analyses/components/Create/QuickAnalyze.tsx index cad3f9839..0200c4b1f 100644 --- a/apps/web/src/analyses/components/Create/QuickAnalyze.tsx +++ b/apps/web/src/analyses/components/Create/QuickAnalyze.tsx @@ -1,7 +1,7 @@ import { Dialog, DialogTitle } from "@base/Dialog"; import QueryError from "@base/QueryError"; import { useListHmms } from "@hmm/queries"; -import type { SampleMinimal } from "@samples/types"; +import type { SampleMinimal } from "@virtool/contracts"; import HmmAlert from "../HmmAlert"; import CreateAnalysisDialogContent from "./CreateAnalysisDialogContent"; import CreateAnalysisForm from "./CreateAnalysisForm"; diff --git a/apps/web/src/analyses/components/Create/SelectedSamples.tsx b/apps/web/src/analyses/components/Create/SelectedSamples.tsx index 7dc32918b..3e77644c8 100644 --- a/apps/web/src/analyses/components/Create/SelectedSamples.tsx +++ b/apps/web/src/analyses/components/Create/SelectedSamples.tsx @@ -1,7 +1,7 @@ import { cn } from "@app/cn"; import Badge from "@base/Badge"; import BoxGroupSection from "@base/BoxGroupSection"; -import type { SampleMinimal } from "@samples/types"; +import type { SampleMinimal } from "@virtool/contracts"; import CreateAnalysisFieldTitle from "./CreateAnalysisFieldTitle"; type SelectedSamplesProps = { diff --git a/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx b/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx index cd083ce50..1be3255dd 100644 --- a/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx +++ b/apps/web/src/analyses/components/Create/__tests__/CreateAnalysisForm.test.tsx @@ -5,7 +5,7 @@ import { mockApiListIndexes } from "@tests/api/indexes"; import { createFakeAnalysisMinimal } from "@tests/fake/analyses"; import { createFakeIndexMinimal } from "@tests/fake/indexes"; import { createFakeSample } from "@tests/fake/samples"; -import { mockGetSampleDetail } from "@tests/server-fn/samples"; +import { mockGetSample } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { renderWithRouter } from "@tests/setup"; import { describe, expect, it, vi } from "vitest"; @@ -23,7 +23,7 @@ async function renderForm(indexId?: number) { mockApiListIndexes([index]); mockListSubtractionsShortlist([]); - mockGetSampleDetail(sample); + mockGetSample(sample); await renderWithRouter( ", () => { it("should show analysis creation when user is full admin", async () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); - mockGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -45,7 +45,7 @@ describe("", () => { const account = createFakeAccount({ administrator_role: null }); sample.user.id = account.id; mockGetAccount(account); - mockGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -56,7 +56,7 @@ describe("", () => { sample.group = at(account.groups, 0); sample.groupWrite = true; mockGetAccount(account); - mockGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -66,7 +66,7 @@ describe("", () => { const account = createFakeAccount({ administrator_role: null }); sample.allWrite = true; mockGetAccount(account); - mockGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Create")).toBeInTheDocument(); @@ -76,7 +76,7 @@ describe("", () => { sample.allWrite = false; sample.groupWrite = false; mockGetAccount(createFakeAccount({ administrator_role: null })); - mockGetSampleDetail(sample); + mockGetSample(sample); renderList(); expect(await screen.findByText("Pathoscope")).toBeInTheDocument(); diff --git a/apps/web/src/otus/components/CurrentOtuContext.tsx b/apps/web/src/otus/components/CurrentOtuContext.tsx index 65ee9203d..e06933668 100644 --- a/apps/web/src/otus/components/CurrentOtuContext.tsx +++ b/apps/web/src/otus/components/CurrentOtuContext.tsx @@ -3,7 +3,7 @@ import QueryError from "@base/QueryError"; import { useFetchOtu } from "@otus/queries"; import type { Otu } from "@otus/types"; import { useFetchReference } from "@references/queries"; -import type { Reference } from "@references/types"; +import type { Reference } from "@virtool/contracts"; import { createContext, type ReactNode, useContext } from "react"; type CurrentOtuContextValue = { diff --git a/apps/web/src/references/components/CloneReference.tsx b/apps/web/src/references/components/CloneReference.tsx index e92b445ce..59eee7931 100644 --- a/apps/web/src/references/components/CloneReference.tsx +++ b/apps/web/src/references/components/CloneReference.tsx @@ -8,7 +8,7 @@ import InputLabel from "@base/InputLabel"; import InputSimple from "@base/InputSimple"; import SaveButton from "@base/SaveButton"; import { useCloneReference } from "@references/queries"; -import type { ReferenceMinimal } from "@references/types"; +import type { ReferenceMinimal } from "@virtool/contracts"; import { useForm } from "react-hook-form"; type FormValues = { diff --git a/apps/web/src/references/components/Detail/AddReferenceGroup.tsx b/apps/web/src/references/components/Detail/AddReferenceGroup.tsx index 4e126201c..406e57689 100644 --- a/apps/web/src/references/components/Detail/AddReferenceGroup.tsx +++ b/apps/web/src/references/components/Detail/AddReferenceGroup.tsx @@ -10,7 +10,7 @@ import SearchToolbar from "@base/SearchToolbar"; import { useInfiniteFindGroups } from "@groups/queries"; import type { GroupMinimal } from "@groups/types"; import { useAddReferenceMember } from "@references/queries"; -import type { ReferenceGroup } from "@references/types"; +import type { ReferenceGroup } from "@virtool/contracts"; import { Users } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/references/components/Detail/AddReferenceUser.tsx b/apps/web/src/references/components/Detail/AddReferenceUser.tsx index 200223eac..3215984fc 100644 --- a/apps/web/src/references/components/Detail/AddReferenceUser.tsx +++ b/apps/web/src/references/components/Detail/AddReferenceUser.tsx @@ -8,9 +8,9 @@ import InitialIcon from "@base/InitialIcon"; import QueryError from "@base/QueryError"; import SearchToolbar from "@base/SearchToolbar"; import { useAddReferenceMember } from "@references/queries"; -import type { ReferenceUser } from "@references/types"; import { useInfiniteFindUsers } from "@users/queries"; import type { User } from "@users/types"; +import type { ReferenceUser } from "@virtool/contracts"; import { Users } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/references/components/Detail/ArchiveReference.tsx b/apps/web/src/references/components/Detail/ArchiveReference.tsx index b30aea8ee..543787854 100644 --- a/apps/web/src/references/components/Detail/ArchiveReference.tsx +++ b/apps/web/src/references/components/Detail/ArchiveReference.tsx @@ -11,7 +11,7 @@ import { useArchiveReference, useUnarchiveReference, } from "@references/queries"; -import type { Reference } from "@references/types"; +import type { Reference } from "@virtool/contracts"; import { AlertCircle, Archive, ArchiveRestore } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/references/components/Detail/EditMember.tsx b/apps/web/src/references/components/Detail/EditMember.tsx index 9b3ef0c0b..a2e7a0b7f 100644 --- a/apps/web/src/references/components/Detail/EditMember.tsx +++ b/apps/web/src/references/components/Detail/EditMember.tsx @@ -4,12 +4,12 @@ import { type ReferenceMemberNoun, useUpdateReferenceMember, } from "@references/queries"; +import { useQueryClient } from "@tanstack/react-query"; import type { ReferenceGroup, ReferenceRights, ReferenceUser, -} from "@references/types"; -import { useQueryClient } from "@tanstack/react-query"; +} from "@virtool/contracts"; import { ReferenceRight } from "./ReferenceRight"; const rights: (keyof ReferenceRights)[] = ["modifyOtu", "build", "modify"]; diff --git a/apps/web/src/references/components/Detail/EditReference.tsx b/apps/web/src/references/components/Detail/EditReference.tsx index b86d9443c..959021724 100644 --- a/apps/web/src/references/components/Detail/EditReference.tsx +++ b/apps/web/src/references/components/Detail/EditReference.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogFooter, DialogTitle } from "@base/Dialog"; import IconButton from "@base/IconButton"; import SaveButton from "@base/SaveButton"; import { useUpdateReference } from "@references/queries"; -import type { Reference } from "@references/types"; +import type { Reference } from "@virtool/contracts"; import { Pencil } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/web/src/references/components/Detail/LatestBuild.tsx b/apps/web/src/references/components/Detail/LatestBuild.tsx index f98d8c099..f9f81ef7e 100644 --- a/apps/web/src/references/components/Detail/LatestBuild.tsx +++ b/apps/web/src/references/components/Detail/LatestBuild.tsx @@ -2,7 +2,7 @@ import BoxGroupSection from "@base/BoxGroupSection"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "@base/Empty"; import Link from "@base/Link"; import RelativeTime from "@base/RelativeTime"; -import type { ReferenceBuild } from "@references/types"; +import type { ReferenceBuild } from "@virtool/contracts"; import { Boxes } from "lucide-react"; type LatestBuildProps = { diff --git a/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx b/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx index caf48e9e1..18913e43f 100644 --- a/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx +++ b/apps/web/src/references/components/Detail/ReferenceDetailHeader.tsx @@ -4,8 +4,8 @@ import ViewHeaderAttribution from "@base/ViewHeaderAttribution"; import ViewHeaderIcons from "@base/ViewHeaderIcons"; import ViewHeaderTitle from "@base/ViewHeaderTitle"; import { useCheckReferenceRight } from "@references/hooks"; -import type { Reference } from "@references/types"; import { useLocation } from "@tanstack/react-router"; +import type { Reference } from "@virtool/contracts"; import ArchiveReference from "./ArchiveReference"; import EditReference from "./EditReference"; diff --git a/apps/web/src/references/components/Detail/ReferenceMembers.tsx b/apps/web/src/references/components/Detail/ReferenceMembers.tsx index c62741472..289a28514 100644 --- a/apps/web/src/references/components/Detail/ReferenceMembers.tsx +++ b/apps/web/src/references/components/Detail/ReferenceMembers.tsx @@ -8,7 +8,7 @@ import { type ReferenceMemberNoun, useRemoveReferenceUser, } from "@references/queries"; -import type { ReferenceGroup, ReferenceUser } from "@references/types"; +import type { ReferenceGroup, ReferenceUser } from "@virtool/contracts"; import { Users } from "lucide-react"; import AddReferenceGroup from "./AddReferenceGroup"; import AddReferenceUser from "./AddReferenceUser"; diff --git a/apps/web/src/references/components/Detail/ReferenceRight.tsx b/apps/web/src/references/components/Detail/ReferenceRight.tsx index 6abcce04e..c3b30a6e1 100644 --- a/apps/web/src/references/components/Detail/ReferenceRight.tsx +++ b/apps/web/src/references/components/Detail/ReferenceRight.tsx @@ -1,5 +1,5 @@ import Checkbox from "@base/Checkbox"; -import type { ReferenceRights } from "@references/types"; +import type { ReferenceRights } from "@virtool/contracts"; const descriptions: Record = { build: "Can build new indexes for the reference.", diff --git a/apps/web/src/references/components/ReferenceItem.tsx b/apps/web/src/references/components/ReferenceItem.tsx index 0401412ad..cb5497e02 100644 --- a/apps/web/src/references/components/ReferenceItem.tsx +++ b/apps/web/src/references/components/ReferenceItem.tsx @@ -5,8 +5,8 @@ import BoxGroupSection from "@base/BoxGroupSection"; import IconButton from "@base/IconButton"; import Link from "@base/Link"; import ProgressCircle from "@base/ProgressCircle"; -import type { ReferenceMinimal } from "@references/types"; import { useFetchTask } from "@tasks/queries"; +import type { ReferenceMinimal } from "@virtool/contracts"; import { Copy } from "lucide-react"; import type { ReactNode } from "react"; diff --git a/apps/web/src/references/hooks.ts b/apps/web/src/references/hooks.ts index 35bebcb46..7fd0848ae 100644 --- a/apps/web/src/references/hooks.ts +++ b/apps/web/src/references/hooks.ts @@ -1,5 +1,5 @@ import { useFetchAccount } from "@account/account"; -import type { ReferenceRight } from "@references/types"; +import type { ReferenceRight } from "@virtool/contracts"; import { useFetchReference } from "./queries"; /** diff --git a/apps/web/src/references/queries.ts b/apps/web/src/references/queries.ts index d65bf5b1f..0ff37ebb4 100644 --- a/apps/web/src/references/queries.ts +++ b/apps/web/src/references/queries.ts @@ -24,8 +24,6 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import { postUpload } from "@uploads/uploader"; -import { useState } from "react"; -import type { ErrorResponse } from "@/types/api"; import type { Reference, ReferenceGroup, @@ -33,7 +31,9 @@ import type { ReferenceSearchResult, ReferenceUpdateRequest, ReferenceUser, -} from "./types"; +} from "@virtool/contracts"; +import { useState } from "react"; +import type { ErrorResponse } from "@/types/api"; /** A reference member is either a user or a group. */ export type ReferenceMemberNoun = "user" | "group"; diff --git a/apps/web/src/references/types.ts b/apps/web/src/references/types.ts index 549704854..eaf03d959 100644 --- a/apps/web/src/references/types.ts +++ b/apps/web/src/references/types.ts @@ -1,21 +1,3 @@ -/** - * The reference wire shapes live in `@virtool/contracts`, where the server data - * layer that produces them and the components that render them can both reach - * them. They are re-exported here so the feature's call sites import from one - * place. - */ -export type { - Reference, - ReferenceBuild, - ReferenceGroup, - ReferenceMinimal, - ReferenceRight, - ReferenceRights, - ReferenceSearchResult, - ReferenceUpdateRequest, - ReferenceUser, -} from "@virtool/contracts"; - /** * A reference reduced to the fields embedded in other resources (OTUs, indexes, * analyses, jobs). These still come from the Python API, so this shape keeps its diff --git a/apps/web/src/samples/components/Create/CreateSample.tsx b/apps/web/src/samples/components/Create/CreateSample.tsx index 7b46c6ee5..5c26e952b 100644 --- a/apps/web/src/samples/components/Create/CreateSample.tsx +++ b/apps/web/src/samples/components/Create/CreateSample.tsx @@ -29,10 +29,10 @@ import ViewHeaderTitle from "@base/ViewHeaderTitle"; import { useListGroups } from "@groups/queries"; import type { Label } from "@labels/types"; import { useCreateSample } from "@samples/queries"; -import type { Sample } from "@samples/types"; import { getCreateSampleRequest, getSampleNameFromReads } from "@samples/utils"; import { useNavigate } from "@tanstack/react-router"; import { useInfiniteFindFiles } from "@uploads/queries"; +import type { Sample } from "@virtool/contracts"; import { WandSparkles } from "lucide-react"; import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; diff --git a/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx b/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx index 19294ce91..a16821b35 100644 --- a/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx +++ b/apps/web/src/samples/components/Detail/SampleFileSizeWarning.tsx @@ -1,7 +1,7 @@ import Alert from "@base/Alert"; import Link from "@base/Link"; -import type { Read } from "@samples/types"; import { useLocation } from "@tanstack/react-router"; +import type { Read } from "@virtool/contracts"; import { AlertTriangle } from "lucide-react"; type SampleFileSizeWarningProps = { diff --git a/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx b/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx index f607ca23b..429977c89 100644 --- a/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/DeleteSample.test.tsx @@ -1,7 +1,7 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createFakeJobNested } from "@tests/fake/jobs"; -import { mockRemoveSample } from "@tests/server-fn/samples"; +import { mockDeleteSample } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it } from "vitest"; @@ -49,7 +49,7 @@ describe("", () => { }); it("should handle submit when confirm button is clicked", async () => { - const deleteSample = mockRemoveSample(); + const deleteSample = mockDeleteSample(); await renderWithRouter(); await userEvent.click(screen.getByRole("button")); diff --git a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx index 05aaeb7f6..6534b8311 100644 --- a/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx +++ b/apps/web/src/samples/components/Detail/__tests__/SampleRights.test.tsx @@ -6,7 +6,7 @@ import { createFakeGroup } from "@tests/fake/groups"; import { createFakeSample } from "@tests/fake/samples"; import { mockListGroups } from "@tests/server-fn/groups"; import { - mockGetSampleDetail, + mockGetSample, mockUpdateSampleRights, } from "@tests/server-fn/samples"; import { mockGetAccount } from "@tests/server-fn/users"; @@ -25,7 +25,7 @@ describe("", () => { groupWrite: false, }); group = createFakeGroup(); - mockGetSampleDetail(sample); + mockGetSample(sample); mockListGroups([group]); }); diff --git a/apps/web/src/samples/components/EditSample.tsx b/apps/web/src/samples/components/EditSample.tsx index 408c65a4d..8cc46bc84 100644 --- a/apps/web/src/samples/components/EditSample.tsx +++ b/apps/web/src/samples/components/EditSample.tsx @@ -5,10 +5,10 @@ import InputLabel from "@base/InputLabel"; import InputSimple from "@base/InputSimple"; import SaveButton from "@base/SaveButton"; import TextArea from "@base/TextArea"; +import type { Sample } from "@virtool/contracts"; import { pick } from "es-toolkit/object"; import { useForm } from "react-hook-form"; import { useUpdateSample } from "../queries"; -import type { Sample } from "../types"; type EditSampleProps = { open?: boolean; diff --git a/apps/web/src/samples/components/Files/SampleReads.tsx b/apps/web/src/samples/components/Files/SampleReads.tsx index 326b64f8e..f67791c6c 100644 --- a/apps/web/src/samples/components/Files/SampleReads.tsx +++ b/apps/web/src/samples/components/Files/SampleReads.tsx @@ -1,6 +1,6 @@ import BoxGroup from "@base/BoxGroup"; import BoxGroupHeader from "@base/BoxGroupHeader"; -import type { Read } from "@samples/types"; +import type { Read } from "@virtool/contracts"; import ReadItem from "./ReadItem"; type SampleReadsProps = { diff --git a/apps/web/src/samples/components/Item/SampleItem.tsx b/apps/web/src/samples/components/Item/SampleItem.tsx index 130563d7d..a922bcada 100644 --- a/apps/web/src/samples/components/Item/SampleItem.tsx +++ b/apps/web/src/samples/components/Item/SampleItem.tsx @@ -3,8 +3,8 @@ import Box from "@base/Box"; import Checkbox from "@base/Checkbox"; import Link from "@base/Link"; import { useFetchJob } from "@jobs/queries"; -import type { SampleMinimal } from "@samples/types"; import { toServerJobNested } from "@samples/utils"; +import type { SampleMinimal } from "@virtool/contracts"; import type { MouseEvent } from "react"; import SampleLabel from "../Label/SampleLabel"; import SampleLibraryTypeLabel from "../Label/SampleLibraryTypeLabel"; diff --git a/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx b/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx index 883f95644..9292c3dc7 100644 --- a/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx +++ b/apps/web/src/samples/components/Label/SampleLibraryTypeLabel.tsx @@ -1,6 +1,6 @@ import Icon from "@base/Icon"; -import type { LibraryType } from "@samples/types"; import { getLibraryTypeDisplayName } from "@samples/utils"; +import type { LibraryType } from "@virtool/contracts"; import { Dna } from "lucide-react"; import { BaseSampleLabel } from "./BaseSampleLabel"; diff --git a/apps/web/src/samples/components/SampleLabelsSelector.tsx b/apps/web/src/samples/components/SampleLabelsSelector.tsx index 0f73be68b..ca74fd291 100644 --- a/apps/web/src/samples/components/SampleLabelsSelector.tsx +++ b/apps/web/src/samples/components/SampleLabelsSelector.tsx @@ -9,7 +9,7 @@ import Input from "@base/Input"; import type { Label } from "@labels/types"; import { getHexColor, getSelectedLabels } from "@samples/labels"; import { useUpdateLabel } from "@samples/queries"; -import type { Sample, SampleMinimal } from "@samples/types"; +import type { Sample, SampleMinimal } from "@virtool/contracts"; import { Tag } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/samples/components/SampleListHeader.tsx b/apps/web/src/samples/components/SampleListHeader.tsx index 0749260a7..f55e8e75e 100644 --- a/apps/web/src/samples/components/SampleListHeader.tsx +++ b/apps/web/src/samples/components/SampleListHeader.tsx @@ -2,7 +2,7 @@ import Button from "@base/Button"; import Checkbox from "@base/Checkbox"; import Icon from "@base/Icon"; import type { Label } from "@labels/types"; -import type { Sample, SampleMinimal } from "@samples/types"; +import type { Sample, SampleMinimal } from "@virtool/contracts"; import { AreaChart } from "lucide-react"; import SampleLabelsSelector from "./SampleLabelsSelector"; diff --git a/apps/web/src/samples/components/SamplesList.tsx b/apps/web/src/samples/components/SamplesList.tsx index a04f6819f..8b8b599a7 100644 --- a/apps/web/src/samples/components/SamplesList.tsx +++ b/apps/web/src/samples/components/SamplesList.tsx @@ -10,7 +10,7 @@ import ViewHeader from "@base/ViewHeader"; import ViewHeaderTitle from "@base/ViewHeaderTitle"; import { useFetchLabels } from "@labels/queries"; import { useListSamples } from "@samples/queries"; -import type { Sample, SampleMinimal } from "@samples/types"; +import type { Sample, SampleMinimal } from "@virtool/contracts"; import { xor } from "es-toolkit/array"; import { FlaskConical, SearchX } from "lucide-react"; import { type MouseEvent, useState } from "react"; diff --git a/apps/web/src/samples/components/Tag/WorkflowTag.tsx b/apps/web/src/samples/components/Tag/WorkflowTag.tsx index 4adf2863e..57abb6493 100644 --- a/apps/web/src/samples/components/Tag/WorkflowTag.tsx +++ b/apps/web/src/samples/components/Tag/WorkflowTag.tsx @@ -1,5 +1,5 @@ import Icon from "@base/Icon"; -import type { WorkflowState } from "@samples/types"; +import type { WorkflowState } from "@virtool/contracts"; import { workflowStateIcons } from "../Filter/workflowStateIcons"; import { BaseWorkflowTag } from "./BaseWorkflowTag"; diff --git a/apps/web/src/samples/components/Tag/WorkflowTags.tsx b/apps/web/src/samples/components/Tag/WorkflowTags.tsx index 79c022e74..2aa9ad2aa 100644 --- a/apps/web/src/samples/components/Tag/WorkflowTags.tsx +++ b/apps/web/src/samples/components/Tag/WorkflowTags.tsx @@ -1,6 +1,6 @@ import { getWorkflowDisplayName } from "@app/utils"; import Link from "@base/Link"; -import type { SampleWorkflows } from "@samples/types"; +import type { SampleWorkflows } from "@virtool/contracts"; import { BaseWorkflowTag } from "./BaseWorkflowTag"; import WorkflowTag from "./WorkflowTag"; diff --git a/apps/web/src/samples/components/__tests__/EditSample.test.tsx b/apps/web/src/samples/components/__tests__/EditSample.test.tsx index f13da2b6a..5a65afb60 100644 --- a/apps/web/src/samples/components/__tests__/EditSample.test.tsx +++ b/apps/web/src/samples/components/__tests__/EditSample.test.tsx @@ -1,7 +1,7 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createFakeSample } from "@tests/fake/samples"; -import { mockEditSample } from "@tests/server-fn/samples"; +import { mockUpdateSample } from "@tests/server-fn/samples"; import { renderWithRouter } from "@tests/setup"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -60,7 +60,7 @@ describe("", () => { }); it("should update sample when form is submitted", async () => { - const updateSample = mockEditSample(sample, { + const updateSample = mockUpdateSample(sample, { name: "newName", isolate: "newIsolate", host: "newHost", diff --git a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx index 05f11e6b3..0c48005db 100644 --- a/apps/web/src/samples/components/__tests__/SamplesList.test.tsx +++ b/apps/web/src/samples/components/__tests__/SamplesList.test.tsx @@ -10,7 +10,7 @@ import { createFakeShortlistSubtraction } from "@tests/fake/subtractions"; import { createFakeUserNested } from "@tests/fake/user"; import { mockFindHmms } from "@tests/server-fn/hmm"; import { mockFindLabels } from "@tests/server-fn/labels"; -import { mockGetSamplePages, mockGetSamples } from "@tests/server-fn/samples"; +import { mockFindSamplePages, mockFindSamples } from "@tests/server-fn/samples"; import { mockListSubtractionsShortlist } from "@tests/server-fn/subtractions"; import { mockGetAccount, mockListUsers } from "@tests/server-fn/users"; import { at, renderWithRouter } from "@tests/setup"; @@ -58,20 +58,20 @@ function SamplesListHarness({ * @returns The sample on each page, in page order */ function mockApiGetSamplePages() { - const documents = [ + const samples = [ createFakeSampleMinimal({ name: "Page One Sample" }), createFakeSampleMinimal({ name: "Page Two Sample" }), ] as const; nock.cleanAll(); - mockGetSamplePages(documents.map((document) => [document])); + mockFindSamplePages(samples.map((sample) => [sample])); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - return documents; + return samples; } /** @@ -88,7 +88,7 @@ function mockApiGetSampleRange(names: string[]) { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockGetSamples(documents); + mockFindSamples(documents); return documents; } @@ -110,7 +110,7 @@ describe("", () => { ]; mockListUsers(users); mockFindLabels(labels); - mockGetSamples(samples); + mockFindSamples(samples); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); @@ -129,9 +129,9 @@ describe("", () => { }); it("should call onChange when search input changes in toolbar", async () => { - mockGetSamples(samples); - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -166,7 +166,7 @@ describe("", () => { describe("label filtering", () => { it("should show a chip for a label selected in the dropdown", async () => { - mockGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -187,8 +187,8 @@ describe("", () => { }); it("should remove every chip when the dropdown is cleared", async () => { - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -215,8 +215,8 @@ describe("", () => { it("should show a chip for the search term", async () => { // One interceptor per samples fetch: the initial empty-term render plus // the single refetch the debounced toolbar commits for "Foo". - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -231,7 +231,7 @@ describe("", () => { describe("workflow filtering", () => { it("should show a chip for a workflow state selected in the dropdown", async () => { - mockGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -252,8 +252,8 @@ describe("", () => { }); it("should keep the menu open and check both states toggled for one workflow", async () => { - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -277,8 +277,8 @@ describe("", () => { }); it("should remove every workflow chip when the dropdown is cleared", async () => { - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -304,7 +304,7 @@ describe("", () => { describe("user filtering", () => { it("should show a chip for a user selected in the dropdown", async () => { - mockGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -322,8 +322,8 @@ describe("", () => { }); it("should show a chip for each of several users selected at once", async () => { - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -384,8 +384,8 @@ describe("", () => { }); it("should remove every chip when the dropdown is cleared", async () => { - mockGetSamples(samples); - mockGetSamples(samples); + mockFindSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -430,7 +430,7 @@ describe("", () => { mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); - mockGetSamples(samples, { foundCount: 2, totalCount: 17 }); + mockFindSamples(samples, { foundCount: 2, totalCount: 17 }); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -537,7 +537,7 @@ describe("", () => { }); it("should clear the selection when a filter changes", async () => { - mockGetSamples(samples); + mockFindSamples(samples); await renderWithRouter(, path); expect(await screen.findByText("Samples")).toBeInTheDocument(); @@ -825,7 +825,7 @@ describe("", () => { // The default samples interceptor is already registered, and it would // answer the request before any empty one added here. nock.cleanAll(); - mockGetSamples([]); + mockFindSamples([]); mockFindHmms(createFakeHmmSearchResults()); mockApiListIndexes([createFakeIndexMinimal()]); mockListSubtractionsShortlist([createFakeShortlistSubtraction()]); diff --git a/apps/web/src/samples/labels.ts b/apps/web/src/samples/labels.ts index 5a5126efe..756bf8f33 100644 --- a/apps/web/src/samples/labels.ts +++ b/apps/web/src/samples/labels.ts @@ -1,5 +1,5 @@ import type { SampleLabel } from "@samples/queries"; -import type { SampleMinimal } from "@samples/types"; +import type { SampleMinimal } from "@virtool/contracts"; import { groupBy } from "es-toolkit"; /** diff --git a/apps/web/src/samples/queries.ts b/apps/web/src/samples/queries.ts index a00e4a65f..edfa32381 100644 --- a/apps/web/src/samples/queries.ts +++ b/apps/web/src/samples/queries.ts @@ -1,12 +1,12 @@ import type { LabelNested } from "@labels/types"; import { samplesQueryKeys } from "@samples/keys"; import { - createSample as createSampleFn, - deleteSample as deleteSampleFn, - findSamples as findSamplesFn, - getSample as getSampleFn, - updateSample as updateSampleFn, - updateSampleRights as updateSampleRightsFn, + createSample, + deleteSample, + findSamples, + getSample, + updateSample, + updateSampleRights, } from "@server/samples/functions"; import { keepPreviousData, @@ -17,16 +17,15 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import { fileQueryKeys } from "@uploads/keys"; -import { union } from "es-toolkit"; import type { - CreateSampleRequest, LibraryType, Sample, SampleMinimal, SampleRightsUpdate, SampleSearchResult, - SampleUpdate, -} from "./types"; +} from "@virtool/contracts"; +import { union } from "es-toolkit"; +import type { CreateSampleRequest, SampleUpdate } from "./types"; /** A label carried by at least one of the selected samples */ export type SampleLabel = LabelNested & { @@ -34,17 +33,6 @@ export type SampleLabel = LabelNested & { allLabeled: boolean; }; -/** - * Updates the data for a sample. - * - * Shared by the single-sample update hook and the bulk label-update hook. - */ -function updateSample(sampleId: number, update: SampleUpdate): Promise { - return updateSampleFn({ - data: { sampleId, ...update }, - }) as Promise; -} - /** * Fetch a page of samples from the API * @@ -73,7 +61,7 @@ export function useListSamples( users, ]), queryFn: () => - findSamplesFn({ + findSamples({ data: { page, perPage: per_page, @@ -90,7 +78,7 @@ export function useListSamples( export function sampleQueryOptions(sampleId: number) { return queryOptions({ queryKey: samplesQueryKeys.detail(sampleId), - queryFn: () => getSampleFn({ data: { sampleId } }) as Promise, + queryFn: () => getSample({ data: { sampleId } }) as Promise, }); } @@ -115,46 +103,38 @@ export function useSuspenseSample(sampleId: number) { } /** - * Creates a sample. + * Initialize a mutator for creating a sample * - * Shared by the single-sample create hook and the bulk create hook. + * @returns A mutator for creating a sample */ -function createSample({ - name, - isolate, - host, - locale, - libraryType, - subtractions, - files, - labels, - group, -}: CreateSampleRequest): Promise { - return createSampleFn({ - data: { +export function useCreateSample() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, isolate, host, locale, - libraryType: libraryType as LibraryType, + libraryType, subtractions, files, labels, group, - }, - }) as Promise; -} - -/** - * Initialize a mutator for creating a sample - * - * @returns A mutator for creating a sample - */ -export function useCreateSample() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: createSample, + }) => + createSample({ + data: { + name, + isolate, + host, + locale, + libraryType: libraryType as LibraryType, + subtractions, + files, + labels, + group, + }, + }) as Promise, onSuccess: () => { // The created sample reserves its read files, so the server stops // returning them. Only the reads selector shows them — an infinite @@ -175,7 +155,10 @@ export function useUpdateSample(sampleId: number) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ update }) => updateSample(sampleId, update), + mutationFn: ({ update }) => + updateSample({ + data: { sampleId, ...update }, + }) as Promise, onSuccess: () => { queryClient.invalidateQueries({ queryKey: samplesQueryKeys.detail(sampleId), @@ -192,7 +175,7 @@ export function useUpdateSample(sampleId: number) { export function useRemoveSample() { return useMutation({ mutationFn: ({ sampleId }) => - deleteSampleFn({ data: { sampleId } }) as Promise, + deleteSample({ data: { sampleId } }) as Promise, }); } @@ -204,7 +187,7 @@ export function useRemoveSample() { export function useUpdateSampleRights(sampleId: number) { return useMutation({ mutationFn: ({ update }) => - updateSampleRightsFn({ + updateSampleRights({ data: { sampleId, ...update }, }) as Promise, }); @@ -236,11 +219,14 @@ export function useUpdateLabel( selectedSamples.map((sample) => { const labelIds = sample.labels.map((label) => label.id); - return updateSample(sample.id, { - labels: allLabeled - ? labelIds.filter((id) => id !== labelId) - : union(labelIds, [labelId]), - }); + return updateSample({ + data: { + sampleId: sample.id, + labels: allLabeled + ? labelIds.filter((id) => id !== labelId) + : union(labelIds, [labelId]), + }, + }) as Promise; }), ); }, diff --git a/apps/web/src/samples/types.ts b/apps/web/src/samples/types.ts index a4ff2ecb5..df1c97415 100644 --- a/apps/web/src/samples/types.ts +++ b/apps/web/src/samples/types.ts @@ -1,34 +1,6 @@ -/** - * Sample Types - * - * @remark - * The sample wire shapes live in `@virtool/contracts`, where the server data - * layer that produces them and the components that render them can both reach - * them. They are re-exported here so the feature's call sites import from one - * place. Client-only request shapes stay owned here. - */ - -export type { - LibraryType, - Quality, - Read, - Sample, - SampleArtifact, - SampleJobNested, - SampleMinimal, - SampleRightsUpdate, - SampleSearchResult, - SampleWorkflows, - WorkflowState, -} from "@virtool/contracts"; - -/* A Sample ID */ -export type SampleID = { - id: number; -}; - /* A Sample with essential information */ -export type SampleNested = SampleID & { +export type SampleNested = { + id: number; name: string; }; diff --git a/apps/web/src/samples/utils.ts b/apps/web/src/samples/utils.ts index b1d280c4d..53c0076e2 100644 --- a/apps/web/src/samples/utils.ts +++ b/apps/web/src/samples/utils.ts @@ -1,11 +1,8 @@ import type { JobNested, ServerJobNested } from "@jobs/types"; import { stripMateToken } from "@uploads/pairing"; import type { Upload } from "@uploads/types"; -import type { - CreateSampleRequest, - LibraryType, - SampleJobNested, -} from "./types"; +import type { LibraryType, SampleJobNested } from "@virtool/contracts"; +import type { CreateSampleRequest } from "./types"; /** * Adapt a sample's embedded job to the snake_case shape the jobs feature's diff --git a/apps/web/src/tests/fake/references.ts b/apps/web/src/tests/fake/references.ts index 18e9c8e7a..82ad09afc 100644 --- a/apps/web/src/tests/fake/references.ts +++ b/apps/web/src/tests/fake/references.ts @@ -1,9 +1,6 @@ import { faker } from "@faker-js/faker"; -import type { - Reference, - ReferenceMinimal, - ReferenceNested, -} from "@references/types"; +import type { ReferenceNested } from "@references/types"; +import type { Reference, ReferenceMinimal } from "@virtool/contracts"; import { createFakeUserNested } from "./user"; /** diff --git a/apps/web/src/tests/fake/samples.ts b/apps/web/src/tests/fake/samples.ts index d28236948..237817653 100644 --- a/apps/web/src/tests/fake/samples.ts +++ b/apps/web/src/tests/fake/samples.ts @@ -1,5 +1,5 @@ import { faker } from "@faker-js/faker"; -import type { Quality, Read, Sample, SampleMinimal } from "@samples/types"; +import type { Quality, Read, Sample, SampleMinimal } from "@virtool/contracts"; import { createFakeLabelNested } from "./labels"; import { createFakeSubtractionNested } from "./subtractions"; import { createFakeUserNested } from "./user"; diff --git a/apps/web/src/tests/server-fn/references.ts b/apps/web/src/tests/server-fn/references.ts index a0c7c502e..3a1288675 100644 --- a/apps/web/src/tests/server-fn/references.ts +++ b/apps/web/src/tests/server-fn/references.ts @@ -1,4 +1,4 @@ -import type { Reference, ReferenceMinimal } from "@references/types"; +import type { Reference, ReferenceMinimal } from "@virtool/contracts"; import { type Mock, vi } from "vitest"; /** diff --git a/apps/web/src/tests/server-fn/samples.ts b/apps/web/src/tests/server-fn/samples.ts index 12fa1486f..e45ed29fc 100644 --- a/apps/web/src/tests/server-fn/samples.ts +++ b/apps/web/src/tests/server-fn/samples.ts @@ -1,4 +1,4 @@ -import type { Sample, SampleMinimal } from "@samples/types"; +import type { Sample, SampleMinimal } from "@virtool/contracts"; import { type Mock, vi } from "vitest"; import { createFakeSample } from "../fake/samples"; @@ -24,7 +24,7 @@ export const sampleServerFnMocks = { * number of samples. `totalCount` is every sample the user may see and * `foundCount` is only those matching the filters. */ -export function mockGetSamples( +export function mockFindSamples( samples: SampleMinimal[], counts: { foundCount?: number; totalCount?: number } = {}, ): Mock { @@ -46,7 +46,7 @@ export function mockGetSamples( * * @param pages - the samples on each page, in page order */ -export function mockGetSamplePages(pages: SampleMinimal[][]): Mock { +export function mockFindSamplePages(pages: SampleMinimal[][]): Mock { sampleServerFnMocks.findSamples.mockImplementation( async ({ data }: { data?: { page?: number } }) => { const page = data?.page ?? 1; @@ -64,7 +64,7 @@ export function mockGetSamplePages(pages: SampleMinimal[][]): Mock { } /** Sets up getSample to resolve with the given sample. */ -export function mockGetSampleDetail(sample: Sample): Mock { +export function mockGetSample(sample: Sample): Mock { sampleServerFnMocks.getSample.mockResolvedValue(sample); return sampleServerFnMocks.getSample; } @@ -78,7 +78,7 @@ export function mockCreateSample(sample?: Sample): Mock { } /** Sets up updateSample to resolve with the given sample, patched with the fields. */ -export function mockEditSample( +export function mockUpdateSample( sample: Sample, update: Partial = {}, ): Mock { @@ -87,7 +87,7 @@ export function mockEditSample( } /** Sets up deleteSample to resolve. */ -export function mockRemoveSample(): Mock { +export function mockDeleteSample(): Mock { sampleServerFnMocks.deleteSample.mockResolvedValue(null); return sampleServerFnMocks.deleteSample; } diff --git a/docs/architecture.md b/docs/architecture.md index 61c69b578..67277b598 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,18 +157,21 @@ written by `data.ts` and rendered by components, so neither owns it: `@virtool/contracts` does. `references.ts` there defines `Reference`, `ReferenceMinimal`, the rights and membership shapes, and the `ReferenceSearchResult` page; `server/references/data.ts` imports them -to annotate its returns, and `src/references/types.ts` re-exports them -so components import from `@references/types` as before. - -The failure mode this replaces is a client `types.ts` that does +to annotate its returns, and components import the same names straight +from `@virtool/contracts`. Don't add a feature `types.ts` re-export for +these — unlike `@server/*` below, importing `@virtool/contracts` +directly crosses no project boundary, so the re-export bought nothing +but a second place a type must be kept listed. `references/types.ts` +and `samples/types.ts` hold only genuinely client-only shapes +(`ReferenceNested`, `CreateSampleRequest`, `SampleUpdate`, and friends). + +The failure mode to avoid is a client `types.ts` that does `import type { Reference } from "@server/references/data"`. It type-checks — the arrow client → `@server/*` is legal — but it makes the browser's view of a shape depend on a *data-layer* module's emitted -declarations, so the shape is only nameable if that module's whole -inferred surface stays portable, and a `data.ts` refactor becomes a -client type break. The `.d.ts` remap also has to succeed for the client -to see a plain object type. Both problems disappear when the type has a -home neither side reaches through the other. +declarations, so a `data.ts` refactor becomes a client type break. That +problem disappears when the type has a home neither side reaches +through the other — `@virtool/contracts`, imported directly. Two shapes generalised out of that move and now live in the package alongside the reference contracts: `UserNested` (`{ id, handle }`, the From 76945a89698a6d2af8ea627ed7657b357a6f3bee Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Mon, 27 Jul 2026 09:33:35 -0700 Subject: [PATCH 09/11] docs: clarify the no-layer-suffix naming rule's escape valve The one-line summary said not to suffix exports with their layer but never mentioned how to resolve the collision that forces the question in the first place, which is the root cause `auth/functions.ts` drifted into `loginFn`-style exports instead of aliasing the colliding import. --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9fc7e2b2d..30f2e2263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,8 @@ The basics: - **JSDoc:** Every exported `type` gets a one-line `/** ... */`. - **Naming:** `is`/`has`/`get` for pure reads; `check`/`validate`/ `assert` for may-throw. Don't suffix exports with their layer - (`Fn`, `Core`, `Handler`, `Impl`). + (`Fn`, `Core`, `Handler`, `Impl`) — when two same-named functions + meet in one file, alias the import instead (`login as loginImpl`). - **Comments:** Default to none. Document *why* when non-obvious, not *what*. - **Concurrency:** Independent awaits go in `Promise.all` — don't pay From 4a84b4a3351f437cfdcbdf46d39ba2fda81990a3 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Mon, 27 Jul 2026 09:37:33 -0700 Subject: [PATCH 10/11] =?UTF-8?q?docs:=20fix=20naming=20rule=20=E2=80=94?= =?UTF-8?q?=20server=20functions=20get=20the=20Fn=20suffix,=20not=20the=20?= =?UTF-8?q?domain=20fn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit had this backwards. loginFn established the actual convention first: a createServerFn export is an RPC call, not a plain function, so it keeps the Fn suffix to mark that at every call site. The plain name belongs to the domain function it wraps, which never crosses the network. getSample, getGroup, and the ~60 other exported server functions across src/server/*/functions.ts are the drift from this, not loginFn. --- AGENTS.md | 10 ++++++---- docs/code-style.md | 34 +++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30f2e2263..01160bada 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -458,7 +458,7 @@ Every server function names who may call it, as middleware, from `@server/auth/policy`: ```ts -export const deleteGroup = createServerFn({ method: "POST" }) +export const deleteGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(groupIdSchema) .handler(async ({ context, data }) => { ... }); @@ -634,9 +634,11 @@ The basics: string literal unions over `enum`. - **JSDoc:** Every exported `type` gets a one-line `/** ... */`. - **Naming:** `is`/`has`/`get` for pure reads; `check`/`validate`/ - `assert` for may-throw. Don't suffix exports with their layer - (`Fn`, `Core`, `Handler`, `Impl`) — when two same-named functions - meet in one file, alias the import instead (`login as loginImpl`). + `assert` for may-throw. A `createServerFn` export gets an `Fn` suffix + (`loginFn`, `getSampleFn`) — it's an RPC call, not a plain function, + and the suffix marks that at every call site. The domain function it + wraps keeps the plain name (`login`, `getSample`) and never crosses + the network. - **Comments:** Default to none. Document *why* when non-obvious, not *what*. - **Concurrency:** Independent awaits go in `Promise.all` — don't pay diff --git a/docs/code-style.md b/docs/code-style.md index 66d21c8e2..2d465d37e 100644 --- a/docs/code-style.md +++ b/docs/code-style.md @@ -82,25 +82,29 @@ is not an `is` or `has`. Prepositional names like `lifetimeFor` or `dataFor` are not in the rule — prefer `getLifetime` / `getData`. -## Naming: do not suffix functions with their layer or mechanism - -Exported function names should describe the domain action or returned -value, not the file, framework, or implementation layer that contains -them. Avoid suffixes like `Fn`, `Core`, `Handler`, or `Impl` in -exported names. Let the module path carry the layer: - -- `server/auth/core.ts` exports pure domain helpers (`login`, `logout`, - `getAuthState`). +## Naming: a server function gets an `Fn` suffix; the domain function it wraps does not + +A `createServerFn`-wrapped export is not a plain function call — every +call site crosses the network, goes through validation and auth +middleware, and returns whatever the RPC layer's plumbing returns +(`loginFn` isn't `login`'s return type, it's a callable RPC handle with +its own `.url`, for instance). That's a real behavioral difference from +the domain function underneath it, worth naming, not hiding: + +- `server/auth/core.ts` exports the pure domain helpers (`login`, + `logout`, `getAuthState`) — no suffix, since these never cross the + RPC boundary. - `server/auth/functions.ts` exports the TanStack Start server - functions that wrap them. + functions that wrap them, suffixed `Fn` (`loginFn`, `logoutFn`) — + every call site, client or test, sees at a glance that the call is a + server round-trip rather than a local one. - React Query hooks living next to the feature's `api.ts` wrap those server calls as `useLoginMutation`, `useAuth`, etc. -When two imported functions with the same domain name meet in one -file, use a local-only alias such as `login as loginImpl` at the -import site. The alias is allowed because it is wiring glue, not -exported API. Framework option names that the library dictates (e.g. -React Query's `queryFn` and `mutationFn`) keep their upstream names. +Because the wrapper and the domain function it wraps now have +different names, no import aliasing is needed for the common case. Only +alias an import (`as` at the import site) for an unrelated, incidental +name collision that isn't this domain-function/server-function pair. Import these model constants by their exported names. Do not alias them with `as` to avoid collisions; instead name other imports From 8e423a224abf1add564d0c73762dc722c23d4259 Mon Sep 17 00:00:00 2001 From: Ian Boyes Date: Mon, 27 Jul 2026 10:08:44 -0700 Subject: [PATCH 11/11] refactor: give every server function export an Fn suffix Every createServerFn-wrapped export now ends in Fn (getUser -> getUserFn, createSample -> createSampleFn, etc.) across all 15 src/server/*/functions.ts files that hadn't yet adopted the convention auth/functions.ts and getPasswordPolicyFn already followed. The suffix marks an RPC call at every call site; the domain function each one wraps keeps its plain name. Updates every call site (queries.ts modules, route guards, jobs/refresh.ts), every tests/server-fn/*.ts mock object and its consumers, and the functions.test.ts call()/callServerFn() string literals and authorization.test.ts's hardcoded exception list. --- apps/web/src/account/account.ts | 4 +- .../__tests__/AccountProfile.test.tsx | 2 +- apps/web/src/account/queries.ts | 26 +++---- .../components/__tests__/Banners.test.tsx | 14 ++-- apps/web/src/administration/queries.ts | 10 +-- apps/web/src/banner/queries.ts | 28 ++++---- apps/web/src/groups/queries.ts | 25 +++---- apps/web/src/hmm/queries.ts | 8 +-- apps/web/src/jobs/__tests__/refresh.test.ts | 26 +++---- apps/web/src/jobs/queries.ts | 6 +- apps/web/src/jobs/refresh.ts | 8 +-- apps/web/src/labels/queries.ts | 16 ++--- apps/web/src/nav/queries.ts | 6 +- .../__tests__/ArchiveReference.test.tsx | 10 +-- .../__tests__/ReferenceList.test.tsx | 2 +- apps/web/src/references/queries.ts | 58 +++++++-------- .../Create/__tests__/CreateSample.test.tsx | 2 +- .../__tests__/SampleLabelsSelector.test.tsx | 4 +- apps/web/src/samples/queries.ts | 26 +++---- .../server/__tests__/authorization.test.ts | 2 +- apps/web/src/server/account/functions.test.ts | 20 +++--- apps/web/src/server/account/functions.ts | 8 +-- apps/web/src/server/auth/exceptions.ts | 6 +- apps/web/src/server/auth/middleware.test.ts | 6 +- apps/web/src/server/groups/functions.test.ts | 16 ++--- apps/web/src/server/groups/functions.ts | 12 ++-- apps/web/src/server/hmm/functions.test.ts | 10 +-- apps/web/src/server/hmm/functions.ts | 6 +- apps/web/src/server/jobs/functions.ts | 6 +- apps/web/src/server/labels/functions.ts | 10 +-- apps/web/src/server/messages/functions.ts | 14 ++-- .../src/server/references/functions.test.ts | 40 +++++------ apps/web/src/server/references/functions.ts | 24 +++---- apps/web/src/server/root/functions.test.ts | 4 +- apps/web/src/server/root/functions.ts | 2 +- apps/web/src/server/samples/functions.test.ts | 18 ++--- apps/web/src/server/samples/functions.ts | 12 ++-- .../web/src/server/settings/functions.test.ts | 18 ++--- apps/web/src/server/settings/functions.ts | 4 +- .../src/server/subtraction/functions.test.ts | 12 ++-- apps/web/src/server/subtraction/functions.ts | 12 ++-- apps/web/src/server/tasks/functions.ts | 2 +- apps/web/src/server/uploads/functions.test.ts | 10 +-- apps/web/src/server/uploads/functions.ts | 4 +- apps/web/src/server/users/functions.ts | 22 +++--- apps/web/src/subtraction/queries.ts | 25 +++---- apps/web/src/tasks/queries.ts | 4 +- apps/web/src/tests/server-fn/account.ts | 16 ++--- apps/web/src/tests/server-fn/groups.ts | 20 +++--- apps/web/src/tests/server-fn/hmm.ts | 18 ++--- apps/web/src/tests/server-fn/jobs.ts | 18 ++--- apps/web/src/tests/server-fn/labels.ts | 14 ++-- apps/web/src/tests/server-fn/references.ts | 44 ++++++------ apps/web/src/tests/server-fn/root.ts | 6 +- apps/web/src/tests/server-fn/samples.ts | 43 +++++------ apps/web/src/tests/server-fn/settings.ts | 20 +++--- apps/web/src/tests/server-fn/subtractions.ts | 36 +++++----- apps/web/src/tests/server-fn/uploads.ts | 8 +-- apps/web/src/tests/server-fn/users.ts | 72 ++++++++++--------- apps/web/src/tests/setup.tsx | 56 +++++++-------- .../components/__tests__/FileManager.test.tsx | 8 +-- .../components/__tests__/UploadItem.test.tsx | 4 +- apps/web/src/uploads/queries.ts | 11 +-- .../components/__tests__/UserGroups.test.tsx | 14 ++-- apps/web/src/users/queries.ts | 34 ++++----- docs/auth.md | 8 +-- docs/queries.md | 2 +- docs/server-push.md | 14 ++-- docs/testing.md | 4 +- 69 files changed, 546 insertions(+), 534 deletions(-) diff --git a/apps/web/src/account/account.ts b/apps/web/src/account/account.ts index f46bab0b6..8a25cce40 100644 --- a/apps/web/src/account/account.ts +++ b/apps/web/src/account/account.ts @@ -1,6 +1,6 @@ import { accountQueryKeys } from "@account/keys"; import type { Account } from "@account/types"; -import { getAccount } from "@server/users/functions"; +import { getAccountFn } from "@server/users/functions"; import { queryOptions, useQuery } from "@tanstack/react-query"; /** @@ -21,7 +21,7 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; export function accountQueryOptions() { return queryOptions({ queryKey: accountQueryKeys.all(), - queryFn: () => getAccount(), + queryFn: () => getAccountFn(), }); } diff --git a/apps/web/src/account/components/__tests__/AccountProfile.test.tsx b/apps/web/src/account/components/__tests__/AccountProfile.test.tsx index 1ef1fd610..cc5591e41 100644 --- a/apps/web/src/account/components/__tests__/AccountProfile.test.tsx +++ b/apps/web/src/account/components/__tests__/AccountProfile.test.tsx @@ -170,7 +170,7 @@ describe("", () => { expect( await screen.findByText("Please specify a username"), ).toBeInTheDocument(); - expect(userServerFnMocks.updateAccountHandle).not.toHaveBeenCalled(); + expect(userServerFnMocks.updateAccountHandleFn).not.toHaveBeenCalled(); }); it("should handle password changes", async () => { diff --git a/apps/web/src/account/queries.ts b/apps/web/src/account/queries.ts index 585b8d8ff..f7ab09208 100644 --- a/apps/web/src/account/queries.ts +++ b/apps/web/src/account/queries.ts @@ -5,13 +5,13 @@ import { resetClient } from "@app/utils"; import type { Permissions } from "@groups/types"; import * as Sentry from "@sentry/tanstackstart-react"; import { - createApiKey, - deleteApiKey, - findApiKeys, - updateApiKey, + createApiKeyFn, + deleteApiKeyFn, + findApiKeysFn, + updateApiKeyFn, } from "@server/account/functions"; import { logoutFn } from "@server/auth/functions"; -import { updateAccountHandle } from "@server/users/functions"; +import { updateAccountHandleFn } from "@server/users/functions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { User } from "@users/types"; import type { ErrorResponse } from "@/types/api"; @@ -50,11 +50,11 @@ export function useUpdateHandle() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { handle: string } >({ - mutationFn: ({ handle }) => updateAccountHandle({ data: { handle } }), + mutationFn: ({ handle }) => updateAccountHandleFn({ data: { handle } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.all() }); }, @@ -93,7 +93,7 @@ export function useChangePassword() { export function useFetchApiKeys() { return useQuery({ queryKey: accountQueryKeys.apiKeys(), - queryFn: () => findApiKeys(), + queryFn: () => findApiKeysFn(), }); } @@ -106,12 +106,12 @@ export function useCreateApiKey() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { name: string; permissions: Permissions } >({ mutationFn: ({ name, permissions }) => - createApiKey({ data: { name, permissions } }), + createApiKeyFn({ data: { name, permissions } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.apiKeys() }); }, @@ -127,12 +127,12 @@ export function useUpdateApiKey() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { keyId: number; permissions: Permissions } >({ mutationFn: ({ keyId, permissions }) => - updateApiKey({ data: { keyId, permissions } }), + updateApiKeyFn({ data: { keyId, permissions } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.apiKeys() }); }, @@ -148,7 +148,7 @@ export function useRemoveApiKey() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ keyId }) => deleteApiKey({ data: { keyId } }), + mutationFn: ({ keyId }) => deleteApiKeyFn({ data: { keyId } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountQueryKeys.apiKeys() }); }, diff --git a/apps/web/src/administration/components/__tests__/Banners.test.tsx b/apps/web/src/administration/components/__tests__/Banners.test.tsx index 75967227c..7104e58f6 100644 --- a/apps/web/src/administration/components/__tests__/Banners.test.tsx +++ b/apps/web/src/administration/components/__tests__/Banners.test.tsx @@ -12,13 +12,13 @@ const updateMessage = vi.fn(); const deleteMessage = vi.fn(); vi.mock("@server/messages/functions", () => ({ - findMessage: vi.fn(), - findMessages: (...args: unknown[]) => findMessages(...args), - setActiveMessage: (...args: unknown[]) => setActiveMessage(...args), - clearActiveMessage: (...args: unknown[]) => clearActiveMessage(...args), - createMessage: (...args: unknown[]) => createMessage(...args), - updateMessage: (...args: unknown[]) => updateMessage(...args), - deleteMessage: (...args: unknown[]) => deleteMessage(...args), + findMessageFn: vi.fn(), + findMessagesFn: (...args: unknown[]) => findMessages(...args), + setActiveMessageFn: (...args: unknown[]) => setActiveMessage(...args), + clearActiveMessageFn: (...args: unknown[]) => clearActiveMessage(...args), + createMessageFn: (...args: unknown[]) => createMessage(...args), + updateMessageFn: (...args: unknown[]) => updateMessage(...args), + deleteMessageFn: (...args: unknown[]) => deleteMessage(...args), })); const { default: Banners } = await import("../Banners"); diff --git a/apps/web/src/administration/queries.ts b/apps/web/src/administration/queries.ts index d4058cab1..546af0e25 100644 --- a/apps/web/src/administration/queries.ts +++ b/apps/web/src/administration/queries.ts @@ -3,8 +3,8 @@ import { roleQueryKeys, settingsQueryKeys, } from "@administration/keys"; -import { getSettings, updateSettings } from "@server/settings/functions"; -import { listAdministratorRoles } from "@server/users/functions"; +import { getSettingsFn, updateSettingsFn } from "@server/settings/functions"; +import { listAdministratorRolesFn } from "@server/users/functions"; import { queryOptions, useMutation, @@ -34,7 +34,7 @@ export type SettingsUpdate = { export function settingsQueryOptions() { return queryOptions({ queryKey: settingsQueryKeys.all(), - queryFn: () => getSettings(), + queryFn: () => getSettingsFn(), }); } @@ -67,7 +67,7 @@ export function useUpdateSettings() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (update) => updateSettings({ data: update }), + mutationFn: (update) => updateSettingsFn({ data: update }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: settingsQueryKeys.all(), @@ -87,7 +87,7 @@ export function useUpdateSettings() { export function administratorRolesQueryOptions() { return queryOptions({ queryKey: roleQueryKeys.all(), - queryFn: () => listAdministratorRoles(), + queryFn: () => listAdministratorRolesFn(), }); } diff --git a/apps/web/src/banner/queries.ts b/apps/web/src/banner/queries.ts index b75f3228f..8f545ddad 100644 --- a/apps/web/src/banner/queries.ts +++ b/apps/web/src/banner/queries.ts @@ -1,12 +1,12 @@ import { bannerQueryKeys } from "@banner/keys"; import { - clearActiveMessage, - createMessage, - deleteMessage, - findMessage, - findMessages, - setActiveMessage, - updateMessage, + clearActiveMessageFn, + createMessageFn, + deleteMessageFn, + findMessageFn, + findMessagesFn, + setActiveMessageFn, + updateMessageFn, } from "@server/messages/functions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Banner, BannerColor } from "./types"; @@ -17,7 +17,7 @@ import type { Banner, BannerColor } from "./types"; export function useFetchBanner() { return useQuery({ queryKey: bannerQueryKeys.active(), - queryFn: () => findMessage(), + queryFn: () => findMessageFn(), }); } @@ -27,7 +27,7 @@ export function useFetchBanner() { export function useFetchBanners() { return useQuery({ queryKey: bannerQueryKeys.lists(), - queryFn: () => findMessages(), + queryFn: () => findMessagesFn(), }); } @@ -38,7 +38,7 @@ export function useCreateBanner() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ message, color }) => - createMessage({ data: { message, color } }), + createMessageFn({ data: { message, color } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bannerQueryKeys.all() }); }, @@ -56,7 +56,7 @@ export function useUpdateBanner() { { id: number; message?: string; color?: BannerColor } >({ mutationFn: async ({ id, message, color }) => { - const banner = await updateMessage({ data: { id, message, color } }); + const banner = await updateMessageFn({ data: { id, message, color } }); if (!banner) { throw new Error("Failed to update banner"); } @@ -75,7 +75,7 @@ export function useDeleteBanner() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id }) => { - await deleteMessage({ data: { id } }); + await deleteMessageFn({ data: { id } }); return null; }, onSuccess: () => { @@ -92,7 +92,7 @@ export function useSetActiveBanner() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id }) => { - const banner = await setActiveMessage({ data: { id } }); + const banner = await setActiveMessageFn({ data: { id } }); if (!banner) { throw new Error("Failed to activate banner"); } @@ -110,7 +110,7 @@ export function useSetActiveBanner() { export function useClearActiveBanner() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => clearActiveMessage(), + mutationFn: () => clearActiveMessageFn(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bannerQueryKeys.all() }); }, diff --git a/apps/web/src/groups/queries.ts b/apps/web/src/groups/queries.ts index ae31d7924..cb92ee00d 100644 --- a/apps/web/src/groups/queries.ts +++ b/apps/web/src/groups/queries.ts @@ -1,11 +1,11 @@ import { groupQueryKeys } from "@groups/keys"; import { - createGroup, - deleteGroup, - findGroups, - getGroup, - listGroups, - updateGroup, + createGroupFn, + deleteGroupFn, + findGroupsFn, + getGroupFn, + listGroupsFn, + updateGroupFn, } from "@server/groups/functions"; import { keepPreviousData, @@ -33,7 +33,7 @@ export function useInfiniteFindGroups(per_page: number, term: string) { return useInfiniteQuery({ queryKey: groupQueryKeys.infiniteList([per_page, term]), queryFn: ({ pageParam }) => - findGroups({ + findGroupsFn({ data: { term, page: pageParam as number, per_page }, }) as Promise, initialPageParam: 1, @@ -55,7 +55,7 @@ export function useInfiniteFindGroups(per_page: number, term: string) { export function useListGroups() { return useQuery({ queryKey: groupQueryKeys.lists(), - queryFn: () => listGroups() as Promise, + queryFn: () => listGroupsFn() as Promise, }); } @@ -69,7 +69,7 @@ export function useFetchGroup(id: string | number) { return useQuery({ queryKey: groupQueryKeys.detail(id), queryFn: () => - getGroup({ data: { groupId: Number(id) } }) as Promise, + getGroupFn({ data: { groupId: Number(id) } }) as Promise, enabled: Boolean(id), placeholderData: keepPreviousData, }); @@ -92,7 +92,7 @@ export function useUpdateGroup() { } >({ mutationFn: ({ id, name, permissions }) => - updateGroup({ + updateGroupFn({ data: { groupId: Number(id), name, permissions }, }) as Promise, onSuccess: (data) => { @@ -111,7 +111,7 @@ export function useRemoveGroup() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id }) => - deleteGroup({ data: { groupId: Number(id) } }) as Promise, + deleteGroupFn({ data: { groupId: Number(id) } }) as Promise, onSuccess: () => { queryClient.invalidateQueries({ queryKey: groupQueryKeys.all() }); }, @@ -126,7 +126,8 @@ export function useRemoveGroup() { export function useCreateGroup() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ name }) => createGroup({ data: { name } }) as Promise, + mutationFn: ({ name }) => + createGroupFn({ data: { name } }) as Promise, onSuccess: () => { queryClient.invalidateQueries({ queryKey: groupQueryKeys.lists() }); }, diff --git a/apps/web/src/hmm/queries.ts b/apps/web/src/hmm/queries.ts index 7e3501565..8edf20fd1 100644 --- a/apps/web/src/hmm/queries.ts +++ b/apps/web/src/hmm/queries.ts @@ -1,5 +1,5 @@ import { hmmQueryKeys } from "@hmm/keys"; -import { findHmms, getHmm, installHmm } from "@server/hmm/functions"; +import { findHmmsFn, getHmmFn, installHmmFn } from "@server/hmm/functions"; import { queryOptions, useMutation, @@ -25,7 +25,7 @@ export function hmmsQueryOptions( return queryOptions({ queryKey: hmmQueryKeys.list([page, per_page, term]), queryFn: () => - findHmms({ data: { page, perPage: per_page, term: term ?? "" } }).then( + findHmmsFn({ data: { page, perPage: per_page, term: term ?? "" } }).then( ({ documents, ...rest }) => ({ ...rest, items: documents }), ), }); @@ -68,7 +68,7 @@ export function useSuspenseHmms(page: number, per_page: number, term?: string) { export function useFetchHmm(hmmId: number) { return useQuery({ queryKey: hmmQueryKeys.detail(hmmId), - queryFn: () => getHmm({ data: { hmmId } }), + queryFn: () => getHmmFn({ data: { hmmId } }), }); } @@ -81,7 +81,7 @@ export function useInstallHmm() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => installHmm(), + mutationFn: () => installHmmFn(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: hmmQueryKeys.lists() }); }, diff --git a/apps/web/src/jobs/__tests__/refresh.test.ts b/apps/web/src/jobs/__tests__/refresh.test.ts index df8b59f3d..382167b5a 100644 --- a/apps/web/src/jobs/__tests__/refresh.test.ts +++ b/apps/web/src/jobs/__tests__/refresh.test.ts @@ -89,7 +89,7 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledExactlyOnceWith({ + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledExactlyOnceWith({ data: { jobIds: [1, 2, 3] }, }); }); @@ -126,7 +126,7 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).not.toHaveBeenCalled(); + expect(jobServerFnMocks.getJobsFn).not.toHaveBeenCalled(); }); // React Query holds a detail's data for the whole gcTime after its row @@ -141,7 +141,7 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).not.toHaveBeenCalled(); + expect(jobServerFnMocks.getJobsFn).not.toHaveBeenCalled(); }); it("marks a cached jobs list stale so its counts and ordering catch up", async () => { @@ -168,16 +168,16 @@ describe("createJobRefreshQueue", () => { await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(2); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(2); expect( - jobServerFnMocks.getJobs.mock.calls.map( + jobServerFnMocks.getJobsFn.mock.calls.map( ([{ data }]) => data.jobIds.length, ), ).toEqual([100, 50]); }); it("falls back to refetching each job when the batch fails", async () => { - jobServerFnMocks.getJobs.mockRejectedValue(new Error("boom")); + jobServerFnMocks.getJobsFn.mockRejectedValue(new Error("boom")); watch(1, 2); const queue = createJobRefreshQueue(queryClient); @@ -198,7 +198,7 @@ describe("createJobRefreshQueue", () => { watch(1); let resolveFirst: (jobs: ServerJob[]) => void = () => undefined; - jobServerFnMocks.getJobs + jobServerFnMocks.getJobsFn .mockImplementationOnce( () => new Promise((resolve) => { @@ -211,17 +211,17 @@ describe("createJobRefreshQueue", () => { queue(1); await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(1); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(1); queue(1); await vi.advanceTimersByTimeAsync(FLUSH_MS * 3); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(1); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(1); resolveFirst([createJob(1, { progress: 10 })]); await vi.advanceTimersByTimeAsync(0); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(2); + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(2); expect(queryClient.getQueryData(jobQueryKeys.detail(1))).toMatchObject({ progress: 90, }); @@ -239,8 +239,8 @@ describe("createJobRefreshQueue", () => { queue(2); await vi.advanceTimersByTimeAsync(FLUSH_MS); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledTimes(2); - expect(jobServerFnMocks.getJobs).toHaveBeenNthCalledWith(2, { + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledTimes(2); + expect(jobServerFnMocks.getJobsFn).toHaveBeenNthCalledWith(2, { data: { jobIds: [2] }, }); }); @@ -256,7 +256,7 @@ describe("createJobRefreshQueue", () => { queue(2); await vi.advanceTimersByTimeAsync(FLUSH_MS / 2); - expect(jobServerFnMocks.getJobs).toHaveBeenCalledExactlyOnceWith({ + expect(jobServerFnMocks.getJobsFn).toHaveBeenCalledExactlyOnceWith({ data: { jobIds: [1, 2] }, }); }); diff --git a/apps/web/src/jobs/queries.ts b/apps/web/src/jobs/queries.ts index 78bbd9f2c..7fc70cdb9 100644 --- a/apps/web/src/jobs/queries.ts +++ b/apps/web/src/jobs/queries.ts @@ -1,5 +1,5 @@ import { jobQueryKeys } from "@jobs/keys"; -import { findJobs, getJob } from "@server/jobs/functions"; +import { findJobsFn, getJobFn } from "@server/jobs/functions"; import { queryOptions, useQuery, @@ -27,7 +27,7 @@ export function jobsQueryOptions( ) { return queryOptions({ queryKey: jobQueryKeys.list([page, per_page, ...states]), - queryFn: () => findJobs({ data: { page, perPage: per_page, states } }), + queryFn: () => findJobsFn({ data: { page, perPage: per_page, states } }), select: JobSearchResultSchema.parse, }); } @@ -82,7 +82,7 @@ function getJobSeed(job: ServerJobNested): ServerJob { export function useFetchJob(jobId: number, seed?: ServerJobNested) { return useQuery({ queryKey: jobQueryKeys.detail(jobId), - queryFn: () => getJob({ data: { jobId } }), + queryFn: () => getJobFn({ data: { jobId } }), select: JobSchema.parse, enabled: Number.isInteger(jobId), initialData: seed ? getJobSeed(seed) : undefined, diff --git a/apps/web/src/jobs/refresh.ts b/apps/web/src/jobs/refresh.ts index b86cb6bec..5b634d4b8 100644 --- a/apps/web/src/jobs/refresh.ts +++ b/apps/web/src/jobs/refresh.ts @@ -1,6 +1,6 @@ import { jobQueryKeys } from "@jobs/keys"; import * as Sentry from "@sentry/tanstackstart-react"; -import { getJobs } from "@server/jobs/functions"; +import { getJobsFn } from "@server/jobs/functions"; import type { QueryClient } from "@tanstack/react-query"; /** @@ -13,7 +13,7 @@ import type { QueryClient } from "@tanstack/react-query"; */ const FLUSH_MS = 500; -/** Ids per request, matching the cap `getJobs` validates. */ +/** Ids per request, matching the cap `getJobsFn` validates. */ const BATCH_SIZE = 100; function chunk(ids: number[], size: number): number[][] { @@ -31,7 +31,7 @@ function chunk(ids: number[], size: number): number[][] { * * Every on-screen job mounts its own `detail(id)` query, so invalidating each * frame's detail fires one request per running job per progress wave. The queue - * collects the ids instead and reads them in a single `getJobs` call, writing + * collects the ids instead and reads them in a single `getJobsFn` call, writing * each result straight into its detail cache. * * The queue holds its own buffer, so callers get one per `QueryClient`. @@ -45,7 +45,7 @@ export function createJobRefreshQueue( async function refreshBatch(jobIds: number[]): Promise { try { - const jobs = await getJobs({ data: { jobIds } }); + const jobs = await getJobsFn({ data: { jobIds } }); for (const job of jobs) { queryClient.setQueryData(jobQueryKeys.detail(job.id), job); diff --git a/apps/web/src/labels/queries.ts b/apps/web/src/labels/queries.ts index 7cd1d6108..4ee58151c 100644 --- a/apps/web/src/labels/queries.ts +++ b/apps/web/src/labels/queries.ts @@ -1,9 +1,9 @@ import { labelQueryKeys } from "@labels/keys"; import { - createLabel, - deleteLabel, - findLabels, - updateLabel, + createLabelFn, + deleteLabelFn, + findLabelsFn, + updateLabelFn, } from "@server/labels/functions"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Label } from "./types"; @@ -16,7 +16,7 @@ import type { Label } from "./types"; export function useFetchLabels() { return useQuery({ queryKey: labelQueryKeys.lists(), - queryFn: () => findLabels(), + queryFn: () => findLabelsFn(), }); } @@ -34,7 +34,7 @@ export function useCreateLabel() { { name: string; description: string; color: string } >({ mutationFn: ({ name, description, color }) => - createLabel({ data: { color, description, name } }), + createLabelFn({ data: { color, description, name } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: labelQueryKeys.lists() }); }, @@ -55,7 +55,7 @@ export function useUpdateLabel() { { labelId: number; name: string; description: string; color: string } >({ mutationFn: ({ labelId, name, description, color }) => - updateLabel({ data: { color, description, labelId, name } }), + updateLabelFn({ data: { color, description, labelId, name } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: labelQueryKeys.lists() }); }, @@ -71,7 +71,7 @@ export function useRemoveLabel() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ labelId }) => deleteLabel({ data: { labelId } }), + mutationFn: ({ labelId }) => deleteLabelFn({ data: { labelId } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: labelQueryKeys.lists() }); }, diff --git a/apps/web/src/nav/queries.ts b/apps/web/src/nav/queries.ts index 100f77c29..ecd4c8244 100644 --- a/apps/web/src/nav/queries.ts +++ b/apps/web/src/nav/queries.ts @@ -1,11 +1,11 @@ -import { getRoot } from "@server/root/functions"; +import { getRootFn } from "@server/root/functions"; import { queryOptions, useQuery } from "@tanstack/react-query"; import { rootQueryKeys } from "@wall/keys"; /** * Query options for the instance root document. * - * Backed by the `getRoot` server function, so this module imports no + * Backed by the `getRootFn` server function, so this module imports no * `@app/api`. The `_authenticated` guard reads it before any session exists to * decide first-user setup, and the whole module must stay HTTP-client-free to * keep superagent off the login wall. @@ -13,7 +13,7 @@ import { rootQueryKeys } from "@wall/keys"; export function rootQueryOptions() { return queryOptions({ queryKey: rootQueryKeys.all(), - queryFn: () => getRoot(), + queryFn: () => getRootFn(), }); } diff --git a/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx b/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx index 5c98729d6..43fed3a38 100644 --- a/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx +++ b/apps/web/src/references/components/Detail/__tests__/ArchiveReference.test.tsx @@ -32,7 +32,7 @@ describe("", () => { expect(getDialogTitle("Archive", detail.name)).toBeNull(); }); - expect(referenceServerFnMocks.archiveReference).toHaveBeenCalledWith({ + expect(referenceServerFnMocks.archiveReferenceFn).toHaveBeenCalledWith({ data: { referenceId: detail.id }, }); }); @@ -52,14 +52,14 @@ describe("", () => { expect(getDialogTitle("Unarchive", detail.name)).toBeNull(); }); - expect(referenceServerFnMocks.unarchiveReference).toHaveBeenCalledWith({ + expect(referenceServerFnMocks.unarchiveReferenceFn).toHaveBeenCalledWith({ data: { referenceId: detail.id }, }); }); it("should surface the server error message when the mutation fails", async () => { const detail = createFakeReference({ archived: false }); - referenceServerFnMocks.archiveReference.mockRejectedValue( + referenceServerFnMocks.archiveReferenceFn.mockRejectedValue( new Error("Reference not found."), ); @@ -74,7 +74,7 @@ describe("", () => { it("should fall back to a generic message when the error has none", async () => { const detail = createFakeReference({ archived: false }); - referenceServerFnMocks.archiveReference.mockRejectedValue(new Error("")); + referenceServerFnMocks.archiveReferenceFn.mockRejectedValue(new Error("")); await renderWithRouter(); @@ -98,6 +98,6 @@ describe("", () => { await waitFor(() => { expect(getDialogTitle("Archive", detail.name)).toBeNull(); }); - expect(referenceServerFnMocks.archiveReference).not.toHaveBeenCalled(); + expect(referenceServerFnMocks.archiveReferenceFn).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/references/components/__tests__/ReferenceList.test.tsx b/apps/web/src/references/components/__tests__/ReferenceList.test.tsx index 737c575f7..70a3e09a7 100644 --- a/apps/web/src/references/components/__tests__/ReferenceList.test.tsx +++ b/apps/web/src/references/components/__tests__/ReferenceList.test.tsx @@ -108,7 +108,7 @@ describe("", () => { }); it("should refetch with archived=true when the Archived toggle is clicked", async () => { - referenceServerFnMocks.findReferences.mockImplementation( + referenceServerFnMocks.findReferencesFn.mockImplementation( async ({ data }: { data: { archived?: boolean } }) => data.archived ? { diff --git a/apps/web/src/references/queries.ts b/apps/web/src/references/queries.ts index 0ff37ebb4..13d752a7d 100644 --- a/apps/web/src/references/queries.ts +++ b/apps/web/src/references/queries.ts @@ -2,20 +2,20 @@ import { settingsQueryKeys } from "@administration/keys"; import type { Settings } from "@administration/types"; import { referenceQueryKeys } from "@references/keys"; import { - addReferenceGroup, - addReferenceUser, - archiveReference, - createReference, - findReferences, - getReference, - removeReferenceGroup, - removeReferenceUser, - unarchiveReference, - updateReference, - updateReferenceGroup, - updateReferenceUser, + addReferenceGroupFn, + addReferenceUserFn, + archiveReferenceFn, + createReferenceFn, + findReferencesFn, + getReferenceFn, + removeReferenceGroupFn, + removeReferenceUserFn, + unarchiveReferenceFn, + updateReferenceFn, + updateReferenceGroupFn, + updateReferenceUserFn, } from "@server/references/functions"; -import { updateSettings } from "@server/settings/functions"; +import { updateSettingsFn } from "@server/settings/functions"; import { queryOptions, useMutation, @@ -55,7 +55,7 @@ export function referencesQueryOptions( return queryOptions({ queryKey: referenceQueryKeys.list([page, per_page, term, archived]), queryFn: () => - findReferences({ + findReferencesFn({ data: { page, per_page, term, archived }, }) as Promise, }); @@ -93,7 +93,7 @@ export function useCloneReference() { { name: string; description: string; refId: number } >({ mutationFn: ({ name, description, refId }) => - createReference({ + createReferenceFn({ data: { name, description, cloneFrom: refId }, }) as Promise, }); @@ -111,7 +111,7 @@ export function useImportReference() { { name: string; description: string; importFrom: number } >({ mutationFn: ({ name, description, importFrom }) => - createReference({ + createReferenceFn({ data: { name, description, importFrom }, }) as Promise, }); @@ -157,7 +157,7 @@ export function useCreateReference() { { name: string; description: string; organism: string } >({ mutationFn: ({ name, description, organism }) => - createReference({ + createReferenceFn({ data: { name, description, organism }, }) as Promise, }); @@ -173,7 +173,7 @@ export function useUpdateReference(refId: number, onSuccess?: () => void) { const mutation = useMutation({ mutationFn: (data) => - updateReference({ + updateReferenceFn({ data: { referenceId: refId, ...data }, }) as Promise, onSuccess: () => { @@ -199,7 +199,7 @@ export function useUpdateReferenceSourceTypes(refId: number) { return useMutation({ mutationFn: (sourceTypes) => - updateReference({ + updateReferenceFn({ data: { referenceId: refId, sourceTypes }, }) as Promise, onSuccess: () => { @@ -220,7 +220,7 @@ export function useUpdateDefaultSourceTypes() { return useMutation({ mutationFn: (sourceTypes) => - updateSettings({ data: { defaultSourceTypes: sourceTypes } }), + updateSettingsFn({ data: { defaultSourceTypes: sourceTypes } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: settingsQueryKeys.all() }); }, @@ -243,10 +243,10 @@ export function useAddReferenceMember( return useMutation({ mutationFn: ({ id }) => noun === "user" - ? (addReferenceUser({ + ? (addReferenceUserFn({ data: { referenceId: refId, userId: id }, }) as Promise) - : (addReferenceGroup({ + : (addReferenceGroupFn({ data: { referenceId: refId, groupId: id }, }) as Promise), onSuccess: () => { @@ -271,10 +271,10 @@ export function useUpdateReferenceMember(noun: ReferenceMemberNoun) { >({ mutationFn: ({ refId, id, update }) => noun === "user" - ? (updateReferenceUser({ + ? (updateReferenceUserFn({ data: { referenceId: refId, userId: id, ...update }, }) as Promise) - : (updateReferenceGroup({ + : (updateReferenceGroupFn({ data: { referenceId: refId, groupId: id, ...update }, }) as Promise), }); @@ -296,10 +296,10 @@ export function useRemoveReferenceUser( return useMutation({ mutationFn: ({ id }) => noun === "user" - ? (removeReferenceUser({ + ? (removeReferenceUserFn({ data: { referenceId: refId, userId: id }, }) as Promise) - : (removeReferenceGroup({ + : (removeReferenceGroupFn({ data: { referenceId: refId, groupId: id }, }) as Promise), onSuccess: () => { @@ -314,7 +314,7 @@ export function referenceQueryOptions(refId: number) { return queryOptions({ queryKey: referenceQueryKeys.detail(refId), queryFn: () => - getReference({ data: { referenceId: refId } }) as Promise, + getReferenceFn({ data: { referenceId: refId } }) as Promise, }); } @@ -352,7 +352,7 @@ export function useArchiveReference(refId: number) { return useMutation({ mutationFn: () => - archiveReference({ + archiveReferenceFn({ data: { referenceId: refId }, }) as Promise, onSuccess: () => { @@ -377,7 +377,7 @@ export function useUnarchiveReference(refId: number) { return useMutation({ mutationFn: () => - unarchiveReference({ + unarchiveReferenceFn({ data: { referenceId: refId }, }) as Promise, onSuccess: () => { diff --git a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx index d266862c3..25be5ed73 100644 --- a/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx +++ b/apps/web/src/samples/components/Create/__tests__/CreateSample.test.tsx @@ -69,7 +69,7 @@ describe("", () => { }); it("should show an error when the read files fail to load", async () => { - uploadServerFnMocks.findUploads.mockRejectedValue(new Error("failed")); + uploadServerFnMocks.findUploadsFn.mockRejectedValue(new Error("failed")); await renderWithRouter(); expect( diff --git a/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx b/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx index 687f5d244..871e961fc 100644 --- a/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx +++ b/apps/web/src/samples/components/__tests__/SampleLabelsSelector.test.tsx @@ -9,7 +9,7 @@ import SampleLabelsSelector from "../SampleLabelsSelector"; /** The labels sent to `updateSample` for the given sample, if it was called. */ function labelsSentFor(sampleId: number): number[] | undefined { - const call = sampleServerFnMocks.updateSample.mock.calls.find( + const call = sampleServerFnMocks.updateSampleFn.mock.calls.find( ([arg]) => arg?.data?.sampleId === sampleId, ); return call?.[0]?.data?.labels; @@ -79,7 +79,7 @@ describe("", () => { beforeEach(() => { // The bulk toggle patches each sample through the updateSample server // function; let it resolve so the mutation settles. - sampleServerFnMocks.updateSample.mockResolvedValue({}); + sampleServerFnMocks.updateSampleFn.mockResolvedValue({}); // "test" (id 1) is on both samples, "label" (id 2) is on one only, so // the selection has labels in mixed states. diff --git a/apps/web/src/samples/queries.ts b/apps/web/src/samples/queries.ts index edfa32381..179147743 100644 --- a/apps/web/src/samples/queries.ts +++ b/apps/web/src/samples/queries.ts @@ -1,12 +1,12 @@ import type { LabelNested } from "@labels/types"; import { samplesQueryKeys } from "@samples/keys"; import { - createSample, - deleteSample, - findSamples, - getSample, - updateSample, - updateSampleRights, + createSampleFn, + deleteSampleFn, + findSamplesFn, + getSampleFn, + updateSampleFn, + updateSampleRightsFn, } from "@server/samples/functions"; import { keepPreviousData, @@ -61,7 +61,7 @@ export function useListSamples( users, ]), queryFn: () => - findSamples({ + findSamplesFn({ data: { page, perPage: per_page, @@ -78,7 +78,7 @@ export function useListSamples( export function sampleQueryOptions(sampleId: number) { return queryOptions({ queryKey: samplesQueryKeys.detail(sampleId), - queryFn: () => getSample({ data: { sampleId } }) as Promise, + queryFn: () => getSampleFn({ data: { sampleId } }) as Promise, }); } @@ -122,7 +122,7 @@ export function useCreateSample() { labels, group, }) => - createSample({ + createSampleFn({ data: { name, isolate, @@ -156,7 +156,7 @@ export function useUpdateSample(sampleId: number) { return useMutation({ mutationFn: ({ update }) => - updateSample({ + updateSampleFn({ data: { sampleId, ...update }, }) as Promise, onSuccess: () => { @@ -175,7 +175,7 @@ export function useUpdateSample(sampleId: number) { export function useRemoveSample() { return useMutation({ mutationFn: ({ sampleId }) => - deleteSample({ data: { sampleId } }) as Promise, + deleteSampleFn({ data: { sampleId } }) as Promise, }); } @@ -187,7 +187,7 @@ export function useRemoveSample() { export function useUpdateSampleRights(sampleId: number) { return useMutation({ mutationFn: ({ update }) => - updateSampleRights({ + updateSampleRightsFn({ data: { sampleId, ...update }, }) as Promise, }); @@ -219,7 +219,7 @@ export function useUpdateLabel( selectedSamples.map((sample) => { const labelIds = sample.labels.map((label) => label.id); - return updateSample({ + return updateSampleFn({ data: { sampleId: sample.id, labels: allLabeled diff --git a/apps/web/src/server/__tests__/authorization.test.ts b/apps/web/src/server/__tests__/authorization.test.ts index 2640996c7..ff43ae187 100644 --- a/apps/web/src/server/__tests__/authorization.test.ts +++ b/apps/web/src/server/__tests__/authorization.test.ts @@ -238,7 +238,7 @@ describe("the open endpoints are reachable without a session", () => { expect(open.map((endpoint) => endpoint.name).sort()).toEqual([ "createFirstUserFn", "getPasswordPolicyFn", - "getRoot", + "getRootFn", "loginFn", "logoutFn", "resetPasswordFn", diff --git a/apps/web/src/server/account/functions.test.ts b/apps/web/src/server/account/functions.test.ts index 7e1fb7e0c..2fc6e826e 100644 --- a/apps/web/src/server/account/functions.test.ts +++ b/apps/web/src/server/account/functions.test.ts @@ -94,14 +94,16 @@ function call(name: string, data?: unknown) { describe("findApiKeys", () => { it("refuses an unauthenticated caller", async () => { - await expect(call("findApiKeys")).rejects.toBeInstanceOf(UnauthorizedError); + await expect(call("findApiKeysFn")).rejects.toBeInstanceOf( + UnauthorizedError, + ); }); it("returns only the signed-in user's keys", async () => { await signIn(); - await call("createApiKey", { name: "Robot", permissions: {} }); + await call("createApiKeyFn", { name: "Robot", permissions: {} }); - const keys = (await call("findApiKeys")) as { name: string }[]; + const keys = (await call("findApiKeysFn")) as { name: string }[]; expect(keys).toHaveLength(1); expect(keys[0]?.name).toBe("Robot"); @@ -112,7 +114,7 @@ describe("createApiKey", () => { it("returns the raw secret and a 201", async () => { await signIn(); - const created = (await call("createApiKey", { + const created = (await call("createApiKeyFn", { name: "Robot", permissions: { create_ref: true }, })) as { key: string; name: string }; @@ -126,7 +128,7 @@ describe("createApiKey", () => { describe("updateApiKey", () => { it("responds with 404 for a key the user does not own", async () => { const owner = await signIn("owner"); - const created = (await call("createApiKey", { + const created = (await call("createApiKeyFn", { name: "Robot", permissions: {}, })) as { id: number }; @@ -134,7 +136,7 @@ describe("updateApiKey", () => { await signIn("intruder"); await expect( - call("updateApiKey", { + call("updateApiKeyFn", { keyId: created.id, permissions: { create_ref: true }, }), @@ -150,12 +152,12 @@ describe("updateApiKey", () => { describe("deleteApiKey", () => { it("removes the signed-in user's key with a 204", async () => { await signIn(); - const created = (await call("createApiKey", { + const created = (await call("createApiKeyFn", { name: "Robot", permissions: {}, })) as { id: number }; - await call("deleteApiKey", { keyId: created.id }); + await call("deleteApiKeyFn", { keyId: created.id }); expect(setResponseStatus).toHaveBeenCalledWith(204); expect(await db.select().from(apiKeys)).toHaveLength(0); @@ -164,7 +166,7 @@ describe("deleteApiKey", () => { it("responds with 404 when the key does not exist", async () => { await signIn(); - await expect(call("deleteApiKey", { keyId: 404 })).rejects.toThrow( + await expect(call("deleteApiKeyFn", { keyId: 404 })).rejects.toThrow( "API key not found.", ); expect(setResponseStatus).toHaveBeenCalledWith(404); diff --git a/apps/web/src/server/account/functions.ts b/apps/web/src/server/account/functions.ts index 31805fa68..376417e95 100644 --- a/apps/web/src/server/account/functions.ts +++ b/apps/web/src/server/account/functions.ts @@ -39,11 +39,11 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findApiKeys = createServerFn({ method: "GET" }) +export const findApiKeysFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async ({ context }) => findApiKeysImpl(db, context.session.userId)); -export const createApiKey = createServerFn({ method: "POST" }) +export const createApiKeyFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(createApiKeySchema) .handler(async ({ context, data }) => { @@ -55,7 +55,7 @@ export const createApiKey = createServerFn({ method: "POST" }) return { ...apiKey, key }; }); -export const updateApiKey = createServerFn({ method: "POST" }) +export const updateApiKeyFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateApiKeySchema) .handler(async ({ context, data }) => { @@ -71,7 +71,7 @@ export const updateApiKey = createServerFn({ method: "POST" }) } }); -export const deleteApiKey = createServerFn({ method: "POST" }) +export const deleteApiKeyFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(keyIdSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/server/auth/exceptions.ts b/apps/web/src/server/auth/exceptions.ts index aa395be0e..c95cc3005 100644 --- a/apps/web/src/server/auth/exceptions.ts +++ b/apps/web/src/server/auth/exceptions.ts @@ -1,4 +1,4 @@ -import { getRoot } from "../root/functions"; +import { getRootFn } from "../root/functions"; import { getPasswordPolicyFn } from "../settings/functions"; import { createFirstUserFn, @@ -14,7 +14,7 @@ import { * createFirstUserFn runs before any user or session exists. * getPasswordPolicyFn serves the first-user and forced-reset forms, which set a * password before there is a session to authenticate. - * getRoot reports whether first-user setup is needed, read by the + * getRootFn reports whether first-user setup is needed, read by the * `_authenticated` guard before a session exists. */ // Annotated rather than inferred: an inferred type would reference TanStack's @@ -23,7 +23,7 @@ import { export const authenticationExceptions: ReadonlyArray<{ url: string }> = [ createFirstUserFn, getPasswordPolicyFn, - getRoot, + getRootFn, loginFn, logoutFn, resetPasswordFn, diff --git a/apps/web/src/server/auth/middleware.test.ts b/apps/web/src/server/auth/middleware.test.ts index 2078ef497..0790a0c96 100644 --- a/apps/web/src/server/auth/middleware.test.ts +++ b/apps/web/src/server/auth/middleware.test.ts @@ -56,7 +56,7 @@ const { createFirstUserFn, loginFn, logoutFn, resetPasswordFn } = await import( "./functions" ); const { getPasswordPolicyFn } = await import("../settings/functions"); -const { getRoot } = await import("../root/functions"); +const { getRootFn } = await import("../root/functions"); const { SESSION_ID_COOKIE, SESSION_TOKEN_COOKIE } = await import("./cookies"); const { basicAuthHeader, seedApiKey, seedSession, seedUser } = await import( "./test/fixtures" @@ -117,7 +117,7 @@ describe("authenticationExceptions", () => { [ createFirstUserFn, getPasswordPolicyFn, - getRoot, + getRootFn, loginFn, logoutFn, resetPasswordFn, @@ -132,7 +132,7 @@ describe("createAuthenticationMiddleware", () => { it.each([ ["createFirstUserFn", () => createFirstUserFn], ["getPasswordPolicyFn", () => getPasswordPolicyFn], - ["getRoot", () => getRoot], + ["getRootFn", () => getRootFn], ["loginFn", () => loginFn], ["logoutFn", () => logoutFn], ["resetPasswordFn", () => resetPasswordFn], diff --git a/apps/web/src/server/groups/functions.test.ts b/apps/web/src/server/groups/functions.test.ts index f0f97d466..0b6c95b17 100644 --- a/apps/web/src/server/groups/functions.test.ts +++ b/apps/web/src/server/groups/functions.test.ts @@ -110,7 +110,7 @@ describe("createGroup", () => { await signIn(null); await expect( - call("createGroup", { name: "hackers" }), + call("createGroupFn", { name: "hackers" }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); expect(await db.select().from(groups)).toHaveLength(0); @@ -118,7 +118,7 @@ describe("createGroup", () => { it("refuses an unauthenticated caller", async () => { await expect( - call("createGroup", { name: "hackers" }), + call("createGroupFn", { name: "hackers" }), ).rejects.toBeInstanceOf(UnauthorizedError); expect(setResponseStatus).toHaveBeenCalledWith(401); }); @@ -126,7 +126,7 @@ describe("createGroup", () => { it("allows an administrator", async () => { await signIn("base"); - const group = (await call("createGroup", { name: "technicians" })) as { + const group = (await call("createGroupFn", { name: "technicians" })) as { name: string; }; @@ -140,7 +140,7 @@ describe("updateGroup", () => { await signIn(null); await expect( - call("updateGroup", { groupId, name: "renamed" }), + call("updateGroupFn", { groupId, name: "renamed" }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); }); @@ -153,7 +153,7 @@ describe("updateGroup", () => { await signIn(null); await expect( - call("updateGroup", { + call("updateGroupFn", { groupId, permissions: { create_ref: true, upload_file: true }, }), @@ -169,7 +169,7 @@ describe("updateGroup", () => { const groupId = await seedGroup(); await signIn("base"); - const group = (await call("updateGroup", { + const group = (await call("updateGroupFn", { groupId, permissions: { create_ref: true }, })) as { permissions: Permissions }; @@ -183,7 +183,7 @@ describe("deleteGroup", () => { const groupId = await seedGroup(); await signIn(null); - await expect(call("deleteGroup", { groupId })).rejects.toBeInstanceOf( + await expect(call("deleteGroupFn", { groupId })).rejects.toBeInstanceOf( ForbiddenError, ); expect(setResponseStatus).toHaveBeenCalledWith(403); @@ -194,7 +194,7 @@ describe("deleteGroup", () => { const groupId = await seedGroup(); await signIn("base"); - await call("deleteGroup", { groupId }); + await call("deleteGroupFn", { groupId }); expect(await db.select().from(groups)).toHaveLength(0); }); diff --git a/apps/web/src/server/groups/functions.ts b/apps/web/src/server/groups/functions.ts index 071f14c64..61c4e8621 100644 --- a/apps/web/src/server/groups/functions.ts +++ b/apps/web/src/server/groups/functions.ts @@ -56,18 +56,18 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { // Ordinary users need the group list to set sample rights and to pick a primary // group, so the reads are open to any signed-in user. -export const listGroups = createServerFn({ method: "GET" }) +export const listGroupsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => listGroupsImpl(db)); -export const findGroups = createServerFn({ method: "GET" }) +export const findGroupsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findGroupsSchema) .handler(async ({ data }) => findGroupsImpl(db, data?.term ?? "", data?.page ?? 1, data?.per_page ?? 25), ); -export const getGroup = createServerFn({ method: "GET" }) +export const getGroupFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(groupIdSchema) .handler(async ({ data }) => { @@ -81,7 +81,7 @@ export const getGroup = createServerFn({ method: "GET" }) // A group's permissions are unioned into every member's, so anyone who can // write a group can grant themselves any permission. All three mutations are // administrator-only, as they were in the Python service they replaced. -export const createGroup = createServerFn({ method: "POST" }) +export const createGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(createGroupSchema) .handler(async ({ data }) => { @@ -94,7 +94,7 @@ export const createGroup = createServerFn({ method: "POST" }) } }); -export const updateGroup = createServerFn({ method: "POST" }) +export const updateGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(updateGroupSchema) .handler(async ({ data }) => { @@ -106,7 +106,7 @@ export const updateGroup = createServerFn({ method: "POST" }) } }); -export const deleteGroup = createServerFn({ method: "POST" }) +export const deleteGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(groupIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/hmm/functions.test.ts b/apps/web/src/server/hmm/functions.test.ts index 40c749813..9165e0e03 100644 --- a/apps/web/src/server/hmm/functions.test.ts +++ b/apps/web/src/server/hmm/functions.test.ts @@ -145,7 +145,7 @@ describe("installHmm", () => { await seedStatus(); mockManifest([RELEASE]); - const installed = (await call("installHmm")) as { + const installed = (await call("installHmmFn")) as { name: string; ready: boolean; user: { id: number; handle: string }; @@ -187,7 +187,7 @@ describe("installHmm", () => { await seedStatus(); mockManifest([RELEASE]); - const installed = (await call("installHmm")) as { + const installed = (await call("installHmmFn")) as { user: { id: number }; }; @@ -200,7 +200,7 @@ describe("installHmm", () => { await seedStatus({ updates: [{ ready: false } as HmmUpdate] }); const fetchSpy = vi.spyOn(globalThis, "fetch"); - await expect(call("installHmm")).rejects.toThrow(); + await expect(call("installHmmFn")).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(409); expect(fetchSpy).not.toHaveBeenCalled(); expect(await db.select().from(tasks)).toHaveLength(0); @@ -211,7 +211,7 @@ describe("installHmm", () => { await seedStatus(); mockManifest([]); - await expect(call("installHmm")).rejects.toThrow(); + await expect(call("installHmmFn")).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(502); expect(await db.select().from(tasks)).toHaveLength(0); }); @@ -221,7 +221,7 @@ describe("getHmm", () => { it("responds 404 when the HMM is absent", async () => { await signIn("base"); - await expect(call("getHmm", { hmmId: 5000 })).rejects.toThrow(); + await expect(call("getHmmFn", { hmmId: 5000 })).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(404); }); }); diff --git a/apps/web/src/server/hmm/functions.ts b/apps/web/src/server/hmm/functions.ts index 26047bd2a..6492ee8d5 100644 --- a/apps/web/src/server/hmm/functions.ts +++ b/apps/web/src/server/hmm/functions.ts @@ -43,12 +43,12 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findHmms = createServerFn({ method: "GET" }) +export const findHmmsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findHmmsSchema) .handler(async ({ data }) => findHmmsImpl(db, data)); -export const getHmm = createServerFn({ method: "GET" }) +export const getHmmFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(hmmIdSchema) .handler(async ({ data }) => { @@ -59,7 +59,7 @@ export const getHmm = createServerFn({ method: "GET" }) } }); -export const installHmm = createServerFn({ method: "POST" }) +export const installHmmFn = createServerFn({ method: "POST" }) .middleware([permission("modify_hmm")]) .handler(async ({ context }) => { try { diff --git a/apps/web/src/server/jobs/functions.ts b/apps/web/src/server/jobs/functions.ts index 7436c48f9..597a4db28 100644 --- a/apps/web/src/server/jobs/functions.ts +++ b/apps/web/src/server/jobs/functions.ts @@ -44,17 +44,17 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findJobs = createServerFn({ method: "GET" }) +export const findJobsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findJobsSchema) .handler(async ({ data }) => findJobsImpl(db, data)); -export const getJobs = createServerFn({ method: "GET" }) +export const getJobsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(jobIdsSchema) .handler(async ({ data }) => getJobsImpl(db, data.jobIds)); -export const getJob = createServerFn({ method: "GET" }) +export const getJobFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(jobIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/labels/functions.ts b/apps/web/src/server/labels/functions.ts index 2da6bb7c8..8ddcf4d4b 100644 --- a/apps/web/src/server/labels/functions.ts +++ b/apps/web/src/server/labels/functions.ts @@ -58,12 +58,12 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findLabels = createServerFn({ method: "GET" }) +export const findLabelsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findLabelsSchema) .handler(async ({ data }) => findLabelsImpl(db, data?.term ?? "")); -export const getLabel = createServerFn({ method: "GET" }) +export const getLabelFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(labelIdSchema) .handler(async ({ data }) => { @@ -74,7 +74,7 @@ export const getLabel = createServerFn({ method: "GET" }) } }); -export const createLabel = createServerFn({ method: "POST" }) +export const createLabelFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(labelValuesSchema) .handler(async ({ data }) => { @@ -87,7 +87,7 @@ export const createLabel = createServerFn({ method: "POST" }) } }); -export const updateLabel = createServerFn({ method: "POST" }) +export const updateLabelFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(labelIdSchema.extend(labelValuesSchema.partial().shape)) .handler(async ({ data }) => { @@ -99,7 +99,7 @@ export const updateLabel = createServerFn({ method: "POST" }) } }); -export const deleteLabel = createServerFn({ method: "POST" }) +export const deleteLabelFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(labelIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/messages/functions.ts b/apps/web/src/server/messages/functions.ts index dc8387dbd..e1a2d10a2 100644 --- a/apps/web/src/server/messages/functions.ts +++ b/apps/web/src/server/messages/functions.ts @@ -45,15 +45,15 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findMessage = createServerFn({ method: "GET" }) +export const findMessageFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => findMessageImpl(db)); -export const findMessages = createServerFn({ method: "GET" }) +export const findMessagesFn = createServerFn({ method: "GET" }) .middleware([adminRole("settings")]) .handler(async () => findMessagesImpl(db)); -export const createMessage = createServerFn({ method: "POST" }) +export const createMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(createMessageSchema) .handler(async ({ context, data }) => { @@ -67,7 +67,7 @@ export const createMessage = createServerFn({ method: "POST" }) return message; }); -export const updateMessage = createServerFn({ method: "POST" }) +export const updateMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(updateMessageSchema) .handler(async ({ context, data }) => { @@ -83,7 +83,7 @@ export const updateMessage = createServerFn({ method: "POST" }) } }); -export const deleteMessage = createServerFn({ method: "POST" }) +export const deleteMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(idSchema) .handler(async ({ data }) => { @@ -96,7 +96,7 @@ export const deleteMessage = createServerFn({ method: "POST" }) } }); -export const setActiveMessage = createServerFn({ method: "POST" }) +export const setActiveMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(idSchema) .handler(async ({ data }) => { @@ -107,7 +107,7 @@ export const setActiveMessage = createServerFn({ method: "POST" }) } }); -export const clearActiveMessage = createServerFn({ method: "POST" }) +export const clearActiveMessageFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .handler(async () => { await clearActiveMessageImpl(db); diff --git a/apps/web/src/server/references/functions.test.ts b/apps/web/src/server/references/functions.test.ts index 6424e0f96..fbbd81ac5 100644 --- a/apps/web/src/server/references/functions.test.ts +++ b/apps/web/src/server/references/functions.test.ts @@ -159,7 +159,7 @@ describe("findReferences", () => { await seedReference(ownerId); await seedReference(otherId); - const result = (await call("findReferences", { + const result = (await call("findReferencesFn", { page: 1, per_page: 25, })) as { @@ -181,7 +181,7 @@ describe("findReferences", () => { await seedReference(adminId); await seedReference(otherId); - const result = (await call("findReferences", { + const result = (await call("findReferencesFn", { page: 1, per_page: 25, })) as { @@ -197,7 +197,7 @@ describe("getReference", () => { await signIn(null); await expect( - call("getReference", { referenceId: 999_999 }), + call("getReferenceFn", { referenceId: 999_999 }), ).rejects.toThrow("Reference not found."); expect(setResponseStatus).toHaveBeenCalledWith(404); }); @@ -206,7 +206,7 @@ describe("getReference", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const reference = (await call("getReference", { referenceId })) as { + const reference = (await call("getReferenceFn", { referenceId })) as { users: { id: number; modifyOtu: boolean }[]; sourceTypes: string[]; }; @@ -224,7 +224,7 @@ describe("getReference", () => { await signIn(null); const referenceId = await seedReference(ownerId); - await expect(call("getReference", { referenceId })).rejects.toThrow( + await expect(call("getReferenceFn", { referenceId })).rejects.toThrow( "Reference not found.", ); expect(setResponseStatus).toHaveBeenCalledWith(404); @@ -238,7 +238,7 @@ describe("getReference", () => { await signIn("full"); const referenceId = await seedReference(ownerId); - const reference = (await call("getReference", { referenceId })) as { + const reference = (await call("getReferenceFn", { referenceId })) as { id: number; }; @@ -268,7 +268,7 @@ describe("getReference", () => { modify_otu: false, }); - const reference = (await call("getReference", { referenceId })) as { + const reference = (await call("getReferenceFn", { referenceId })) as { id: number; }; @@ -281,14 +281,14 @@ describe("createReference", () => { await signIn(null); await expect( - call("createReference", { name: "New", description: "", organism: "" }), + call("createReferenceFn", { name: "New", description: "", organism: "" }), ).rejects.toBeInstanceOf(ForbiddenError); }); it("creates an empty reference and seeds the creator with all rights", async () => { const userId = await signIn("full"); - const reference = (await call("createReference", { + const reference = (await call("createReferenceFn", { name: "Empty", description: "desc", organism: "virus", @@ -304,7 +304,7 @@ describe("createReference", () => { const userId = await signIn("full"); const sourceId = await seedReference(userId); - const reference = (await call("createReference", { + const reference = (await call("createReferenceFn", { name: "", description: "", organism: "", @@ -322,7 +322,7 @@ describe("createReference", () => { await signIn("full"); await expect( - call("createReference", { + call("createReferenceFn", { name: "Imported", description: "", organism: "", @@ -336,7 +336,7 @@ describe("createReference", () => { const userId = await signIn("full"); const uploadId = await seedUpload(userId); - const reference = (await call("createReference", { + const reference = (await call("createReferenceFn", { name: "Imported", description: "", organism: "", @@ -358,7 +358,7 @@ describe("updateReference", () => { const referenceId = await seedReference(ownerId); await expect( - call("updateReference", { referenceId, name: "Renamed" }), + call("updateReferenceFn", { referenceId, name: "Renamed" }), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -366,7 +366,7 @@ describe("updateReference", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const reference = (await call("updateReference", { + const reference = (await call("updateReferenceFn", { referenceId, name: "Renamed", restrictSourceTypes: true, @@ -381,7 +381,7 @@ describe("updateReference", () => { const referenceId = await seedReference(ownerId, { archived: true }); await expect( - call("updateReference", { referenceId, name: "Renamed" }), + call("updateReferenceFn", { referenceId, name: "Renamed" }), ).rejects.toThrow("Reference is archived."); expect(setResponseStatus).toHaveBeenCalledWith(409); }); @@ -392,7 +392,7 @@ describe("archiveReference", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const reference = (await call("archiveReference", { referenceId })) as { + const reference = (await call("archiveReferenceFn", { referenceId })) as { archived: boolean; }; @@ -411,7 +411,7 @@ describe("reference membership", () => { .returning({ id: groups.id }), ); - const member = (await call("addReferenceGroup", { + const member = (await call("addReferenceGroupFn", { referenceId, groupId: group.id, modify: true, @@ -437,7 +437,7 @@ describe("reference membership", () => { ); await expect( - call("addReferenceGroup", { referenceId, groupId: group.id }), + call("addReferenceGroupFn", { referenceId, groupId: group.id }), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -445,7 +445,7 @@ describe("reference membership", () => { const ownerId = await signIn(null); const referenceId = await seedReference(ownerId); - const member = (await call("updateReferenceUser", { + const member = (await call("updateReferenceUserFn", { referenceId, userId: ownerId, modify: false, @@ -462,7 +462,7 @@ describe("reference membership", () => { const referenceId = await seedReference(ownerId); await expect( - call("removeReferenceUser", { referenceId, userId: 999_999 }), + call("removeReferenceUserFn", { referenceId, userId: 999_999 }), ).rejects.toThrow("Member not found."); expect(setResponseStatus).toHaveBeenCalledWith(404); }); diff --git a/apps/web/src/server/references/functions.ts b/apps/web/src/server/references/functions.ts index a0607e4ea..64ecc80bb 100644 --- a/apps/web/src/server/references/functions.ts +++ b/apps/web/src/server/references/functions.ts @@ -126,7 +126,7 @@ const authorizeReference = createServerOnlyFn( }, ); -export const findReferences = createServerFn({ method: "GET" }) +export const findReferencesFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findReferencesSchema) .handler(async ({ context, data }) => { @@ -144,7 +144,7 @@ export const findReferences = createServerFn({ method: "GET" }) ); }); -export const getReference = createServerFn({ method: "GET" }) +export const getReferenceFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(referenceIdSchema) .handler(async ({ context, data }) => { @@ -162,7 +162,7 @@ export const getReference = createServerFn({ method: "GET" }) } }); -export const createReference = createServerFn({ method: "POST" }) +export const createReferenceFn = createServerFn({ method: "POST" }) .middleware([permission("create_ref")]) .validator(createReferenceSchema) .handler(async ({ context, data }) => { @@ -182,7 +182,7 @@ export const createReference = createServerFn({ method: "POST" }) } }); -export const updateReference = createServerFn({ method: "POST" }) +export const updateReferenceFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateReferenceSchema) .handler(async ({ context, data }) => { @@ -195,7 +195,7 @@ export const updateReference = createServerFn({ method: "POST" }) } }); -export const archiveReference = createServerFn({ method: "POST" }) +export const archiveReferenceFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceIdSchema) .handler(async ({ context, data }) => { @@ -211,7 +211,7 @@ export const archiveReference = createServerFn({ method: "POST" }) } }); -export const unarchiveReference = createServerFn({ method: "POST" }) +export const unarchiveReferenceFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceIdSchema) .handler(async ({ context, data }) => { @@ -227,7 +227,7 @@ export const unarchiveReference = createServerFn({ method: "POST" }) } }); -export const addReferenceUser = createServerFn({ method: "POST" }) +export const addReferenceUserFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(addReferenceUserSchema) .handler(async ({ context, data }) => { @@ -247,7 +247,7 @@ export const addReferenceUser = createServerFn({ method: "POST" }) } }); -export const addReferenceGroup = createServerFn({ method: "POST" }) +export const addReferenceGroupFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(addReferenceGroupSchema) .handler(async ({ context, data }) => { @@ -269,7 +269,7 @@ export const addReferenceGroup = createServerFn({ method: "POST" }) } }); -export const updateReferenceUser = createServerFn({ method: "POST" }) +export const updateReferenceUserFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateReferenceUserSchema) .handler(async ({ context, data }) => { @@ -282,7 +282,7 @@ export const updateReferenceUser = createServerFn({ method: "POST" }) } }); -export const updateReferenceGroup = createServerFn({ method: "POST" }) +export const updateReferenceGroupFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateReferenceGroupSchema) .handler(async ({ context, data }) => { @@ -295,7 +295,7 @@ export const updateReferenceGroup = createServerFn({ method: "POST" }) } }); -export const removeReferenceUser = createServerFn({ method: "POST" }) +export const removeReferenceUserFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceUserSchema) .handler(async ({ context, data }) => { @@ -313,7 +313,7 @@ export const removeReferenceUser = createServerFn({ method: "POST" }) } }); -export const removeReferenceGroup = createServerFn({ method: "POST" }) +export const removeReferenceGroupFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(referenceGroupSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/server/root/functions.test.ts b/apps/web/src/server/root/functions.test.ts index d50b2571c..4a7a8aa9b 100644 --- a/apps/web/src/server/root/functions.test.ts +++ b/apps/web/src/server/root/functions.test.ts @@ -62,7 +62,7 @@ beforeEach(() => { describe("getRoot", () => { it("reports firstUser when the instance has no users", async () => { - const root = (await callServerFn(handlers, "getRoot", undefined)) as { + const root = (await callServerFn(handlers, "getRootFn", undefined)) as { firstUser: boolean; version: string; }; @@ -74,7 +74,7 @@ describe("getRoot", () => { it("reports no firstUser once a user exists", async () => { await seedUser(db); - const root = (await callServerFn(handlers, "getRoot", undefined)) as { + const root = (await callServerFn(handlers, "getRootFn", undefined)) as { firstUser: boolean; }; diff --git a/apps/web/src/server/root/functions.ts b/apps/web/src/server/root/functions.ts index 445d273cd..5573e6af8 100644 --- a/apps/web/src/server/root/functions.ts +++ b/apps/web/src/server/root/functions.ts @@ -7,7 +7,7 @@ import { getUserCount } from "../users/data"; // before any session exists to decide whether to redirect to first-user setup, // so this cannot require a session. `version` is the running deployment's build // version, injected by Vite's `define` (see appVersion.d.ts). -export const getRoot = createServerFn({ method: "GET" }) +export const getRootFn = createServerFn({ method: "GET" }) .middleware([open()]) .handler(async () => ({ firstUser: (await getUserCount(db)) === 0, diff --git a/apps/web/src/server/samples/functions.test.ts b/apps/web/src/server/samples/functions.test.ts index 33ccfce00..67715674d 100644 --- a/apps/web/src/server/samples/functions.test.ts +++ b/apps/web/src/server/samples/functions.test.ts @@ -119,7 +119,7 @@ describe("getSample", () => { await signIn(null); - await expect(call("getSample", { sampleId })).rejects.toBeInstanceOf( + await expect(call("getSampleFn", { sampleId })).rejects.toBeInstanceOf( ForbiddenError, ); expect(setResponseStatus).toHaveBeenCalledWith(403); @@ -128,7 +128,7 @@ describe("getSample", () => { it("returns 404, not 403, for a sample that does not exist", async () => { await signIn(null); - await expect(call("getSample", { sampleId: 123456 })).rejects.toThrow(); + await expect(call("getSampleFn", { sampleId: 123456 })).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(404); expect(setResponseStatus).not.toHaveBeenCalledWith(403); }); @@ -137,7 +137,7 @@ describe("getSample", () => { const userId = await signIn(null); const sampleId = await seedSampleRow({ user_id: userId }); - const sample = (await call("getSample", { sampleId })) as { id: number }; + const sample = (await call("getSampleFn", { sampleId })) as { id: number }; expect(sample.id).toBe(sampleId); }); }); @@ -147,7 +147,7 @@ describe("updateSampleRights", () => { await signIn("full"); await expect( - call("updateSampleRights", { sampleId: 123456, allRead: true }), + call("updateSampleRightsFn", { sampleId: 123456, allRead: true }), ).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(404); }); @@ -159,7 +159,7 @@ describe("updateSampleRights", () => { await signIn(null); await expect( - call("updateSampleRights", { sampleId, allRead: true }), + call("updateSampleRightsFn", { sampleId, allRead: true }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); }); @@ -168,7 +168,7 @@ describe("updateSampleRights", () => { const userId = await signIn(null); const sampleId = await seedSampleRow({ user_id: userId }); - const sample = (await call("updateSampleRights", { + const sample = (await call("updateSampleRightsFn", { sampleId, allRead: true, })) as { allRead: boolean }; @@ -181,7 +181,7 @@ describe("updateSampleRights", () => { await signIn("full"); - const sample = (await call("updateSampleRights", { + const sample = (await call("updateSampleRightsFn", { sampleId, allWrite: true, })) as { allWrite: boolean }; @@ -196,7 +196,7 @@ describe("deleteSample", () => { await signIn(null); - await expect(call("deleteSample", { sampleId })).rejects.toBeInstanceOf( + await expect(call("deleteSampleFn", { sampleId })).rejects.toBeInstanceOf( ForbiddenError, ); expect(setResponseStatus).toHaveBeenCalledWith(403); @@ -224,7 +224,7 @@ describe("deleteSample", () => { job_id: jobId, }); - await expect(call("deleteSample", { sampleId })).rejects.toThrow(); + await expect(call("deleteSampleFn", { sampleId })).rejects.toThrow(); expect(setResponseStatus).toHaveBeenCalledWith(400); // The sample must survive the rejected delete. diff --git a/apps/web/src/server/samples/functions.ts b/apps/web/src/server/samples/functions.ts index c0c78042a..740e3a9c4 100644 --- a/apps/web/src/server/samples/functions.ts +++ b/apps/web/src/server/samples/functions.ts @@ -120,7 +120,7 @@ function coerceGroup(group: string | number | null | undefined): number | null { return typeof group === "number" ? group : Number(group); } -export const findSamples = createServerFn({ method: "GET" }) +export const findSamplesFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findSamplesSchema) .handler(async ({ context, data }) => { @@ -140,7 +140,7 @@ export const findSamples = createServerFn({ method: "GET" }) ); }); -export const getSample = createServerFn({ method: "GET" }) +export const getSampleFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(sampleIdSchema) .handler(async ({ context, data }) => { @@ -152,7 +152,7 @@ export const getSample = createServerFn({ method: "GET" }) } }); -export const createSample = createServerFn({ method: "POST" }) +export const createSampleFn = createServerFn({ method: "POST" }) .middleware([permission("create_sample")]) .validator(SampleCreateRequest) .handler(async ({ context, data }) => { @@ -177,7 +177,7 @@ export const createSample = createServerFn({ method: "POST" }) } }); -export const updateSample = createServerFn({ method: "POST" }) +export const updateSampleFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateSampleSchema) .handler(async ({ context, data }) => { @@ -190,7 +190,7 @@ export const updateSample = createServerFn({ method: "POST" }) } }); -export const deleteSample = createServerFn({ method: "POST" }) +export const deleteSampleFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(sampleIdSchema) .handler(async ({ context, data }) => { @@ -225,7 +225,7 @@ export const deleteSample = createServerFn({ method: "POST" }) } }); -export const updateSampleRights = createServerFn({ method: "POST" }) +export const updateSampleRightsFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(updateRightsSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/server/settings/functions.test.ts b/apps/web/src/server/settings/functions.test.ts index ec579dc6d..a751870d8 100644 --- a/apps/web/src/server/settings/functions.test.ts +++ b/apps/web/src/server/settings/functions.test.ts @@ -95,12 +95,14 @@ function call(name: string, data?: unknown) { describe("getSettings", () => { it("refuses an unauthenticated caller", async () => { - await expect(call("getSettings")).rejects.toBeInstanceOf(UnauthorizedError); + await expect(call("getSettingsFn")).rejects.toBeInstanceOf( + UnauthorizedError, + ); }); it("refuses a caller without the settings role", async () => { await signIn("base"); - await expect(call("getSettings")).rejects.toBeInstanceOf(ForbiddenError); + await expect(call("getSettingsFn")).rejects.toBeInstanceOf(ForbiddenError); }); it("returns the settings for a settings administrator", async () => { @@ -112,7 +114,7 @@ describe("getSettings", () => { sampleGroup: "force_choice", }); - await expect(call("getSettings")).resolves.toMatchObject({ + await expect(call("getSettingsFn")).resolves.toMatchObject({ defaultSourceTypes: ["genotype"], enableApi: true, minimumPasswordLength: 12, @@ -124,14 +126,14 @@ describe("getSettings", () => { describe("updateSettings", () => { it("refuses an unauthenticated caller", async () => { await expect( - call("updateSettings", { enableApi: true }), + call("updateSettingsFn", { enableApi: true }), ).rejects.toBeInstanceOf(UnauthorizedError); }); it("refuses a caller without the settings role", async () => { await signIn("base"); await expect( - call("updateSettings", { enableApi: true }), + call("updateSettingsFn", { enableApi: true }), ).rejects.toBeInstanceOf(ForbiddenError); }); @@ -140,7 +142,7 @@ describe("updateSettings", () => { await seedSettings(db, { enableApi: false }); await expect( - call("updateSettings", { + call("updateSettingsFn", { enableApi: true, defaultSourceTypes: ["strain"], }), @@ -159,12 +161,12 @@ describe("updateSettings", () => { it("rejects an invalid sample group", async () => { await signIn("settings"); await expect( - call("updateSettings", { sampleGroup: "everyone" }), + call("updateSettingsFn", { sampleGroup: "everyone" }), ).rejects.toThrow(); }); it("rejects an empty patch", async () => { await signIn("settings"); - await expect(call("updateSettings", {})).rejects.toThrow(); + await expect(call("updateSettingsFn", {})).rejects.toThrow(); }); }); diff --git a/apps/web/src/server/settings/functions.ts b/apps/web/src/server/settings/functions.ts index d17032044..31257ed6a 100644 --- a/apps/web/src/server/settings/functions.ts +++ b/apps/web/src/server/settings/functions.ts @@ -54,11 +54,11 @@ const updateSettingsSchema = z message: "At least one setting must be provided.", }); -export const getSettings = createServerFn({ method: "GET" }) +export const getSettingsFn = createServerFn({ method: "GET" }) .middleware([adminRole("settings")]) .handler(async (): Promise => getSettingsImpl(db)); -export const updateSettings = createServerFn({ method: "POST" }) +export const updateSettingsFn = createServerFn({ method: "POST" }) .middleware([adminRole("settings")]) .validator(updateSettingsSchema) .handler(async ({ data }): Promise => updateSettingsImpl(db, data)); diff --git a/apps/web/src/server/subtraction/functions.test.ts b/apps/web/src/server/subtraction/functions.test.ts index 02e228651..44e4f6c7a 100644 --- a/apps/web/src/server/subtraction/functions.test.ts +++ b/apps/web/src/server/subtraction/functions.test.ts @@ -114,7 +114,7 @@ describe("createSubtraction", () => { const uploadId = await seedUpload(userId); await expect( - call("createSubtraction", { + call("createSubtractionFn", { name: "Arabidopsis", nickname: "", uploadId, @@ -125,7 +125,7 @@ describe("createSubtraction", () => { it("refuses an unauthenticated caller", async () => { await expect( - call("createSubtraction", { + call("createSubtractionFn", { name: "Arabidopsis", nickname: "", uploadId: 1, @@ -137,7 +137,7 @@ describe("createSubtraction", () => { const userId = await signIn("full"); const uploadId = await seedUpload(userId); - const subtraction = (await call("createSubtraction", { + const subtraction = (await call("createSubtractionFn", { name: "Arabidopsis", nickname: "plant", uploadId, @@ -152,7 +152,7 @@ describe("createSubtraction", () => { await signIn("full"); await expect( - call("createSubtraction", { + call("createSubtractionFn", { name: "Arabidopsis", nickname: "", uploadId: 999_999, @@ -167,7 +167,7 @@ describe("getSubtraction", () => { await signIn(null); await expect( - call("getSubtraction", { subtractionId: 999_999 }), + call("getSubtractionFn", { subtractionId: 999_999 }), ).rejects.toThrow("Subtraction not found."); expect(setResponseStatus).toHaveBeenCalledWith(404); }); @@ -178,7 +178,7 @@ describe("deleteSubtraction", () => { await signIn(null); await expect( - call("deleteSubtraction", { subtractionId: 1 }), + call("deleteSubtractionFn", { subtractionId: 1 }), ).rejects.toBeInstanceOf(ForbiddenError); }); }); diff --git a/apps/web/src/server/subtraction/functions.ts b/apps/web/src/server/subtraction/functions.ts index c40c7665e..8fd172662 100644 --- a/apps/web/src/server/subtraction/functions.ts +++ b/apps/web/src/server/subtraction/functions.ts @@ -52,7 +52,7 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findSubtractions = createServerFn({ method: "GET" }) +export const findSubtractionsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findSubtractionsSchema) .handler(async ({ data }) => @@ -64,11 +64,11 @@ export const findSubtractions = createServerFn({ method: "GET" }) }), ); -export const listSubtractionsShortlist = createServerFn({ method: "GET" }) +export const listSubtractionsShortlistFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => listSubtractionsShortlistImpl(db)); -export const getSubtraction = createServerFn({ method: "GET" }) +export const getSubtractionFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(subtractionIdSchema) .handler(async ({ data }) => { @@ -79,7 +79,7 @@ export const getSubtraction = createServerFn({ method: "GET" }) } }); -export const createSubtraction = createServerFn({ method: "POST" }) +export const createSubtractionFn = createServerFn({ method: "POST" }) .middleware([permission("modify_subtraction")]) .validator(createSubtractionSchema) .handler(async ({ context, data }) => { @@ -97,7 +97,7 @@ export const createSubtraction = createServerFn({ method: "POST" }) } }); -export const updateSubtraction = createServerFn({ method: "POST" }) +export const updateSubtractionFn = createServerFn({ method: "POST" }) .middleware([permission("modify_subtraction")]) .validator(updateSubtractionSchema) .handler(async ({ data }) => { @@ -109,7 +109,7 @@ export const updateSubtraction = createServerFn({ method: "POST" }) } }); -export const deleteSubtraction = createServerFn({ method: "POST" }) +export const deleteSubtractionFn = createServerFn({ method: "POST" }) .middleware([permission("modify_subtraction")]) .validator(subtractionIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/tasks/functions.ts b/apps/web/src/server/tasks/functions.ts index 154a4d377..e4d7e85f1 100644 --- a/apps/web/src/server/tasks/functions.ts +++ b/apps/web/src/server/tasks/functions.ts @@ -23,7 +23,7 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const getTask = createServerFn({ method: "GET" }) +export const getTaskFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(taskIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/uploads/functions.test.ts b/apps/web/src/server/uploads/functions.test.ts index f12dbe53c..2f2774dd6 100644 --- a/apps/web/src/server/uploads/functions.test.ts +++ b/apps/web/src/server/uploads/functions.test.ts @@ -123,7 +123,7 @@ describe("findUploads", () => { const userId = await signIn(null); await seedUpload(userId, { name: "reads.fq.gz" }); - const result = (await call("findUploads", { + const result = (await call("findUploadsFn", { page: 1, per_page: 25, })) as { items: { name: string }[] }; @@ -138,7 +138,7 @@ describe("deleteUpload", () => { const upload = await seedUpload(userId); await expect( - call("deleteUpload", { id: upload.id }), + call("deleteUploadFn", { id: upload.id }), ).rejects.toBeInstanceOf(ForbiddenError); expect(setResponseStatus).toHaveBeenCalledWith(403); }); @@ -147,7 +147,7 @@ describe("deleteUpload", () => { const userId = await signIn("full"); const upload = await seedUpload(userId); - await call("deleteUpload", { id: upload.id }); + await call("deleteUploadFn", { id: upload.id }); const [row] = await db.select().from(uploadsTable); expect(row?.removed).toBe(true); @@ -157,7 +157,7 @@ describe("deleteUpload", () => { it("maps a missing upload to a 404", async () => { await signIn("full"); - await expect(call("deleteUpload", { id: 404 })).rejects.toThrow( + await expect(call("deleteUploadFn", { id: 404 })).rejects.toThrow( "Upload not found.", ); expect(setResponseStatus).toHaveBeenCalledWith(404); @@ -167,7 +167,7 @@ describe("deleteUpload", () => { const userId = await signIn("full"); const upload = await seedUpload(userId, { reserved: true }); - await expect(call("deleteUpload", { id: upload.id })).rejects.toThrow( + await expect(call("deleteUploadFn", { id: upload.id })).rejects.toThrow( "Upload is reserved and in use.", ); expect(setResponseStatus).toHaveBeenCalledWith(409); diff --git a/apps/web/src/server/uploads/functions.ts b/apps/web/src/server/uploads/functions.ts index cd7868e65..33e3d1de5 100644 --- a/apps/web/src/server/uploads/functions.ts +++ b/apps/web/src/server/uploads/functions.ts @@ -47,7 +47,7 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const findUploads = createServerFn({ method: "GET" }) +export const findUploadsFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(findUploadsSchema) .handler(async ({ data }) => @@ -60,7 +60,7 @@ export const findUploads = createServerFn({ method: "GET" }) ), ); -export const deleteUpload = createServerFn({ method: "POST" }) +export const deleteUploadFn = createServerFn({ method: "POST" }) .middleware([permission("remove_file")]) .validator(uploadIdSchema) .handler(async ({ data }) => { diff --git a/apps/web/src/server/users/functions.ts b/apps/web/src/server/users/functions.ts index 339200bef..e6812ab7c 100644 --- a/apps/web/src/server/users/functions.ts +++ b/apps/web/src/server/users/functions.ts @@ -114,17 +114,17 @@ const rethrowAsHttp = createServerOnlyFn((err: unknown): never => { throw err; }); -export const listAdministratorRoles = createServerFn({ method: "GET" }) +export const listAdministratorRolesFn = createServerFn({ method: "GET" }) .middleware([adminRole("base")]) .handler(async () => listAdministratorRolesImpl()); // Any authenticated user can see who else exists — the handles are already // visible on samples, jobs, and analyses they can read. -export const listUsers = createServerFn({ method: "GET" }) +export const listUsersFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async () => listUsersImpl(db)); -export const findUsers = createServerFn({ method: "GET" }) +export const findUsersFn = createServerFn({ method: "GET" }) .middleware([adminRole("users")]) .validator(findUsersSchema) .handler(async ({ data }) => { @@ -140,9 +140,9 @@ export const findUsers = createServerFn({ method: "GET" }) // A paginated user search any signed-in user may run, mirroring Python's // `GET /users` (authenticated, no administrator filter). Backs the reference // member picker, where a non-admin who holds `modify` on a reference searches -// users to add. `findUsers` above is the stricter administrator-only variant +// users to add. `findUsersFn` above is the stricter administrator-only variant // used by the user administration views. -export const searchUsers = createServerFn({ method: "GET" }) +export const searchUsersFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .validator(searchUsersSchema) .handler(async ({ data }) => @@ -156,11 +156,11 @@ export const searchUsers = createServerFn({ method: "GET" }) // Not on the authentication exception list, so an anonymous call gets a 401. // The login wall and the authenticated route guard both rely on that: a // rejected call is how they learn there is no session. -export const getAccount = createServerFn({ method: "GET" }) +export const getAccountFn = createServerFn({ method: "GET" }) .middleware([authenticated()]) .handler(async ({ context }) => getAccountImpl(db, context.session.userId)); -export const getUser = createServerFn({ method: "GET" }) +export const getUserFn = createServerFn({ method: "GET" }) .middleware([adminRole("users")]) .validator(userIdSchema) .handler(async ({ data }) => { @@ -173,7 +173,7 @@ export const getUser = createServerFn({ method: "GET" }) } }); -export const createUser = createServerFn({ method: "POST" }) +export const createUserFn = createServerFn({ method: "POST" }) .middleware([adminRole("users")]) .validator(createUserSchema) .handler(async ({ data }) => { @@ -194,7 +194,7 @@ export const createUser = createServerFn({ method: "POST" }) } }); -export const updateUser = createServerFn({ method: "POST" }) +export const updateUserFn = createServerFn({ method: "POST" }) .middleware([adminRole("users")]) .validator(updateUserSchema) .handler(async ({ context, data }) => { @@ -220,7 +220,7 @@ export const updateUser = createServerFn({ method: "POST" }) } }); -export const updateAccountHandle = createServerFn({ method: "POST" }) +export const updateAccountHandleFn = createServerFn({ method: "POST" }) .middleware([authenticated()]) .validator(accountHandleSchema) .handler(async ({ context, data }) => { @@ -235,7 +235,7 @@ export const updateAccountHandle = createServerFn({ method: "POST" }) } }); -export const setAdministratorRole = createServerFn({ method: "POST" }) +export const setAdministratorRoleFn = createServerFn({ method: "POST" }) .middleware([adminRole("full")]) .validator(setAdministratorRoleSchema) .handler(async ({ context, data }) => { diff --git a/apps/web/src/subtraction/queries.ts b/apps/web/src/subtraction/queries.ts index fe1bfc9db..a1e05ab5c 100644 --- a/apps/web/src/subtraction/queries.ts +++ b/apps/web/src/subtraction/queries.ts @@ -1,10 +1,10 @@ import { - createSubtraction, - deleteSubtraction, - findSubtractions, - getSubtraction, - listSubtractionsShortlist, - updateSubtraction, + createSubtractionFn, + deleteSubtractionFn, + findSubtractionsFn, + getSubtractionFn, + listSubtractionsShortlistFn, + updateSubtractionFn, } from "@server/subtraction/functions"; import { subtractionQueryKeys } from "@subtraction/keys"; import { @@ -35,7 +35,7 @@ export function useCreateSubtraction() { { name: string; nickname: string; uploadId: number } >({ mutationFn: ({ name, nickname, uploadId }) => - createSubtraction({ + createSubtractionFn({ data: { name, nickname, uploadId }, }) as Promise, onSuccess: () => { @@ -61,7 +61,7 @@ export function subtractionsQueryOptions( return queryOptions({ queryKey: subtractionQueryKeys.list([page, per_page, term]), queryFn: () => - findSubtractions({ + findSubtractionsFn({ data: { page, per_page, term }, }) as Promise, }); @@ -94,7 +94,7 @@ export function useFetchSubtraction(subtractionId: number) { return useQuery({ queryKey: subtractionQueryKeys.detail(subtractionId), queryFn: () => - getSubtraction({ + getSubtractionFn({ data: { subtractionId }, }) as Promise, }); @@ -114,7 +114,7 @@ export function useUpdateSubtraction(subtractionId: number) { { name: string; nickname: string } >({ mutationFn: ({ name, nickname }) => - updateSubtraction({ + updateSubtractionFn({ data: { subtractionId, name, nickname }, }) as Promise, onSuccess: () => { @@ -133,7 +133,7 @@ export function useUpdateSubtraction(subtractionId: number) { export function useDeleteSubtraction() { return useMutation({ mutationFn: ({ subtractionId }) => - deleteSubtraction({ + deleteSubtractionFn({ data: { subtractionId }, }) as Promise, }); @@ -150,6 +150,7 @@ export function useDeleteSubtraction() { export function useFetchSubtractionsShortlist() { return useQuery({ queryKey: subtractionQueryKeys.shortlist(), - queryFn: () => listSubtractionsShortlist() as Promise, + queryFn: () => + listSubtractionsShortlistFn() as Promise, }); } diff --git a/apps/web/src/tasks/queries.ts b/apps/web/src/tasks/queries.ts index 0583024b9..439d3d5ba 100644 --- a/apps/web/src/tasks/queries.ts +++ b/apps/web/src/tasks/queries.ts @@ -1,4 +1,4 @@ -import { getTask } from "@server/tasks/functions"; +import { getTaskFn } from "@server/tasks/functions"; import { useQuery } from "@tanstack/react-query"; import { taskQueryKeys } from "@tasks/keys"; import { type ServerTask, TaskSchema } from "./types"; @@ -17,7 +17,7 @@ import { type ServerTask, TaskSchema } from "./types"; export function useFetchTask(taskId: number, seed?: ServerTask) { return useQuery({ queryKey: taskQueryKeys.detail(taskId), - queryFn: () => getTask({ data: { taskId } }), + queryFn: () => getTaskFn({ data: { taskId } }), select: TaskSchema.parse, enabled: Number.isInteger(taskId), initialData: seed ? TaskSchema.parse(seed) : undefined, diff --git a/apps/web/src/tests/server-fn/account.ts b/apps/web/src/tests/server-fn/account.ts index 0389c0ab8..6a81bfae1 100644 --- a/apps/web/src/tests/server-fn/account.ts +++ b/apps/web/src/tests/server-fn/account.ts @@ -9,16 +9,16 @@ import { createFakeApiKey } from "../fake/account"; * view can stub them without per-file `vi.mock` boilerplate. */ export const accountServerFnMocks = { - findApiKeys: vi.fn(), - createApiKey: vi.fn(), - updateApiKey: vi.fn(), - deleteApiKey: vi.fn(), + findApiKeysFn: vi.fn(), + createApiKeyFn: vi.fn(), + updateApiKeyFn: vi.fn(), + deleteApiKeyFn: vi.fn(), }; /** Sets up findApiKeys to resolve with the given API keys. */ export function mockFindApiKeys(apiKeys: ApiKey[]): Mock { - accountServerFnMocks.findApiKeys.mockResolvedValue(apiKeys); - return accountServerFnMocks.findApiKeys; + accountServerFnMocks.findApiKeysFn.mockResolvedValue(apiKeys); + return accountServerFnMocks.findApiKeysFn; } /** @@ -30,9 +30,9 @@ export function mockCreateApiKey( permissions: Permissions, overrides?: Partial, ): Mock { - accountServerFnMocks.createApiKey.mockResolvedValue({ + accountServerFnMocks.createApiKeyFn.mockResolvedValue({ ...createFakeApiKey({ permissions, ...overrides }), key, }); - return accountServerFnMocks.createApiKey; + return accountServerFnMocks.createApiKeyFn; } diff --git a/apps/web/src/tests/server-fn/groups.ts b/apps/web/src/tests/server-fn/groups.ts index 040906ae9..af7c15356 100644 --- a/apps/web/src/tests/server-fn/groups.ts +++ b/apps/web/src/tests/server-fn/groups.ts @@ -7,23 +7,23 @@ import { type Mock, vi } from "vitest"; * the groups server functions without per-file `vi.mock` boilerplate. */ export const groupServerFnMocks = { - listGroups: vi.fn(), - findGroups: vi.fn(), - getGroup: vi.fn(), - createGroup: vi.fn(), - updateGroup: vi.fn(), - deleteGroup: vi.fn(), + listGroupsFn: vi.fn(), + findGroupsFn: vi.fn(), + getGroupFn: vi.fn(), + createGroupFn: vi.fn(), + updateGroupFn: vi.fn(), + deleteGroupFn: vi.fn(), }; /** Sets up the listGroups server fn to resolve with the provided groups. */ export function mockListGroups(groups: Group[]): Mock { - groupServerFnMocks.listGroups.mockResolvedValue(groups); - return groupServerFnMocks.listGroups; + groupServerFnMocks.listGroupsFn.mockResolvedValue(groups); + return groupServerFnMocks.listGroupsFn; } /** Sets up the getGroup server fn to resolve with the provided group. */ export function mockGetGroup(group: Group): Mock { - groupServerFnMocks.getGroup.mockImplementation( + groupServerFnMocks.getGroupFn.mockImplementation( async ({ data }: { data: { groupId: number } }) => { if (data.groupId === group.id) { return group; @@ -31,5 +31,5 @@ export function mockGetGroup(group: Group): Mock { throw new Error(`unexpected groupId in mockGetGroup: ${data.groupId}`); }, ); - return groupServerFnMocks.getGroup; + return groupServerFnMocks.getGroupFn; } diff --git a/apps/web/src/tests/server-fn/hmm.ts b/apps/web/src/tests/server-fn/hmm.ts index d2f151546..188833685 100644 --- a/apps/web/src/tests/server-fn/hmm.ts +++ b/apps/web/src/tests/server-fn/hmm.ts @@ -7,21 +7,21 @@ import { type Mock, vi } from "vitest"; * these without per-file `vi.mock` boilerplate. */ export const hmmServerFnMocks = { - findHmms: vi.fn(), - getHmm: vi.fn(), - installHmm: vi.fn(), + findHmmsFn: vi.fn(), + getHmmFn: vi.fn(), + installHmmFn: vi.fn(), }; /** Sets up findHmms to resolve with the given search results. */ export function mockFindHmms(searchResults: HmmSearchResult): Mock { - hmmServerFnMocks.findHmms.mockResolvedValue(searchResults); - return hmmServerFnMocks.findHmms; + hmmServerFnMocks.findHmmsFn.mockResolvedValue(searchResults); + return hmmServerFnMocks.findHmmsFn; } /** Sets up getHmm to resolve with the given HMM. */ export function mockGetHmm(hmm: Hmm): Mock { - hmmServerFnMocks.getHmm.mockResolvedValue(hmm); - return hmmServerFnMocks.getHmm; + hmmServerFnMocks.getHmmFn.mockResolvedValue(hmm); + return hmmServerFnMocks.getHmmFn; } /** @@ -29,8 +29,8 @@ export function mockGetHmm(hmm: Hmm): Mock { * a server function surfaces a 404 to the client. */ export function mockGetHmmError(status: number): Mock { - hmmServerFnMocks.getHmm.mockRejectedValue( + hmmServerFnMocks.getHmmFn.mockRejectedValue( Object.assign(new Error("HMM not found."), { status }), ); - return hmmServerFnMocks.getHmm; + return hmmServerFnMocks.getHmmFn; } diff --git a/apps/web/src/tests/server-fn/jobs.ts b/apps/web/src/tests/server-fn/jobs.ts index 14a347045..ee31cd0c3 100644 --- a/apps/web/src/tests/server-fn/jobs.ts +++ b/apps/web/src/tests/server-fn/jobs.ts @@ -7,9 +7,9 @@ import { type Mock, vi } from "vitest"; * the jobs server functions without per-file `vi.mock` boilerplate. */ export const jobServerFnMocks = { - findJobs: vi.fn(), - getJob: vi.fn(), - getJobs: vi.fn(), + findJobsFn: vi.fn(), + getJobFn: vi.fn(), + getJobsFn: vi.fn(), }; /** Sets up findJobs to resolve with a single page containing the given jobs. */ @@ -17,7 +17,7 @@ export function mockFindJobs( jobs: ServerJobMinimal[], found_count?: number, ): Mock { - jobServerFnMocks.findJobs.mockResolvedValue({ + jobServerFnMocks.findJobsFn.mockResolvedValue({ counts: {}, found_count: found_count ?? jobs.length, items: jobs, @@ -26,7 +26,7 @@ export function mockFindJobs( per_page: 25, total_count: jobs.length, }); - return jobServerFnMocks.findJobs; + return jobServerFnMocks.findJobsFn; } /** @@ -36,16 +36,16 @@ export function mockFindJobs( * the result rather than an error. */ export function mockGetJobs(jobs: ServerJob[]): Mock { - jobServerFnMocks.getJobs.mockImplementation( + jobServerFnMocks.getJobsFn.mockImplementation( async ({ data }: { data: { jobIds: number[] } }) => jobs.filter((job) => data.jobIds.includes(job.id)), ); - return jobServerFnMocks.getJobs; + return jobServerFnMocks.getJobsFn; } /** Sets up getJob to resolve with the given job when matched by id. */ export function mockGetJob(jobId: number, job: ServerJob): Mock { - jobServerFnMocks.getJob.mockImplementation( + jobServerFnMocks.getJobFn.mockImplementation( async ({ data }: { data: { jobId: number } }) => { if (data.jobId === jobId) { return job; @@ -53,5 +53,5 @@ export function mockGetJob(jobId: number, job: ServerJob): Mock { throw new Error(`unexpected jobId in mockGetJob: ${data.jobId}`); }, ); - return jobServerFnMocks.getJob; + return jobServerFnMocks.getJobFn; } diff --git a/apps/web/src/tests/server-fn/labels.ts b/apps/web/src/tests/server-fn/labels.ts index 009573ca2..08141ba43 100644 --- a/apps/web/src/tests/server-fn/labels.ts +++ b/apps/web/src/tests/server-fn/labels.ts @@ -7,15 +7,15 @@ import { type Mock, vi } from "vitest"; * labels can stub them without per-file `vi.mock` boilerplate. */ export const labelServerFnMocks = { - createLabel: vi.fn(), - deleteLabel: vi.fn(), - findLabels: vi.fn(), - getLabel: vi.fn(), - updateLabel: vi.fn(), + createLabelFn: vi.fn(), + deleteLabelFn: vi.fn(), + findLabelsFn: vi.fn(), + getLabelFn: vi.fn(), + updateLabelFn: vi.fn(), }; /** Sets up findLabels to resolve with the given labels. */ export function mockFindLabels(labels: Label[]): Mock { - labelServerFnMocks.findLabels.mockResolvedValue(labels); - return labelServerFnMocks.findLabels; + labelServerFnMocks.findLabelsFn.mockResolvedValue(labels); + return labelServerFnMocks.findLabelsFn; } diff --git a/apps/web/src/tests/server-fn/references.ts b/apps/web/src/tests/server-fn/references.ts index 3a1288675..2f29618b2 100644 --- a/apps/web/src/tests/server-fn/references.ts +++ b/apps/web/src/tests/server-fn/references.ts @@ -7,18 +7,18 @@ import { type Mock, vi } from "vitest"; * or mutates references can stub them without per-file `vi.mock` boilerplate. */ export const referenceServerFnMocks = { - findReferences: vi.fn(), - getReference: vi.fn(), - createReference: vi.fn(), - updateReference: vi.fn(), - archiveReference: vi.fn(), - unarchiveReference: vi.fn(), - addReferenceUser: vi.fn(), - addReferenceGroup: vi.fn(), - updateReferenceUser: vi.fn(), - updateReferenceGroup: vi.fn(), - removeReferenceUser: vi.fn(), - removeReferenceGroup: vi.fn(), + findReferencesFn: vi.fn(), + getReferenceFn: vi.fn(), + createReferenceFn: vi.fn(), + updateReferenceFn: vi.fn(), + archiveReferenceFn: vi.fn(), + unarchiveReferenceFn: vi.fn(), + addReferenceUserFn: vi.fn(), + addReferenceGroupFn: vi.fn(), + updateReferenceUserFn: vi.fn(), + updateReferenceGroupFn: vi.fn(), + removeReferenceUserFn: vi.fn(), + removeReferenceGroupFn: vi.fn(), }; /** Sets up findReferences to resolve with a single page of the given items. */ @@ -26,7 +26,7 @@ export function mockFindReferences( items: ReferenceMinimal[], overrides?: Partial<{ foundCount: number; totalCount: number }>, ): Mock { - referenceServerFnMocks.findReferences.mockResolvedValue({ + referenceServerFnMocks.findReferencesFn.mockResolvedValue({ foundCount: items.length, totalCount: items.length, page: 1, @@ -35,12 +35,12 @@ export function mockFindReferences( items, ...overrides, }); - return referenceServerFnMocks.findReferences; + return referenceServerFnMocks.findReferencesFn; } /** Sets up getReference to resolve with the given reference when matched. */ export function mockGetReference(reference: Reference): Mock { - referenceServerFnMocks.getReference.mockImplementation( + referenceServerFnMocks.getReferenceFn.mockImplementation( async ({ data }: { data: { referenceId: number } }) => { if (data.referenceId === reference.id) { return reference; @@ -50,23 +50,23 @@ export function mockGetReference(reference: Reference): Mock { ); }, ); - return referenceServerFnMocks.getReference; + return referenceServerFnMocks.getReferenceFn; } /** Sets up createReference to resolve with the given reference. */ export function mockCreateReference(reference: Reference): Mock { - referenceServerFnMocks.createReference.mockResolvedValue(reference); - return referenceServerFnMocks.createReference; + referenceServerFnMocks.createReferenceFn.mockResolvedValue(reference); + return referenceServerFnMocks.createReferenceFn; } /** Sets up archiveReference to resolve with the given reference. */ export function mockArchiveReference(reference: Reference): Mock { - referenceServerFnMocks.archiveReference.mockResolvedValue(reference); - return referenceServerFnMocks.archiveReference; + referenceServerFnMocks.archiveReferenceFn.mockResolvedValue(reference); + return referenceServerFnMocks.archiveReferenceFn; } /** Sets up unarchiveReference to resolve with the given reference. */ export function mockUnarchiveReference(reference: Reference): Mock { - referenceServerFnMocks.unarchiveReference.mockResolvedValue(reference); - return referenceServerFnMocks.unarchiveReference; + referenceServerFnMocks.unarchiveReferenceFn.mockResolvedValue(reference); + return referenceServerFnMocks.unarchiveReferenceFn; } diff --git a/apps/web/src/tests/server-fn/root.ts b/apps/web/src/tests/server-fn/root.ts index 04539c287..1a74e87d7 100644 --- a/apps/web/src/tests/server-fn/root.ts +++ b/apps/web/src/tests/server-fn/root.ts @@ -6,7 +6,7 @@ import { type Mock, vi } from "vitest"; * unauthenticated root document without per-file `vi.mock` boilerplate. */ export const rootServerFnMocks = { - getRoot: vi.fn(), + getRootFn: vi.fn(), }; /** @@ -17,9 +17,9 @@ export function mockGetRoot(root: { firstUser: boolean; version?: string; }): Mock { - rootServerFnMocks.getRoot.mockResolvedValue({ + rootServerFnMocks.getRootFn.mockResolvedValue({ version: __APP_VERSION__, ...root, }); - return rootServerFnMocks.getRoot; + return rootServerFnMocks.getRootFn; } diff --git a/apps/web/src/tests/server-fn/samples.ts b/apps/web/src/tests/server-fn/samples.ts index e45ed29fc..df5e150f2 100644 --- a/apps/web/src/tests/server-fn/samples.ts +++ b/apps/web/src/tests/server-fn/samples.ts @@ -8,12 +8,12 @@ import { createFakeSample } from "../fake/samples"; * sample server functions without per-file `vi.mock` boilerplate. */ export const sampleServerFnMocks = { - findSamples: vi.fn(), - getSample: vi.fn(), - createSample: vi.fn(), - updateSample: vi.fn(), - deleteSample: vi.fn(), - updateSampleRights: vi.fn(), + findSamplesFn: vi.fn(), + getSampleFn: vi.fn(), + createSampleFn: vi.fn(), + updateSampleFn: vi.fn(), + deleteSampleFn: vi.fn(), + updateSampleRightsFn: vi.fn(), }; /** @@ -28,7 +28,7 @@ export function mockFindSamples( samples: SampleMinimal[], counts: { foundCount?: number; totalCount?: number } = {}, ): Mock { - sampleServerFnMocks.findSamples.mockResolvedValue({ + sampleServerFnMocks.findSamplesFn.mockResolvedValue({ page: 1, pageCount: 1, perPage: 5, @@ -36,7 +36,7 @@ export function mockFindSamples( foundCount: counts.foundCount ?? samples.length, items: samples, }); - return sampleServerFnMocks.findSamples; + return sampleServerFnMocks.findSamplesFn; } /** @@ -47,7 +47,7 @@ export function mockFindSamples( * @param pages - the samples on each page, in page order */ export function mockFindSamplePages(pages: SampleMinimal[][]): Mock { - sampleServerFnMocks.findSamples.mockImplementation( + sampleServerFnMocks.findSamplesFn.mockImplementation( async ({ data }: { data?: { page?: number } }) => { const page = data?.page ?? 1; return { @@ -60,21 +60,21 @@ export function mockFindSamplePages(pages: SampleMinimal[][]): Mock { }; }, ); - return sampleServerFnMocks.findSamples; + return sampleServerFnMocks.findSamplesFn; } /** Sets up getSample to resolve with the given sample. */ export function mockGetSample(sample: Sample): Mock { - sampleServerFnMocks.getSample.mockResolvedValue(sample); - return sampleServerFnMocks.getSample; + sampleServerFnMocks.getSampleFn.mockResolvedValue(sample); + return sampleServerFnMocks.getSampleFn; } /** Sets up createSample to resolve with the given (or a fake) sample. */ export function mockCreateSample(sample?: Sample): Mock { - sampleServerFnMocks.createSample.mockResolvedValue( + sampleServerFnMocks.createSampleFn.mockResolvedValue( sample ?? createFakeSample(), ); - return sampleServerFnMocks.createSample; + return sampleServerFnMocks.createSampleFn; } /** Sets up updateSample to resolve with the given sample, patched with the fields. */ @@ -82,14 +82,17 @@ export function mockUpdateSample( sample: Sample, update: Partial = {}, ): Mock { - sampleServerFnMocks.updateSample.mockResolvedValue({ ...sample, ...update }); - return sampleServerFnMocks.updateSample; + sampleServerFnMocks.updateSampleFn.mockResolvedValue({ + ...sample, + ...update, + }); + return sampleServerFnMocks.updateSampleFn; } /** Sets up deleteSample to resolve. */ export function mockDeleteSample(): Mock { - sampleServerFnMocks.deleteSample.mockResolvedValue(null); - return sampleServerFnMocks.deleteSample; + sampleServerFnMocks.deleteSampleFn.mockResolvedValue(null); + return sampleServerFnMocks.deleteSampleFn; } /** Sets up updateSampleRights to resolve with the given sample, patched with the fields. */ @@ -97,9 +100,9 @@ export function mockUpdateSampleRights( sample: Sample, update: Partial = {}, ): Mock { - sampleServerFnMocks.updateSampleRights.mockResolvedValue({ + sampleServerFnMocks.updateSampleRightsFn.mockResolvedValue({ ...sample, ...update, }); - return sampleServerFnMocks.updateSampleRights; + return sampleServerFnMocks.updateSampleRightsFn; } diff --git a/apps/web/src/tests/server-fn/settings.ts b/apps/web/src/tests/server-fn/settings.ts index 5595fdc7f..7e9ef86e4 100644 --- a/apps/web/src/tests/server-fn/settings.ts +++ b/apps/web/src/tests/server-fn/settings.ts @@ -9,8 +9,8 @@ import { type Mock, vi } from "vitest"; */ export const settingsServerFnMocks = { getPasswordPolicyFn: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), + getSettingsFn: vi.fn(), + updateSettingsFn: vi.fn(), }; /** Set the minimum password length the password forms will validate against. */ @@ -25,14 +25,14 @@ export function mockGetPasswordPolicy( /** Resolve the settings query with the given settings. */ export function mockGetSettings(settings: Settings): Mock { - settingsServerFnMocks.getSettings.mockResolvedValue(settings); - return settingsServerFnMocks.getSettings; + settingsServerFnMocks.getSettingsFn.mockResolvedValue(settings); + return settingsServerFnMocks.getSettingsFn; } /** Resolve the settings update, echoing the given settings back to the caller. */ export function mockUpdateSettings(settings: Settings): Mock { - settingsServerFnMocks.updateSettings.mockResolvedValue(settings); - return settingsServerFnMocks.updateSettings; + settingsServerFnMocks.updateSettingsFn.mockResolvedValue(settings); + return settingsServerFnMocks.updateSettingsFn; } /** @@ -48,8 +48,8 @@ export function mockSettingsStore(initial: Settings): { } { let current = initial; - settingsServerFnMocks.getSettings.mockImplementation(async () => current); - settingsServerFnMocks.updateSettings.mockImplementation( + settingsServerFnMocks.getSettingsFn.mockImplementation(async () => current); + settingsServerFnMocks.updateSettingsFn.mockImplementation( async ({ data }: { data: Partial }) => { current = { ...current, ...data }; return current; @@ -57,7 +57,7 @@ export function mockSettingsStore(initial: Settings): { ); return { - getSettings: settingsServerFnMocks.getSettings, - updateSettings: settingsServerFnMocks.updateSettings, + getSettings: settingsServerFnMocks.getSettingsFn, + updateSettings: settingsServerFnMocks.updateSettingsFn, }; } diff --git a/apps/web/src/tests/server-fn/subtractions.ts b/apps/web/src/tests/server-fn/subtractions.ts index f6287293e..444cd0fa0 100644 --- a/apps/web/src/tests/server-fn/subtractions.ts +++ b/apps/web/src/tests/server-fn/subtractions.ts @@ -11,17 +11,17 @@ import { type Mock, vi } from "vitest"; * reads subtractions can stub them without per-file `vi.mock` boilerplate. */ export const subtractionServerFnMocks = { - createSubtraction: vi.fn(), - deleteSubtraction: vi.fn(), - findSubtractions: vi.fn(), - getSubtraction: vi.fn(), - listSubtractionsShortlist: vi.fn(), - updateSubtraction: vi.fn(), + createSubtractionFn: vi.fn(), + deleteSubtractionFn: vi.fn(), + findSubtractionsFn: vi.fn(), + getSubtractionFn: vi.fn(), + listSubtractionsShortlistFn: vi.fn(), + updateSubtractionFn: vi.fn(), }; /** Sets up findSubtractions to resolve with a single page of the given items. */ export function mockFindSubtractions(items: SubtractionMinimal[]): Mock { - subtractionServerFnMocks.findSubtractions.mockResolvedValue({ + subtractionServerFnMocks.findSubtractionsFn.mockResolvedValue({ found_count: items.length, total_count: items.length, ready_count: items.filter((item) => item.ready).length, @@ -30,12 +30,12 @@ export function mockFindSubtractions(items: SubtractionMinimal[]): Mock { per_page: 25, items, }); - return subtractionServerFnMocks.findSubtractions; + return subtractionServerFnMocks.findSubtractionsFn; } /** Sets up getSubtraction to resolve with the given subtraction when matched. */ export function mockGetSubtraction(subtraction: Subtraction): Mock { - subtractionServerFnMocks.getSubtraction.mockImplementation( + subtractionServerFnMocks.getSubtractionFn.mockImplementation( async ({ data }: { data: { subtractionId: number } }) => { if (data.subtractionId === subtraction.id) { return subtraction; @@ -45,13 +45,13 @@ export function mockGetSubtraction(subtraction: Subtraction): Mock { ); }, ); - return subtractionServerFnMocks.getSubtraction; + return subtractionServerFnMocks.getSubtractionFn; } /** Sets up createSubtraction to resolve with the given subtraction. */ export function mockCreateSubtraction(subtraction: Subtraction): Mock { - subtractionServerFnMocks.createSubtraction.mockResolvedValue(subtraction); - return subtractionServerFnMocks.createSubtraction; + subtractionServerFnMocks.createSubtractionFn.mockResolvedValue(subtraction); + return subtractionServerFnMocks.createSubtractionFn; } /** @@ -59,7 +59,7 @@ export function mockCreateSubtraction(subtraction: Subtraction): Mock { * whatever name and nickname the caller submitted. */ export function mockUpdateSubtraction(subtraction: Subtraction): Mock { - subtractionServerFnMocks.updateSubtraction.mockImplementation( + subtractionServerFnMocks.updateSubtractionFn.mockImplementation( async ({ data, }: { @@ -70,21 +70,21 @@ export function mockUpdateSubtraction(subtraction: Subtraction): Mock { nickname: data.nickname ?? subtraction.nickname, }), ); - return subtractionServerFnMocks.updateSubtraction; + return subtractionServerFnMocks.updateSubtractionFn; } /** Sets up deleteSubtraction to resolve. */ export function mockDeleteSubtraction(): Mock { - subtractionServerFnMocks.deleteSubtraction.mockResolvedValue(null); - return subtractionServerFnMocks.deleteSubtraction; + subtractionServerFnMocks.deleteSubtractionFn.mockResolvedValue(null); + return subtractionServerFnMocks.deleteSubtractionFn; } /** Sets up listSubtractionsShortlist to resolve with the given options. */ export function mockListSubtractionsShortlist( subtractions: SubtractionOption[], ): Mock { - subtractionServerFnMocks.listSubtractionsShortlist.mockResolvedValue( + subtractionServerFnMocks.listSubtractionsShortlistFn.mockResolvedValue( subtractions, ); - return subtractionServerFnMocks.listSubtractionsShortlist; + return subtractionServerFnMocks.listSubtractionsShortlistFn; } diff --git a/apps/web/src/tests/server-fn/uploads.ts b/apps/web/src/tests/server-fn/uploads.ts index 46edd9d2f..9731168a0 100644 --- a/apps/web/src/tests/server-fn/uploads.ts +++ b/apps/web/src/tests/server-fn/uploads.ts @@ -11,13 +11,13 @@ import { type Mock, vi } from "vitest"; * exercise uploading mock `@uploads/uploader` directly. */ export const uploadServerFnMocks = { - deleteUpload: vi.fn(), - findUploads: vi.fn(), + deleteUploadFn: vi.fn(), + findUploadsFn: vi.fn(), }; /** Sets up findUploads to resolve with a single page of the given uploads. */ export function mockFindUploads(files: Upload[]): Mock { - uploadServerFnMocks.findUploads.mockResolvedValue({ + uploadServerFnMocks.findUploadsFn.mockResolvedValue({ items: files, found_count: files.length, total_count: files.length, @@ -25,5 +25,5 @@ export function mockFindUploads(files: Upload[]): Mock { page_count: 1, per_page: 25, }); - return uploadServerFnMocks.findUploads; + return uploadServerFnMocks.findUploadsFn; } diff --git a/apps/web/src/tests/server-fn/users.ts b/apps/web/src/tests/server-fn/users.ts index a413e8c16..a3b154c91 100644 --- a/apps/web/src/tests/server-fn/users.ts +++ b/apps/web/src/tests/server-fn/users.ts @@ -9,21 +9,21 @@ import { expect, type Mock, vi } from "vitest"; * the users server functions without per-file `vi.mock` boilerplate. */ export const userServerFnMocks = { - findUsers: vi.fn(), - searchUsers: vi.fn(), - listUsers: vi.fn(), - getAccount: vi.fn(), - getUser: vi.fn(), - createUser: vi.fn(), - updateUser: vi.fn(), - updateAccountHandle: vi.fn(), - setAdministratorRole: vi.fn(), - listAdministratorRoles: vi.fn(), + findUsersFn: vi.fn(), + searchUsersFn: vi.fn(), + listUsersFn: vi.fn(), + getAccountFn: vi.fn(), + getUserFn: vi.fn(), + createUserFn: vi.fn(), + updateUserFn: vi.fn(), + updateAccountHandleFn: vi.fn(), + setAdministratorRoleFn: vi.fn(), + listAdministratorRolesFn: vi.fn(), }; /** Sets up findUsers to resolve with a single page containing the given users. */ export function mockFindUsers(users: User[]): Mock { - userServerFnMocks.findUsers.mockResolvedValue({ + userServerFnMocks.findUsersFn.mockResolvedValue({ items: users, foundCount: users.length, page: 1, @@ -31,12 +31,12 @@ export function mockFindUsers(users: User[]): Mock { perPage: 25, totalCount: users.length, }); - return userServerFnMocks.findUsers; + return userServerFnMocks.findUsersFn; } /** Sets up searchUsers to resolve with a single page containing the given users. */ export function mockSearchUsers(users: User[]): Mock { - userServerFnMocks.searchUsers.mockResolvedValue({ + userServerFnMocks.searchUsersFn.mockResolvedValue({ items: users, foundCount: users.length, page: 1, @@ -44,21 +44,21 @@ export function mockSearchUsers(users: User[]): Mock { perPage: 25, totalCount: users.length, }); - return userServerFnMocks.searchUsers; + return userServerFnMocks.searchUsersFn; } /** Sets up listUsers to resolve with the given users, reduced to id and handle. */ export function mockListUsers(users: UserNested[]): Mock { - userServerFnMocks.listUsers.mockResolvedValue( + userServerFnMocks.listUsersFn.mockResolvedValue( users.map(({ handle, id }) => ({ handle, id })), ); - return userServerFnMocks.listUsers; + return userServerFnMocks.listUsersFn; } /** Sets up getAccount to resolve with the given account. */ export function mockGetAccount(account: Account): Mock { - userServerFnMocks.getAccount.mockResolvedValue(account); - return userServerFnMocks.getAccount; + userServerFnMocks.getAccountFn.mockResolvedValue(account); + return userServerFnMocks.getAccountFn; } /** @@ -72,14 +72,14 @@ export function mockGetAccountUnauthorized(): Mock { const error = new Error("Unauthorized"); error.name = "UnauthorizedError"; - userServerFnMocks.getAccount.mockRejectedValue(error); + userServerFnMocks.getAccountFn.mockRejectedValue(error); - return userServerFnMocks.getAccount; + return userServerFnMocks.getAccountFn; } /** Sets up getUser to resolve with the given user when matched by id. */ export function mockGetUser(userId: number, user: User): Mock { - userServerFnMocks.getUser.mockImplementation( + userServerFnMocks.getUserFn.mockImplementation( async ({ data }: { data: { userId: number } }) => { if (data.userId === userId) { return user; @@ -87,7 +87,7 @@ export function mockGetUser(userId: number, user: User): Mock { throw new Error(`unexpected userId in mockGetUser: ${data.userId}`); }, ); - return userServerFnMocks.getUser; + return userServerFnMocks.getUserFn; } /** Sets up createUser to resolve with the given user (or reject on a 4xx code). */ @@ -97,11 +97,11 @@ export function mockCreateUser( message = "User already exists.", ): Mock { if (statusCode >= 400) { - userServerFnMocks.createUser.mockRejectedValue(new Error(message)); + userServerFnMocks.createUserFn.mockRejectedValue(new Error(message)); } else { - userServerFnMocks.createUser.mockResolvedValue(user ?? {}); + userServerFnMocks.createUserFn.mockResolvedValue(user ?? {}); } - return userServerFnMocks.createUser; + return userServerFnMocks.createUserFn; } /** Sets up updateUser to resolve with the merged user (or reject on a 4xx code). */ @@ -114,11 +114,11 @@ export function mockUpdateUser( if (statusCode >= 400) { const message = typeof update.message === "string" ? update.message : "Bad request."; - userServerFnMocks.updateUser.mockRejectedValue(new Error(message)); + userServerFnMocks.updateUserFn.mockRejectedValue(new Error(message)); } else { - userServerFnMocks.updateUser.mockResolvedValue({ ...user, ...update }); + userServerFnMocks.updateUserFn.mockResolvedValue({ ...user, ...update }); } - return userServerFnMocks.updateUser; + return userServerFnMocks.updateUserFn; } /** @@ -135,9 +135,11 @@ export function mockUpdateAccountHandle( expectedHandle?: string, ): Mock { if (statusCode >= 400) { - userServerFnMocks.updateAccountHandle.mockRejectedValue(new Error(message)); + userServerFnMocks.updateAccountHandleFn.mockRejectedValue( + new Error(message), + ); } else { - userServerFnMocks.updateAccountHandle.mockImplementation( + userServerFnMocks.updateAccountHandleFn.mockImplementation( async ({ data }: { data: { handle: string } }) => { if (expectedHandle !== undefined) { expect(data.handle).toBe(expectedHandle); @@ -146,17 +148,17 @@ export function mockUpdateAccountHandle( }, ); } - return userServerFnMocks.updateAccountHandle; + return userServerFnMocks.updateAccountHandleFn; } /** Sets up listAdministratorRoles to resolve with the given roles. */ export function mockListAdministratorRoles(roles: AdministratorRole[]): Mock { - userServerFnMocks.listAdministratorRoles.mockResolvedValue(roles); - return userServerFnMocks.listAdministratorRoles; + userServerFnMocks.listAdministratorRolesFn.mockResolvedValue(roles); + return userServerFnMocks.listAdministratorRolesFn; } /** Sets up setAdministratorRole to resolve with the given user. */ export function mockSetAdministratorRole(user: User): Mock { - userServerFnMocks.setAdministratorRole.mockResolvedValue(user); - return userServerFnMocks.setAdministratorRole; + userServerFnMocks.setAdministratorRoleFn.mockResolvedValue(user); + return userServerFnMocks.setAdministratorRoleFn; } diff --git a/apps/web/src/tests/setup.tsx b/apps/web/src/tests/setup.tsx index fad1e410f..832d51a9f 100644 --- a/apps/web/src/tests/setup.tsx +++ b/apps/web/src/tests/setup.tsx @@ -107,16 +107,16 @@ beforeEach(() => { ...Object.values(authServerFnMocks), ...Object.values(labelServerFnMocks), ...Object.values(rootServerFnMocks), - uploadServerFnMocks.findUploads, - uploadServerFnMocks.deleteUpload, - subtractionServerFnMocks.findSubtractions, - subtractionServerFnMocks.getSubtraction, - subtractionServerFnMocks.listSubtractionsShortlist, - referenceServerFnMocks.findReferences, - referenceServerFnMocks.getReference, - sampleServerFnMocks.findSamples, - sampleServerFnMocks.getSample, - settingsServerFnMocks.getSettings, + uploadServerFnMocks.findUploadsFn, + uploadServerFnMocks.deleteUploadFn, + subtractionServerFnMocks.findSubtractionsFn, + subtractionServerFnMocks.getSubtractionFn, + subtractionServerFnMocks.listSubtractionsShortlistFn, + referenceServerFnMocks.findReferencesFn, + referenceServerFnMocks.getReferenceFn, + sampleServerFnMocks.findSamplesFn, + sampleServerFnMocks.getSampleFn, + settingsServerFnMocks.getSettingsFn, ]) { fn.mockReset(); // Default to a pending promise so an un-stubbed query renders its loading @@ -125,24 +125,24 @@ beforeEach(() => { } for (const fn of [ - subtractionServerFnMocks.createSubtraction, - subtractionServerFnMocks.updateSubtraction, - subtractionServerFnMocks.deleteSubtraction, - referenceServerFnMocks.createReference, - referenceServerFnMocks.updateReference, - referenceServerFnMocks.archiveReference, - referenceServerFnMocks.unarchiveReference, - referenceServerFnMocks.addReferenceUser, - referenceServerFnMocks.addReferenceGroup, - referenceServerFnMocks.updateReferenceUser, - referenceServerFnMocks.updateReferenceGroup, - referenceServerFnMocks.removeReferenceUser, - referenceServerFnMocks.removeReferenceGroup, - sampleServerFnMocks.createSample, - sampleServerFnMocks.updateSample, - sampleServerFnMocks.deleteSample, - sampleServerFnMocks.updateSampleRights, - settingsServerFnMocks.updateSettings, + subtractionServerFnMocks.createSubtractionFn, + subtractionServerFnMocks.updateSubtractionFn, + subtractionServerFnMocks.deleteSubtractionFn, + referenceServerFnMocks.createReferenceFn, + referenceServerFnMocks.updateReferenceFn, + referenceServerFnMocks.archiveReferenceFn, + referenceServerFnMocks.unarchiveReferenceFn, + referenceServerFnMocks.addReferenceUserFn, + referenceServerFnMocks.addReferenceGroupFn, + referenceServerFnMocks.updateReferenceUserFn, + referenceServerFnMocks.updateReferenceGroupFn, + referenceServerFnMocks.removeReferenceUserFn, + referenceServerFnMocks.removeReferenceGroupFn, + sampleServerFnMocks.createSampleFn, + sampleServerFnMocks.updateSampleFn, + sampleServerFnMocks.deleteSampleFn, + sampleServerFnMocks.updateSampleRightsFn, + settingsServerFnMocks.updateSettingsFn, ]) { fn.mockReset(); } diff --git a/apps/web/src/uploads/components/__tests__/FileManager.test.tsx b/apps/web/src/uploads/components/__tests__/FileManager.test.tsx index 041071590..d88c6482b 100644 --- a/apps/web/src/uploads/components/__tests__/FileManager.test.tsx +++ b/apps/web/src/uploads/components/__tests__/FileManager.test.tsx @@ -121,7 +121,7 @@ describe("", () => { mockGetAccount(createFakeAccount({ administrator_role: "full" })); mockFindUploads(files); - uploadServerFnMocks.deleteUpload.mockResolvedValue(null); + uploadServerFnMocks.deleteUploadFn.mockResolvedValue(null); await renderWithRouter(, path); @@ -139,15 +139,15 @@ describe("", () => { await userEvent.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(uploadServerFnMocks.deleteUpload).toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).toHaveBeenCalledWith({ data: { id: first.id }, }); - expect(uploadServerFnMocks.deleteUpload).toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).toHaveBeenCalledWith({ data: { id: second.id }, }); }); - expect(uploadServerFnMocks.deleteUpload).not.toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).not.toHaveBeenCalledWith({ data: { id: unselected.id }, }); }); diff --git a/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx b/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx index 29afd4ac1..84b6a3216 100644 --- a/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx +++ b/apps/web/src/uploads/components/__tests__/UploadItem.test.tsx @@ -43,13 +43,13 @@ describe("", () => { }); it("should have [props.onRemove] called when trash icon clicked", async () => { - uploadServerFnMocks.deleteUpload.mockResolvedValue(null); + uploadServerFnMocks.deleteUploadFn.mockResolvedValue(null); renderWithProviders(); await userEvent.click(screen.getByRole("button", { name: "remove" })); await waitFor(() => { - expect(uploadServerFnMocks.deleteUpload).toHaveBeenCalledWith({ + expect(uploadServerFnMocks.deleteUploadFn).toHaveBeenCalledWith({ data: { id: props.id }, }); }); diff --git a/apps/web/src/uploads/queries.ts b/apps/web/src/uploads/queries.ts index 8283960ce..99bf37b1e 100644 --- a/apps/web/src/uploads/queries.ts +++ b/apps/web/src/uploads/queries.ts @@ -1,4 +1,4 @@ -import { deleteUpload, findUploads } from "@server/uploads/functions"; +import { deleteUploadFn, findUploadsFn } from "@server/uploads/functions"; import { keepPreviousData, useInfiniteQuery, @@ -12,7 +12,8 @@ import type { FileResponse, UploadType } from "./types"; export function useListFiles(type: UploadType, page: number, per_page: number) { return useQuery({ queryKey: fileQueryKeys.list([type, page, per_page]), - queryFn: () => findUploads({ data: { upload_type: type, page, per_page } }), + queryFn: () => + findUploadsFn({ data: { upload_type: type, page, per_page } }), placeholderData: keepPreviousData, }); } @@ -21,7 +22,7 @@ export function useInfiniteFindFiles(type: UploadType, per_page: number) { return useInfiniteQuery({ queryKey: fileQueryKeys.infiniteList([type, per_page]), queryFn: ({ pageParam }) => - findUploads({ + findUploadsFn({ data: { upload_type: type, page: pageParam as number, per_page }, }), initialPageParam: 1, @@ -41,7 +42,7 @@ export function useInfiniteFindFiles(type: UploadType, per_page: number) { */ export function useDeleteFile() { return useMutation({ - mutationFn: ({ id }) => deleteUpload({ data: { id } }), + mutationFn: ({ id }) => deleteUploadFn({ data: { id } }), }); } @@ -59,7 +60,7 @@ export function useDeleteFiles() { // the first failure would let the list refetch while the rest are still // in flight, so files that did get deleted would linger in the list. const results = await Promise.allSettled( - ids.map((id) => deleteUpload({ data: { id } })), + ids.map((id) => deleteUploadFn({ data: { id } })), ); const failure = results.find((result) => result.status === "rejected"); diff --git a/apps/web/src/users/components/__tests__/UserGroups.test.tsx b/apps/web/src/users/components/__tests__/UserGroups.test.tsx index 6ac90eb28..c1ae8baf6 100644 --- a/apps/web/src/users/components/__tests__/UserGroups.test.tsx +++ b/apps/web/src/users/components/__tests__/UserGroups.test.tsx @@ -112,7 +112,7 @@ describe("", () => { await userEvent.click(screen.getByRole("option", { name: "bar" })); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1, 2] }, }), ); @@ -138,7 +138,7 @@ describe("", () => { await userEvent.keyboard("{ArrowDown}{Enter}"); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1, 2] }, }), ); @@ -163,7 +163,7 @@ describe("", () => { await userEvent.click(await screen.findByRole("option", { name: "bar" })); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1, 2] }, }), ); @@ -223,7 +223,7 @@ describe("", () => { await userEvent.click(await screen.findByRole("radio", { name: "bar" })); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, primary_group: 2 }, }), ); @@ -246,7 +246,7 @@ describe("", () => { ); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, primary_group: null }, }), ); @@ -269,7 +269,7 @@ describe("", () => { ); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [2], primary_group: null }, }), ); @@ -292,7 +292,7 @@ describe("", () => { ); await waitFor(() => - expect(userServerFnMocks.updateUser).toHaveBeenCalledWith({ + expect(userServerFnMocks.updateUserFn).toHaveBeenCalledWith({ data: { userId, groups: [1] }, }), ); diff --git a/apps/web/src/users/queries.ts b/apps/web/src/users/queries.ts index 26cb30979..09f5ba55b 100644 --- a/apps/web/src/users/queries.ts +++ b/apps/web/src/users/queries.ts @@ -1,12 +1,12 @@ import type { AdministratorRoleName } from "@administration/types"; import { - createUser, - findUsers, - getUser, - listUsers, - searchUsers, - setAdministratorRole, - updateUser, + createUserFn, + findUsersFn, + getUserFn, + listUsersFn, + searchUsersFn, + setAdministratorRoleFn, + updateUserFn, } from "@server/users/functions"; import { keepPreviousData, @@ -28,7 +28,7 @@ import type { UserNested } from "./types"; export function useListUsers() { return useQuery({ queryKey: userQueryKeys.nested(), - queryFn: () => listUsers(), + queryFn: () => listUsersFn(), }); } @@ -43,7 +43,7 @@ export function useInfiniteFindUsers(perPage: number, term: string) { return useInfiniteQuery({ queryKey: userQueryKeys.infiniteList([perPage, term]), queryFn: ({ pageParam }) => - searchUsers({ data: { page: pageParam as number, perPage, term } }), + searchUsersFn({ data: { page: pageParam as number, perPage, term } }), initialPageParam: 1, getNextPageParam: (lastPage) => { if (lastPage.page >= lastPage.pageCount) { @@ -74,7 +74,7 @@ export function usersQueryOptions( return queryOptions({ queryKey: userQueryKeys.list([page, perPage, term, administrator, active]), queryFn: () => - findUsers({ + findUsersFn({ data: { page, perPage, term, administrator, active }, }), }); @@ -109,7 +109,7 @@ export function useSuspenseUsers( export function useCreateUser() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { handle: string; @@ -118,7 +118,7 @@ export function useCreateUser() { } >({ mutationFn: ({ handle, password, forceReset }) => - createUser({ data: { handle, password, forceReset } }), + createUserFn({ data: { handle, password, forceReset } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.lists() }); }, @@ -133,7 +133,7 @@ export function useCreateUser() { export function userQueryOptions(userId: number) { return queryOptions({ queryKey: userQueryKeys.detail(userId), - queryFn: () => getUser({ data: { userId } }), + queryFn: () => getUserFn({ data: { userId } }), }); } @@ -168,12 +168,12 @@ export type UserUpdate = { export function useUpdateUser() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { userId: number; update: UserUpdate } >({ mutationFn: ({ userId, update }) => - updateUser({ data: { userId, ...update } }), + updateUserFn({ data: { userId, ...update } }), onSuccess: (result) => { if (result) { queryClient.setQueryData(userQueryKeys.detail(result.id), result); @@ -191,12 +191,12 @@ export function useUpdateUser() { export function useSetAdministratorRole() { const queryClient = useQueryClient(); return useMutation< - Awaited>, + Awaited>, Error, { role: AdministratorRoleName | null; user_id: number } >({ mutationFn: ({ role, user_id }) => - setAdministratorRole({ data: { userId: user_id, role } }), + setAdministratorRoleFn({ data: { userId: user_id, role } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all() }); }, diff --git a/docs/auth.md b/docs/auth.md index 938a62001..9fe3e0be2 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -177,7 +177,7 @@ may do*. That second question is answered by a policy, declared as middleware on the function itself, from `server/auth/policy.ts`: ```ts -export const deleteGroup = createServerFn({ method: "POST" }) +export const deleteGroupFn = createServerFn({ method: "POST" }) .middleware([adminRole("base")]) .validator(groupIdSchema) .handler(async ({ context, data }) => { ... }); @@ -210,7 +210,7 @@ A policy states the **floor**. A rule that depends on the row being touched cannot be expressed at the door — an administrator editing another administrator needs the `full` role, and that is only knowable after the target user is read. Those checks stay in the handler, after -the read, with `requireAdminRole`. `updateUser` is the worked example. +the read, with `requireAdminRole`. `updateUserFn` is the worked example. `data.ts` remains a pure persistence layer that assumes its caller has already been authorized — never put a role check there. @@ -396,8 +396,8 @@ zod rule. `checkConfiguredPasswordLength(db, password)` (`server/auth/service.ts`) is the single enforcement point. Every path that **sets** a password -calls it: `createFirstUserFn`, `resetPasswordFn`, `createUser`, and -`updateUser`. It reads the setting, applies the pure +calls it: `createFirstUserFn`, `resetPasswordFn`, `createUserFn`, and +`updateUserFn`. It reads the setting, applies the pure `checkPasswordLength` from `passwordPolicy.ts`, and throws `PasswordTooShortError`; each handler maps that to a 400 carrying the error's message. diff --git a/docs/queries.md b/docs/queries.md index c148265a3..d6818ef5b 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -370,7 +370,7 @@ Use the split deliberately: export function useUpdateSample(sampleId: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ update }) => updateSample(sampleId, update), + mutationFn: ({ update }) => updateSampleFn(sampleId, update), onSuccess: () => { queryClient.invalidateQueries({ queryKey: samplesQueryKeys.detail(sampleId), diff --git a/docs/server-push.md b/docs/server-push.md index 4c479b513..54d2ab24d 100644 --- a/docs/server-push.md +++ b/docs/server-push.md @@ -187,7 +187,7 @@ application's own doing. Jobs are the only domain that emits an update frame per running job per progress wave, and every job on screen holds its own `detail(id)` query (see the nested-job sites below). Invalidating each frame's detail -therefore cost one `getJob` request per running job per tick — 25 rows, +therefore cost one `getJobFn` request per running job per tick — 25 rows, 25 requests. So `jobs`/`update` frames do not go through `selectQueryKey`. They go to @@ -200,12 +200,12 @@ a queue built by `createJobRefreshQueue` (`jobs/refresh.ts`), one per drift no matter how many details are refreshed. This is a no-op on pages that cache no jobs list. 3. Drops ids whose `detail(id)` has no **active** observer, then reads - the rest through the `getJobs` server function — one request per - wave, chunked at the 100-id cap `getJobs` validates. This is what + the rest through the `getJobsFn` server function — one request per + wave, chunked at the 100-id cap `getJobsFn` validates. This is what keeps the jobs list page, whose rows render from the list query, from fetching a detail per row. -4. Writes each result straight into its `detail(id)` cache. `getJobs` - returns the full `Job`, the same shape `getJob` returns, so +4. Writes each result straight into its `detail(id)` cache. `getJobsFn` + returns the full `Job`, the same shape `getJobFn` returns, so `detail(id)` stays one canonical shape and `JobDetail` — which renders `args` and `steps` — reads the same entry as a sample row rendering only `progress`. @@ -250,7 +250,7 @@ undo by accident: seeded with the nested object, so a `jobs` update frame refreshes `jobQueryKeys.detail(id)`. The round-trip fan-out that used to cost is gone — see "Job updates are batched" above — but the payload is - unchanged: `getJobs` returns the full `Job` (args, claim, steps) for + unchanged: `getJobsFn` returns the full `Job` (args, claim, steps) for every row, where a nested view renders two fields. Trimming it means a second cache shape for `detail(id)`, which `JobDetail` also reads, so it needs its own key rather than a narrower read. Not worth it @@ -261,7 +261,7 @@ undo by accident: `hmm/components/HmmInstall.tsx`) now keep live task `progress`/`step` fresh by mounting `useFetchTask(id)` seeded with the nested task, so a `tasks` update frame invalidates `taskQueryKeys.detail(id)` and - refetches through the `getTask` server function. `hmm` itself is not + refetches through the `getTaskFn` server function. `hmm` itself is not an `SseDomain` and has no `/hmms` refetch trigger; `HmmInstall` bridges that gap by watching the live task's `complete` and invalidating `hmmQueryKeys.lists()` from a `useEffect`. If HMM gains diff --git a/docs/testing.md b/docs/testing.md index d41e68ea8..7b4d8744f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -78,7 +78,7 @@ const handlers = (await import( )) as SplitServerFnModule; await expect( - callServerFn(handlers, "deleteGroup", { groupId }), + callServerFn(handlers, "deleteGroupFn", { groupId }), ).rejects.toBeInstanceOf(ForbiddenError); ``` @@ -268,7 +268,7 @@ exists to prevent — `mockApiGetAccount` never touched HTTP. Each `server-fn/` file mirrors the server module it mocks, not the client feature, because one file maps to one `vi.mock` target. So the -account mocks live in `server-fn/users.ts` — `getAccount` is exported +account mocks live in `server-fn/users.ts` — `getAccountFn` is exported from `@server/users/functions` — and there is no `server-fn/account.ts`. ### Asserting a server function was called