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)); + } +}