From 7058dc5c1c24fe39ba0d085f1007f216630d8c24 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Thu, 11 Jun 2026 21:08:24 -0500 Subject: [PATCH] preserve large numeric job args Job args can contain database identifiers that exceed JavaScript's safe integer range. Returning args as nested JSON lets `response.json()` round numeric literals before River UI renders or copies them. Return args as JSON text from the standard and Pro serializers and map the field to `argsRaw` in the frontend. Parse the text into a lossless view model that preserves numeric tokens, sorts object keys, keeps collapsible detail views, and bounds work for pathological payloads. Skip formatting list args when they are hidden. Cover the wire contract, browser fetch mapping, JSON grammar and safety limits, sorted display and copy behavior, and list/detail rendering. Closes #593 --- CHANGELOG.md | 4 + handler_api_endpoint.go | 30 +- handler_api_endpoint_test.go | 35 +- .../prohandler/pro_handler_api_endpoints.go | 30 +- .../pro_handler_api_endpoints_test.go | 22 + src/components/JSONTextView.test.tsx | 117 +++++ src/components/JSONTextView.tsx | 39 ++ src/components/JSONView.tsx | 26 +- src/components/JobDetail.test.tsx | 11 + src/components/JobDetail.tsx | 3 +- src/components/JobList.test.tsx | 35 +- src/components/JobList.tsx | 15 +- src/components/WorkflowDetail.test.tsx | 18 + src/components/WorkflowDetail.tsx | 3 +- src/services/jobs.test.ts | 96 ++++ src/services/jobs.ts | 17 +- src/services/workflows.test.ts | 62 +++ src/test/factories/job.ts | 2 +- src/test/factories/workflowJob.ts | 2 + src/utils/jsonText.test.ts | 73 +++ src/utils/jsonText.ts | 427 ++++++++++++++++++ 21 files changed, 1009 insertions(+), 58 deletions(-) create mode 100644 src/components/JSONTextView.test.tsx create mode 100644 src/components/JSONTextView.tsx create mode 100644 src/services/jobs.test.ts create mode 100644 src/utils/jsonText.test.ts create mode 100644 src/utils/jsonText.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ec6c32..09ad5718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Job args: preserve large numeric JSON values exactly when displaying and copying args, while keeping object keys sorted. [Fixes #593](https://github.com/riverqueue/riverui/issues/593). [PR #594](https://github.com/riverqueue/riverui/pull/594). + ## [v0.17.0] - 2026-07-31 ### Added diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 752a8d9c..1ded8ce3 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -977,20 +977,20 @@ type PartitionConfig struct { } type RiverJobMinimal struct { - ID int64 `json:"id"` - Args json.RawMessage `json:"args"` - Attempt int `json:"attempt"` - AttemptedAt *time.Time `json:"attempted_at"` - AttemptedBy []string `json:"attempted_by"` - CreatedAt time.Time `json:"created_at"` - FinalizedAt *time.Time `json:"finalized_at"` - Kind string `json:"kind"` - MaxAttempts int `json:"max_attempts"` - Priority int `json:"priority"` - Queue string `json:"queue"` - ScheduledAt time.Time `json:"scheduled_at"` - State string `json:"state"` - Tags []string `json:"tags"` + ID int64 `json:"id"` + Args string `json:"args"` + Attempt int `json:"attempt"` + AttemptedAt *time.Time `json:"attempted_at"` + AttemptedBy []string `json:"attempted_by"` + CreatedAt time.Time `json:"created_at"` + FinalizedAt *time.Time `json:"finalized_at"` + Kind string `json:"kind"` + MaxAttempts int `json:"max_attempts"` + Priority int `json:"priority"` + Queue string `json:"queue"` + ScheduledAt time.Time `json:"scheduled_at"` + State string `json:"state"` + Tags []string `json:"tags"` } type RiverJob struct { @@ -1022,7 +1022,7 @@ func riverJobToSerializableJobMinimal(riverJob *rivertype.JobRow) *RiverJobMinim return &RiverJobMinimal{ ID: riverJob.ID, - Args: riverJob.EncodedArgs, + Args: string(riverJob.EncodedArgs), Attempt: riverJob.Attempt, AttemptedAt: riverJob.AttemptedAt, AttemptedBy: attemptedBy, diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index 87ae9693..fd8e6450 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -2,6 +2,7 @@ package riverui import ( "context" + "encoding/json" "log/slog" "net/http" "testing" @@ -514,11 +515,25 @@ func TestAPIHandlerJobGet(t *testing.T) { endpoint, bundle := setupEndpoint(ctx, t, newJobGetEndpoint) - job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{}) + encodedArgs := []byte(`{"id":1970670598291982290,"max":9223372036854775807}`) + job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + EncodedArgs: encodedArgs, + }) resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobGetRequest{JobID: job.ID}) require.NoError(t, err) require.Equal(t, job.ID, resp.ID) + expectedArgs := string(job.EncodedArgs) + require.Equal(t, expectedArgs, resp.Args) + require.Contains(t, resp.Args, "1970670598291982290") + require.Contains(t, resp.Args, "9223372036854775807") + + var wireResp struct { + Args string `json:"args"` + } + require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp)) + require.Equal(t, expectedArgs, wireResp.Args) + require.True(t, json.Valid([]byte(wireResp.Args))) }) t.Run("NotFound", func(t *testing.T) { @@ -542,9 +557,10 @@ func TestAPIHandlerJobList(t *testing.T) { endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint) job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ - Kind: ptrutil.Ptr("kind1"), - Queue: ptrutil.Ptr("queue1"), - State: ptrutil.Ptr(rivertype.JobStateRunning), + EncodedArgs: []byte(`{"id":1970670598291982290}`), + Kind: ptrutil.Ptr("kind1"), + Queue: ptrutil.Ptr("queue1"), + State: ptrutil.Ptr(rivertype.JobStateRunning), }) job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ Kind: ptrutil.Ptr("kind2"), @@ -556,7 +572,18 @@ func TestAPIHandlerJobList(t *testing.T) { require.NoError(t, err) require.Len(t, resp.Data, 2) require.Equal(t, job1.ID, resp.Data[0].ID) + expectedArgs := string(job1.EncodedArgs) + require.Equal(t, expectedArgs, resp.Data[0].Args) + require.Contains(t, resp.Data[0].Args, "1970670598291982290") require.Equal(t, job2.ID, resp.Data[1].ID) + + var wireResp struct { + Data []struct { + Args string `json:"args"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp)) + require.Equal(t, expectedArgs, wireResp.Data[0].Args) }) t.Run("FilterByIDs", func(t *testing.T) { diff --git a/riverproui/internal/prohandler/pro_handler_api_endpoints.go b/riverproui/internal/prohandler/pro_handler_api_endpoints.go index fcaac882..19c76811 100644 --- a/riverproui/internal/prohandler/pro_handler_api_endpoints.go +++ b/riverproui/internal/prohandler/pro_handler_api_endpoints.go @@ -828,20 +828,20 @@ func (a *workflowRetryEndpoint[TTx]) Execute(ctx context.Context, req *workflowR } type riverJobMinimal struct { - ID int64 `json:"id"` - Args json.RawMessage `json:"args"` - Attempt int `json:"attempt"` - AttemptedAt *time.Time `json:"attempted_at"` - AttemptedBy []string `json:"attempted_by"` - CreatedAt time.Time `json:"created_at"` - FinalizedAt *time.Time `json:"finalized_at"` - Kind string `json:"kind"` - MaxAttempts int `json:"max_attempts"` - Priority int `json:"priority"` - Queue string `json:"queue"` - ScheduledAt time.Time `json:"scheduled_at"` - State string `json:"state"` - Tags []string `json:"tags"` + ID int64 `json:"id"` + Args string `json:"args"` + Attempt int `json:"attempt"` + AttemptedAt *time.Time `json:"attempted_at"` + AttemptedBy []string `json:"attempted_by"` + CreatedAt time.Time `json:"created_at"` + FinalizedAt *time.Time `json:"finalized_at"` + Kind string `json:"kind"` + MaxAttempts int `json:"max_attempts"` + Priority int `json:"priority"` + Queue string `json:"queue"` + ScheduledAt time.Time `json:"scheduled_at"` + State string `json:"state"` + Tags []string `json:"tags"` } func internalJobToJobMinimal(internal *rivertype.JobRow) *riverJobMinimal { @@ -852,7 +852,7 @@ func internalJobToJobMinimal(internal *rivertype.JobRow) *riverJobMinimal { return &riverJobMinimal{ ID: internal.ID, - Args: internal.EncodedArgs, + Args: string(internal.EncodedArgs), Attempt: internal.Attempt, AttemptedAt: internal.AttemptedAt, AttemptedBy: attemptedBy, diff --git a/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go b/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go index 97cd0402..b7f13be9 100644 --- a/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go +++ b/riverproui/internal/prohandler/pro_handler_api_endpoints_test.go @@ -177,6 +177,7 @@ func TestProAPIHandlerWorkflowGet(t *testing.T) { } dependencyJob := jobWithSchema(ctx, t, bundle.exec, bundle.schema, &testfactory.JobOpts{ + EncodedArgs: []byte(`{"id":1970670598291982290,"max":9223372036854775807}`), FinalizedAt: ptrutil.Ptr(now.Add(-2 * time.Minute)), Metadata: workflowMetadata("wf_get", "collect_inputs", nil), State: ptrutil.Ptr(rivertype.JobStateCompleted), @@ -207,6 +208,27 @@ func TestProAPIHandlerWorkflowGet(t *testing.T) { require.Equal(t, workflowTaskWaitReasonNone, taskByID[dependencyJob.ID].WaitReason) require.Nil(t, taskByID[dependencyJob.ID].Wait) + expectedArgs := string(dependencyJob.EncodedArgs) + require.Equal(t, expectedArgs, taskByID[dependencyJob.ID].Args) + require.Contains(t, taskByID[dependencyJob.ID].Args, "1970670598291982290") + require.Contains(t, taskByID[dependencyJob.ID].Args, "9223372036854775807") + + var wireResp struct { + Tasks []struct { + Args string `json:"args"` + ID int64 `json:"id"` + } `json:"tasks"` + } + require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp)) + var dependencyArgs string + for _, task := range wireResp.Tasks { + if task.ID == dependencyJob.ID { + dependencyArgs = task.Args + break + } + } + require.Equal(t, expectedArgs, dependencyArgs) + require.True(t, json.Valid([]byte(dependencyArgs))) waitingTask := taskByID[waitingJob.ID] require.NotNil(t, waitingTask) diff --git a/src/components/JSONTextView.test.tsx b/src/components/JSONTextView.test.tsx new file mode 100644 index 00000000..f5d13ace --- /dev/null +++ b/src/components/JSONTextView.test.tsx @@ -0,0 +1,117 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import toast from "react-hot-toast"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import JSONTextView from "./JSONTextView"; + +Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockImplementation(() => Promise.resolve()), + }, +}); + +vi.mock("react-hot-toast", () => ({ + default: { + custom: vi.fn(), + }, +})); + +describe("JSONTextView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders and copies sorted JSON without rounding large numbers", async () => { + const rawJSON = '{"z":2,"id":1970670598291982290,"a":1}'; + const formattedJSON = `{ + "a": 1, + "id": 1970670598291982290, + "z": 2 +}`; + + render(); + + expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByTestId("text-copy-button")); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(formattedJSON); + + await waitFor(() => { + expect(toast.custom).toHaveBeenCalled(); + }); + }); + + it("keeps nested args collapsible while copying the complete value", async () => { + const rawJSON = + '{"z":2,"outer":{"nested":{"id":1970670598291982290}},"a":1}'; + + render(); + + expect(screen.queryByText("1970670598291982290")).not.toBeInTheDocument(); + + const outerButton = screen + .getAllByRole("button") + .find((button) => button.textContent?.includes('"outer"')); + expect(outerButton).toBeDefined(); + fireEvent.click(outerButton!); + + const nestedButton = screen + .getAllByRole("button") + .find((button) => button.textContent?.includes('"nested"')); + expect(nestedButton).toBeDefined(); + fireEvent.click(nestedButton!); + + expect(screen.getByText("1970670598291982290")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByTestId("text-copy-button")); + }); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`{ + "a": 1, + "outer": { + "nested": { + "id": 1970670598291982290 + } + }, + "z": 2 +}`); + }); + + it("uses the same sorted order for displayed and copied integer keys", async () => { + render(); + + const twoKey = screen.getByText('"2"'); + const tenKey = screen.getByText('"10"'); + expect( + twoKey.compareDocumentPosition(tenKey) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + await act(async () => { + fireEvent.click(screen.getByTestId("text-copy-button")); + }); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`{ + "2": "two", + "10": "ten", + "a": 0 +}`); + }); + + it("renders malformed or excessively nested args as literal text", () => { + const deeplyNested = `${"[".repeat(5_000)}0${"]".repeat(5_000)}`; + const { rerender } = render(); + + expect(screen.getByText("{not valid")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(deeplyNested)).toBeInTheDocument(); + }); +}); diff --git a/src/components/JSONTextView.tsx b/src/components/JSONTextView.tsx new file mode 100644 index 00000000..46361444 --- /dev/null +++ b/src/components/JSONTextView.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; + +import JSONView from "@/components/JSONView"; +import PlaintextPanel from "@/components/PlaintextPanel"; +import { prepareJSONText } from "@/utils/jsonText"; + +type JSONTextViewProps = { + className?: string; + copyTitle?: string; + text: string; +}; + +export default function JSONTextView({ + className, + copyTitle = "JSON", + text, +}: JSONTextViewProps) { + const prepared = useMemo(() => prepareJSONText(text), [text]); + + if (prepared) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/JSONView.tsx b/src/components/JSONView.tsx index e66ebfcd..dff10ec0 100644 --- a/src/components/JSONView.tsx +++ b/src/components/JSONView.tsx @@ -7,6 +7,7 @@ import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import React from "react"; import CopyablePanel from "@/components/CopyablePanel"; +import { isJSONTextNumber } from "@/utils/jsonText"; interface JSONNodeRendererProps { data: unknown; @@ -22,6 +23,10 @@ interface JSONViewProps { * Additional class names to apply to the component. */ className?: string; + /** + * Preformatted JSON to copy instead of serializing data. + */ + copyText?: string; /** * The title to show in the copy confirmation toast. * @default "JSON" @@ -40,6 +45,7 @@ interface JSONViewProps { */ export default function JSONView({ className, + copyText, copyTitle = "JSON", data, defaultExpandDepth = 1, @@ -62,7 +68,7 @@ export default function JSONView({ return (
@@ -81,6 +87,10 @@ function isPlainObject(value: unknown): value is Record { return prototype === null || prototype === Object.prototype; } +function isPrimitiveValue(value: unknown): boolean { + return isJSONTextNumber(value) || typeof value !== "object" || value === null; +} + function JSONNodeRenderer({ data, defaultExpandDepth, @@ -171,7 +181,7 @@ function JSONNodeRenderer({ const color = styleConfig.json.key; // For primitive values, render key and value inline - if (propKey && (typeof data !== "object" || data === null)) { + if (propKey && isPrimitiveValue(data)) { return ( + {data.raw} + {maybeComma(isLastItemInParent)} + + ); + } if (data === null) { return ( @@ -592,6 +610,10 @@ function sortObjectKeysInternal( value: unknown, sortedValues: WeakMap, ): unknown { + if (isJSONTextNumber(value)) { + return value; + } + if (Array.isArray(value)) { const cachedArray = sortedValues.get(value); if (cachedArray) { diff --git a/src/components/JobDetail.test.tsx b/src/components/JobDetail.test.tsx index 9e1aa95b..af1c14b5 100644 --- a/src/components/JobDetail.test.tsx +++ b/src/components/JobDetail.test.tsx @@ -73,3 +73,14 @@ test("cancels job delete confirmation", async () => { ).not.toBeInTheDocument(); }); }); + +test("renders raw job args without rounding large numbers", () => { + const argsRaw = '{"id":1970670598291982290}'; + const job = jobFactory.completed().build({ argsRaw }); + + render( + , + ); + + expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument(); +}); diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 693abc4f..3040348e 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -3,6 +3,7 @@ import ButtonForGroup from "@components/ButtonForGroup"; import ConfirmationDialog from "@components/ConfirmationDialog"; import JobAttempts from "@components/JobAttempts"; import JobTimeline from "@components/JobTimeline"; +import JSONTextView from "@components/JSONTextView"; import JSONView from "@components/JSONView"; import RelativeTimeFormatter from "@components/RelativeTimeFormatter"; import TopNavTitleOnly from "@components/TopNavTitleOnly"; @@ -164,7 +165,7 @@ export default function JobDetail({ Args
- +
diff --git a/src/components/JobList.test.tsx b/src/components/JobList.test.tsx index d8e202ce..56679cbe 100644 --- a/src/components/JobList.test.tsx +++ b/src/components/JobList.test.tsx @@ -19,7 +19,8 @@ import JobList from "./JobList"; type UseSettings = typeof import("@hooks/use-settings").useSettings; type UseSettingsReturn = ReturnType; -const { mockUseSettings } = vi.hoisted(() => ({ +const { mockCompactJSONText, mockUseSettings } = vi.hoisted(() => ({ + mockCompactJSONText: vi.fn(), mockUseSettings: vi.fn() as MockedFunction, })); @@ -55,13 +56,28 @@ vi.mock("@hooks/use-settings", () => ({ useSettings: mockUseSettings, })); +vi.mock("@utils/jsonText", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + compactJSONText: (text: string) => { + mockCompactJSONText(text); + return actual.compactJSONText(text); + }, + }; +}); + describe("JobList", () => { beforeEach(() => { + mockCompactJSONText.mockReset(); mockUseSettings.mockReset(); }); it("shows job args by default", () => { - const job = jobMinimalFactory.build(); + const job = jobMinimalFactory.build({ + argsRaw: '{"z":2,"id":1970670598291982290,"a":1}', + }); const features = createFeatures({ jobListHideArgsByDefault: false, }); @@ -87,7 +103,9 @@ describe("JobList", () => { , ); - expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument(); + expect( + screen.getByText('{"a":1,"id":1970670598291982290,"z":2}'), + ).toBeInTheDocument(); }); it("hides job args when jobListHideArgsByDefault is true", () => { @@ -117,9 +135,8 @@ describe("JobList", () => { , ); - expect( - screen.queryByText(JSON.stringify(job.args)), - ).not.toBeInTheDocument(); + expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument(); + expect(mockCompactJSONText).not.toHaveBeenCalled(); }); it("shows job args when user overrides default hide setting", () => { @@ -150,7 +167,7 @@ describe("JobList", () => { ); // Even though server default is to hide, user setting should make them visible - expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument(); + expect(screen.getByText(job.argsRaw)).toBeInTheDocument(); }); it("hides job args when user overrides default show setting", () => { @@ -181,9 +198,7 @@ describe("JobList", () => { ); // Even though server default is to show, user setting should hide them - expect( - screen.queryByText(JSON.stringify(job.args)), - ).not.toBeInTheDocument(); + expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument(); }); it("requires confirmation before deleting selected jobs", async () => { diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx index 820004fd..8a7ab4f9 100644 --- a/src/components/JobList.tsx +++ b/src/components/JobList.tsx @@ -29,6 +29,7 @@ import { JobStateFilterItem, jobStateFilterItems, } from "@utils/jobStateFilterItems"; +import { compactJSONText } from "@utils/jsonText"; import { classNames } from "@utils/style"; import React, { FormEvent, @@ -82,6 +83,14 @@ type JobListItemProps = { ) => void; }; +const JobArgsPreview = ({ argsRaw }: { argsRaw: string }) => { + const argsPreview = useMemo(() => compactJSONText(argsRaw), [argsRaw]); + + return ( +

{argsPreview}

+ ); +}; + const JobListItem = ({ checked, hideArgs, @@ -132,11 +141,7 @@ const JobListItem = ({ - {showArgs && ( -

- {JSON.stringify(job.args)} -

- )} + {showArgs && } {job.queue} diff --git a/src/components/WorkflowDetail.test.tsx b/src/components/WorkflowDetail.test.tsx index 3b59f709..7d6d4d32 100644 --- a/src/components/WorkflowDetail.test.tsx +++ b/src/components/WorkflowDetail.test.tsx @@ -218,6 +218,24 @@ describe("WorkflowDetail wait inspector", () => { expect(screen.getByText("Not waiting")).toBeInTheDocument(); }); + it("renders selected task args without rounding large numbers", async () => { + const argsRaw = '{"id":1970670598291982290}'; + const task = workflowJobFactory.build({ + argsRaw, + id: 1, + state: JobState.Completed, + task: "send_response", + waitReason: "none", + }); + + await renderWorkflowDetail( + { id: "wf-test-args", name: "Workflow Test", tasks: [task] }, + task.id, + ); + + expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument(); + }); + it("updates the lower inspector when the selected task changes", async () => { const firstTask = workflowJobFactory.build({ id: 1, diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx index 210b185e..3dfeaf72 100644 --- a/src/components/WorkflowDetail.tsx +++ b/src/components/WorkflowDetail.tsx @@ -2,6 +2,7 @@ import ButtonForGroup from "@components/ButtonForGroup"; import { DurationCompact } from "@components/DurationCompact"; import { Subheading } from "@components/Heading"; import { RunningSpinnerIcon } from "@components/icons/jobStateIcons"; +import JSONTextView from "@components/JSONTextView"; import JSONView from "@components/JSONView"; import RelativeTimeFormatter from "@components/RelativeTimeFormatter"; import RetryWorkflowDialog from "@components/RetryWorkflowDialog"; @@ -335,7 +336,7 @@ const SelectedJobDetails = ({
Args - +
diff --git a/src/services/jobs.test.ts b/src/services/jobs.test.ts new file mode 100644 index 00000000..e57f0887 --- /dev/null +++ b/src/services/jobs.test.ts @@ -0,0 +1,96 @@ +import { getJob, getJobKey, listJobs, listJobsKey } from "@services/jobs"; +import { JobState } from "@services/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +type APIAttemptErrorForTest = { + at: string; + attempt: number; + error: string; + trace: string; +}; + +type APIJobForTest = { + args: string; + attempt: number; + attempted_by: string[]; + created_at: string; + errors: APIAttemptErrorForTest[]; + finalized_at: undefined; + id: number; + kind: string; + max_attempts: number; + metadata: object; + priority: number; + queue: string; + scheduled_at: string; + state: JobState; + tags: string[]; +}; + +const apiJob = (overrides: Partial = {}): APIJobForTest => ({ + args: '{"id":1970670598291982290}', + attempt: 0, + attempted_by: [], + created_at: "2026-04-21T17:57:00Z", + errors: [], + finalized_at: undefined, + id: 123, + kind: "RowOperation", + max_attempts: 25, + metadata: {}, + priority: 1, + queue: "default", + scheduled_at: "2026-04-21T17:57:00Z", + state: JobState.Available, + tags: [], + ...overrides, +}); + +describe("jobs service", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("preserves list job args as raw JSON text", async () => { + document.body.innerHTML = + ''; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [apiJob()] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + + const jobs = await listJobs({ + client: undefined as never, + meta: undefined, + queryKey: listJobsKey({ limit: 10 }), + signal: new AbortController().signal, + }); + + expect(jobs[0]?.argsRaw).toBe('{"id":1970670598291982290}'); + }); + + it("preserves job detail args as raw JSON text", async () => { + document.body.innerHTML = + ''; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(apiJob()), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + + const job = await getJob({ + client: undefined as never, + meta: undefined, + queryKey: getJobKey(123n), + signal: new AbortController().signal, + }); + + expect(job.argsRaw).toBe('{"id":1970670598291982290}'); + }); +}); diff --git a/src/services/jobs.ts b/src/services/jobs.ts index caddc780..4f4ff9aa 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -17,13 +17,15 @@ export type AttemptError = { }; export type Job = { - [Key in keyof JobFromAPI as SnakeToCamelCase]: Key extends + [Key in keyof Omit as SnakeToCamelCase]: Key extends | StringEndingWithUnderscoreAt | undefined ? Date : JobFromAPI[Key] extends AttemptErrorFromAPI[] ? AttemptError[] : JobFromAPI[Key]; +} & { + argsRaw: string; }; export type JobFromAPI = { @@ -43,18 +45,25 @@ export type JobLogs = { }; export type JobMinimal = { - [Key in keyof JobMinimalFromAPI as SnakeToCamelCase]: Key extends + [Key in keyof Omit< + JobMinimalFromAPI, + "args" + > as SnakeToCamelCase]: Key extends | StringEndingWithUnderscoreAt | undefined ? Date : JobMinimalFromAPI[Key]; +} & { + argsRaw: string; }; // Represents a Job as received from the API. This just like Job, except with // string dates instead of Date objects and keys as snake_case instead of // camelCase. export type JobMinimalFromAPI = { - args: object; + // JSON text as returned by River. Keep this unparsed to preserve large + // integer values exactly for display and copy. + args: string; attempt: number; attempted_at?: string; attempted_by: string[]; @@ -100,7 +109,7 @@ type RiverJobLogEntry = { export const apiJobMinimalToJobMinimal = ( job: JobMinimalFromAPI, ): JobMinimal => ({ - args: job.args, + argsRaw: job.args, attempt: job.attempt, attemptedAt: job.attempted_at ? new Date(job.attempted_at) : undefined, attemptedBy: job.attempted_by, diff --git a/src/services/workflows.test.ts b/src/services/workflows.test.ts index 9844c582..4dd1d3bf 100644 --- a/src/services/workflows.test.ts +++ b/src/services/workflows.test.ts @@ -1,4 +1,7 @@ +import { JobState } from "@services/types"; import { + getWorkflow, + getWorkflowKey, getWorkflowTaskSignals, getWorkflowTaskWaitDiagnostics, } from "@services/workflows"; @@ -10,6 +13,65 @@ describe("workflows service", () => { document.body.innerHTML = ""; }); + it("preserves workflow task args as raw JSON text", async () => { + document.body.innerHTML = + ''; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + id: "wf-args", + name: "Workflow Args", + tasks: [ + { + args: '{"id":1970670598291982290}', + attempt: 0, + attempted_by: [], + created_at: "2026-04-21T17:57:00Z", + deps: [], + errors: [], + id: 123, + ignore_cancelled_deps: false, + ignore_deleted_deps: false, + ignore_discarded_deps: false, + kind: "RowOperation", + max_attempts: 25, + metadata: { + deps: [], + task: "row_operation", + workflow_id: "wf-args", + workflow_staged_at: "2026-04-21T17:57:00Z", + }, + name: "row_operation", + priority: 1, + queue: "default", + scheduled_at: "2026-04-21T17:57:00Z", + state: JobState.Available, + tags: [], + wait_reason: "none", + workflow_id: "wf-args", + }, + ], + }), + { + headers: { "Content-Type": "application/json" }, + status: 200, + }, + ), + ); + + const workflow = await getWorkflow({ + client: undefined as never, + direction: "forward", + meta: undefined, + pageParam: undefined, + queryKey: getWorkflowKey("wf-args"), + signal: new AbortController().signal, + }); + + expect(workflow.tasks[0]?.argsRaw).toBe('{"id":1970670598291982290}'); + }); + it("parses task signal dates, ids, cursor ids, evidence, and scope", async () => { document.body.innerHTML = ''; diff --git a/src/test/factories/job.ts b/src/test/factories/job.ts index efd797a6..c40e51b5 100644 --- a/src/test/factories/job.ts +++ b/src/test/factories/job.ts @@ -266,7 +266,7 @@ export const jobFactory = JobFactory.define(({ sequence }) => { const createdAt = faker.date.recent({ days: 0.001 }); return { - args: { baz: 1, foo: "bar" }, + argsRaw: '{"baz":1,"foo":"bar"}', attempt: 0, attemptedAt: undefined, attemptedBy: [], diff --git a/src/test/factories/workflowJob.ts b/src/test/factories/workflowJob.ts index 0f8ce16e..d5b59bb1 100644 --- a/src/test/factories/workflowJob.ts +++ b/src/test/factories/workflowJob.ts @@ -13,6 +13,7 @@ const defaultWorkflowStagedAt = new Date("2025-01-01T00:00:00.000Z"); const defaultWorkflowID = "wf-1"; type WorkflowJobFactoryParams = { + argsRaw?: string; attemptedAt?: Date; createdAt?: Date; deps?: string[]; @@ -81,6 +82,7 @@ export const workflowJobFactory = Factory.define< const baseJob = jobFactory.build({ ...(attemptedAt ? { attemptedAt } : {}), + ...(params.argsRaw ? { argsRaw: params.argsRaw } : {}), createdAt, ...(finalizedAt ? { finalizedAt } : {}), id, diff --git a/src/utils/jsonText.test.ts b/src/utils/jsonText.test.ts new file mode 100644 index 00000000..069d4563 --- /dev/null +++ b/src/utils/jsonText.test.ts @@ -0,0 +1,73 @@ +import { + compactJSONText, + formatJSONText, + prepareJSONText, +} from "@utils/jsonText"; +import { describe, expect, it } from "vitest"; + +describe("jsonText", () => { + it("sorts object keys without parsing large number tokens", () => { + const rawJSON = + '{"z":2,"id":1970670598291982290,"nested":{"b":9223372036854775807,"a":1}}'; + + expect(compactJSONText(rawJSON)).toBe( + '{"id":1970670598291982290,"nested":{"a":1,"b":9223372036854775807},"z":2}', + ); + }); + + it("pretty-prints sorted JSON without rounding numbers", () => { + const rawJSON = '{"z":2,"id":1970670598291982290,"a":1}'; + + expect(formatJSONText(rawJSON)).toBe(`{ + "a": 1, + "id": 1970670598291982290, + "z": 2 +}`); + }); + + it("falls back to original text when args are not valid JSON", () => { + expect(formatJSONText("{not valid")).toBe("{not valid"); + }); + + it("handles arrays, literals, strings, and every JSON number form", () => { + const rawJSON = + '[true,false,null,-0,-12,0.25,1.25e+30,"line\\n\\u263a",{},[]]'; + + expect(compactJSONText(rawJSON)).toBe( + '[true,false,null,-0,-12,0.25,1.25e+30,"line\\n☺",{},[]]', + ); + expect(JSON.parse(formatJSONText(rawJSON))).toEqual(JSON.parse(rawJSON)); + }); + + it("sorts nested objects and preserves escaped keys", () => { + const rawJSON = String.raw`{"z":0,"a\"key":"value","array":[{"b":2,"a":1}]}`; + + expect(compactJSONText(rawJSON)).toBe( + String.raw`{"a\"key":"value","array":[{"a":1,"b":2}],"z":0}`, + ); + }); + + it.each(["", "[1,]", '{"a":}', "01", '"unterminated', "true false"])( + "returns malformed input unchanged: %j", + (rawJSON) => { + expect(compactJSONText(rawJSON)).toBe(rawJSON); + expect(formatJSONText(rawJSON)).toBe(rawJSON); + expect(prepareJSONText(rawJSON)).toBeUndefined(); + }, + ); + + it("falls back safely when valid JSON exceeds the nesting limit", () => { + const rawJSON = `${"[".repeat(5_000)}0${"]".repeat(5_000)}`; + + expect(compactJSONText(rawJSON)).toBe(rawJSON); + expect(formatJSONText(rawJSON)).toBe(rawJSON); + expect(prepareJSONText(rawJSON)).toBeUndefined(); + }); + + it("uses compact sorted output when indentation expands excessively", () => { + const rawJSON = `${"[".repeat(99)}{"z":2,"a":1}${"]".repeat(99)}`; + const expected = `${"[".repeat(99)}{"a":1,"z":2}${"]".repeat(99)}`; + + expect(formatJSONText(rawJSON)).toBe(expected); + }); +}); diff --git a/src/utils/jsonText.ts b/src/utils/jsonText.ts new file mode 100644 index 00000000..bdfeb51b --- /dev/null +++ b/src/utils/jsonText.ts @@ -0,0 +1,427 @@ +const JSON_TEXT_NUMBER = Symbol("JSONTextNumber"); +const MAX_JSON_NESTING_DEPTH = 100; +const MAX_PRETTY_PRINT_LENGTH = 1_048_576; +const MAX_PRETTY_PRINT_EXTRA_LENGTH = 16_384; +const MAX_PRETTY_PRINT_EXPANSION = 4; + +export type JSONTextNumber = { + readonly [JSON_TEXT_NUMBER]: true; + readonly raw: string; +}; + +export type JSONTextValue = + | { [key: string]: JSONTextValue } + | boolean + | JSONTextNumber + | JSONTextValue[] + | null + | string; + +export type PreparedJSONText = { + copyText: string; + value: JSONTextValue; +}; + +type JSONTextNode = + | { entries: JSONTextObjectEntry[]; kind: "object" } + | { kind: "array"; values: JSONTextNode[] } + | { kind: "literal"; value: "false" | "null" | "true" } + | { kind: "number"; raw: string } + | { kind: "string"; value: string }; + +type JSONTextObjectEntry = { + key: string; + value: JSONTextNode; +}; + +class BoundedStringBuilder { + private length = 0; + private readonly parts: string[] = []; + + constructor(private readonly maxLength: number) {} + + append(value: string): boolean { + if (this.length + value.length > this.maxLength) return false; + + this.length += value.length; + this.parts.push(value); + return true; + } + + toString(): string { + return this.parts.join(""); + } +} + +class JSONTextParser { + private position = 0; + + constructor(private readonly text: string) {} + + parse(): JSONTextNode | undefined { + try { + const value = this.parseValue(); + this.skipWhitespace(); + return this.position === this.text.length ? value : undefined; + } catch { + return undefined; + } + } + + private expect(char: string) { + if (this.text[this.position] !== char) { + throw new Error(`expected ${char}`); + } + this.position += 1; + } + + private parseArray(depth: number): JSONTextNode { + this.expect("["); + this.skipWhitespace(); + + const values: JSONTextNode[] = []; + if (this.text[this.position] === "]") { + this.position += 1; + return { kind: "array", values }; + } + + while (true) { + values.push(this.parseValue(depth + 1)); + this.skipWhitespace(); + + if (this.text[this.position] === "]") { + this.position += 1; + return { kind: "array", values }; + } + + this.expect(","); + this.skipWhitespace(); + } + } + + private parseLiteral(literal: "false" | "null" | "true"): JSONTextNode { + if (!this.text.startsWith(literal, this.position)) { + throw new Error(`expected ${literal}`); + } + + this.position += literal.length; + return { kind: "literal", value: literal }; + } + + private parseNumber(): JSONTextNode { + const start = this.position; + + if (this.text[this.position] === "-") { + this.position += 1; + } + + if (this.text[this.position] === "0") { + this.position += 1; + } else if (isDigitOneToNine(this.text[this.position])) { + this.position += 1; + while (isDigit(this.text[this.position])) { + this.position += 1; + } + } else { + throw new Error("expected number"); + } + + if (this.text[this.position] === ".") { + this.position += 1; + if (!isDigit(this.text[this.position])) { + throw new Error("expected fractional digit"); + } + while (isDigit(this.text[this.position])) { + this.position += 1; + } + } + + if (this.text[this.position] === "e" || this.text[this.position] === "E") { + this.position += 1; + if ( + this.text[this.position] === "+" || + this.text[this.position] === "-" + ) { + this.position += 1; + } + if (!isDigit(this.text[this.position])) { + throw new Error("expected exponent digit"); + } + while (isDigit(this.text[this.position])) { + this.position += 1; + } + } + + return { kind: "number", raw: this.text.slice(start, this.position) }; + } + + private parseObject(depth: number): JSONTextNode { + this.expect("{"); + this.skipWhitespace(); + + const entries: JSONTextObjectEntry[] = []; + if (this.text[this.position] === "}") { + this.position += 1; + return { entries, kind: "object" }; + } + + while (true) { + const key = this.parseStringValue(); + this.skipWhitespace(); + this.expect(":"); + const value = this.parseValue(depth + 1); + entries.push({ key, value }); + this.skipWhitespace(); + + if (this.text[this.position] === "}") { + this.position += 1; + return { entries, kind: "object" }; + } + + this.expect(","); + this.skipWhitespace(); + } + } + + private parseString(): JSONTextNode { + return { kind: "string", value: this.parseStringValue() }; + } + + private parseStringValue(): string { + const start = this.position; + this.expect('"'); + + while (this.position < this.text.length) { + const char = this.text[this.position]; + if (char === '"') { + this.position += 1; + const parsed = JSON.parse(this.text.slice(start, this.position)); + if (typeof parsed !== "string") { + throw new Error("expected string"); + } + return parsed; + } + + if (char === "\\") { + this.position += 2; + } else { + this.position += 1; + } + } + + throw new Error("unterminated string"); + } + + private parseValue(depth = 0): JSONTextNode { + if (depth > MAX_JSON_NESTING_DEPTH) { + throw new Error("maximum JSON nesting depth exceeded"); + } + + this.skipWhitespace(); + + const char = this.text[this.position]; + if (char === "{") return this.parseObject(depth); + if (char === "[") return this.parseArray(depth); + if (char === '"') return this.parseString(); + if (char === "t") return this.parseLiteral("true"); + if (char === "f") return this.parseLiteral("false"); + if (char === "n") return this.parseLiteral("null"); + return this.parseNumber(); + } + + private skipWhitespace() { + while (/[\t\n\r ]/.test(this.text[this.position] ?? "")) { + this.position += 1; + } + } +} + +export function compactJSONText(text: string): string { + const parsed = parseJSONText(text); + if (!parsed) return text; + + try { + return stringifyCompact(parsed); + } catch { + return text; + } +} + +export function formatJSONText(text: string): string { + const parsed = parseJSONText(text); + if (!parsed) return text; + + try { + return stringifyForDisplay(parsed, text.length); + } catch { + return text; + } +} + +export function isJSONTextNumber(value: unknown): value is JSONTextNumber { + return ( + typeof value === "object" && value !== null && JSON_TEXT_NUMBER in value + ); +} + +export function prepareJSONText(text: string): PreparedJSONText | undefined { + const parsed = parseJSONText(text); + if (!parsed) return undefined; + + try { + return { + copyText: stringifyForDisplay(parsed, text.length), + value: nodeToValue(parsed), + }; + } catch { + return undefined; + } +} + +function isDigit(char: string | undefined): boolean { + return char !== undefined && char >= "0" && char <= "9"; +} + +function isDigitOneToNine(char: string | undefined): boolean { + return char !== undefined && char >= "1" && char <= "9"; +} + +function nodeToValue(node: JSONTextNode): JSONTextValue { + switch (node.kind) { + case "array": + return node.values.map(nodeToValue); + case "literal": + if (node.value === "null") return null; + return node.value === "true"; + case "number": + return { [JSON_TEXT_NUMBER]: true, raw: node.raw }; + case "object": { + const value = Object.create(null) as { [key: string]: JSONTextValue }; + for (const entry of node.entries) { + Object.defineProperty(value, entry.key, { + configurable: true, + enumerable: true, + value: nodeToValue(entry.value), + writable: true, + }); + } + return value; + } + case "string": + return node.value; + } +} + +function parseJSONText(text: string): JSONTextNode | undefined { + const parser = new JSONTextParser(text); + return parser.parse(); +} + +function sortableArrayIndex(key: string): number | undefined { + const index = Number(key); + if ( + !Number.isInteger(index) || + index < 0 || + index >= 2 ** 32 - 1 || + String(index) !== key + ) { + return undefined; + } + + return index; +} + +function sortedEntries(entries: JSONTextObjectEntry[]): JSONTextObjectEntry[] { + return [...entries].sort((left, right) => { + const leftIndex = sortableArrayIndex(left.key); + const rightIndex = sortableArrayIndex(right.key); + + if (leftIndex !== undefined && rightIndex !== undefined) { + return leftIndex - rightIndex; + } + if (leftIndex !== undefined) return -1; + if (rightIndex !== undefined) return 1; + return left.key.localeCompare(right.key); + }); +} + +function stringifyCompact(node: JSONTextNode): string { + switch (node.kind) { + case "array": + return `[${node.values.map(stringifyCompact).join(",")}]`; + case "literal": + return node.value; + case "number": + return node.raw; + case "object": + return `{${sortedEntries(node.entries) + .map( + (entry) => + `${JSON.stringify(entry.key)}:${stringifyCompact(entry.value)}`, + ) + .join(",")}}`; + case "string": + return JSON.stringify(node.value); + } +} + +function stringifyForDisplay(node: JSONTextNode, sourceLength: number): string { + const maxLength = Math.min( + MAX_PRETTY_PRINT_LENGTH, + Math.max( + sourceLength * MAX_PRETTY_PRINT_EXPANSION, + sourceLength + MAX_PRETTY_PRINT_EXTRA_LENGTH, + ), + ); + const pretty = stringifyPrettyWithinLimit(node, maxLength); + + return pretty ?? stringifyCompact(node); +} + +function stringifyPrettyWithinLimit( + node: JSONTextNode, + maxLength: number, +): string | undefined { + const builder = new BoundedStringBuilder(maxLength); + return writePretty(node, 0, builder) ? builder.toString() : undefined; +} + +function writePretty( + node: JSONTextNode, + depth: number, + builder: BoundedStringBuilder, +): boolean { + const indent = " "; + const currentIndent = indent.repeat(depth); + const childIndent = indent.repeat(depth + 1); + + switch (node.kind) { + case "array": { + if (node.values.length === 0) return builder.append("[]"); + if (!builder.append("[\n")) return false; + for (const [index, value] of node.values.entries()) { + if (index > 0 && !builder.append(",\n")) return false; + if (!builder.append(childIndent)) return false; + if (!writePretty(value, depth + 1, builder)) return false; + } + return builder.append(`\n${currentIndent}]`); + } + case "literal": + return builder.append(node.value); + case "number": + return builder.append(node.raw); + case "object": { + if (node.entries.length === 0) return builder.append("{}"); + if (!builder.append("{\n")) return false; + for (const [index, entry] of sortedEntries(node.entries).entries()) { + if (index > 0 && !builder.append(",\n")) return false; + if (!builder.append(childIndent)) return false; + if (!builder.append(`${JSON.stringify(entry.key)}: `)) return false; + if (!writePretty(entry.value, depth + 1, builder)) return false; + } + return builder.append(`\n${currentIndent}}`); + } + case "string": + return builder.append(JSON.stringify(node.value)); + } +}