diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e574c8..705bcbd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584). +- 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). ## [v0.16.0] - 2026-05-19 diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 22cd326f..e15f1a09 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -943,20 +943,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 { @@ -988,7 +988,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 b555f54d..dd0a8622 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" "testing" "time" @@ -438,11 +439,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) { @@ -466,9 +481,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"), @@ -480,7 +496,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 b4794122..375e1412 100644 --- a/riverproui/internal/prohandler/pro_handler_api_endpoints.go +++ b/riverproui/internal/prohandler/pro_handler_api_endpoints.go @@ -825,20 +825,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 { @@ -849,7 +849,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 f2a85511..1c4c8ab8 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..42eb2d5c --- /dev/null +++ b/src/components/JSONTextView.test.tsx @@ -0,0 +1,52 @@ +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(); + }); + }); +}); diff --git a/src/components/JSONTextView.tsx b/src/components/JSONTextView.tsx new file mode 100644 index 00000000..17894556 --- /dev/null +++ b/src/components/JSONTextView.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; + +import PlaintextPanel from "@/components/PlaintextPanel"; +import { formatJSONText } from "@/utils/jsonText"; + +type JSONTextViewProps = { + className?: string; + copyTitle?: string; + text: string; +}; + +export default function JSONTextView({ + className, + copyTitle = "JSON", + text, +}: JSONTextViewProps) { + const formattedText = useMemo(() => formatJSONText(text), [text]); + + return ( + + ); +} 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..57a02745 100644 --- a/src/components/JobList.test.tsx +++ b/src/components/JobList.test.tsx @@ -61,7 +61,9 @@ describe("JobList", () => { }); 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 +89,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 +121,7 @@ describe("JobList", () => { , ); - expect( - screen.queryByText(JSON.stringify(job.args)), - ).not.toBeInTheDocument(); + expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument(); }); it("shows job args when user overrides default hide setting", () => { @@ -150,7 +152,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 +183,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..411e4353 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, @@ -89,6 +90,10 @@ const JobListItem = ({ onChangeSelect, }: JobListItemProps) => { const showArgs = !hideArgs; + const argsPreview = useMemo( + () => compactJSONText(job.argsRaw), + [job.argsRaw], + ); return (
  • @@ -134,7 +139,7 @@ const JobListItem = ({ {showArgs && (

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

    )} diff --git a/src/components/WorkflowDetail.test.tsx b/src/components/WorkflowDetail.test.tsx index 444cbc83..def686b7 100644 --- a/src/components/WorkflowDetail.test.tsx +++ b/src/components/WorkflowDetail.test.tsx @@ -219,6 +219,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..09796c78 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -17,18 +17,13 @@ export type AttemptError = { }; export type Job = { - [Key in keyof JobFromAPI as SnakeToCamelCase]: Key extends - | StringEndingWithUnderscoreAt - | undefined - ? Date - : JobFromAPI[Key] extends AttemptErrorFromAPI[] - ? AttemptError[] - : JobFromAPI[Key]; -}; + errors: AttemptError[]; + logs: JobLogs; + metadata: KnownMetadata | object; +} & JobMinimal; export type JobFromAPI = { errors: AttemptErrorFromAPI[]; - logs: JobLogs; metadata: KnownMetadata | object; } & JobMinimalFromAPI; @@ -43,18 +38,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 +102,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..c9b15ff1 --- /dev/null +++ b/src/utils/jsonText.test.ts @@ -0,0 +1,27 @@ +import { compactJSONText, formatJSONText } 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"); + }); +}); diff --git a/src/utils/jsonText.ts b/src/utils/jsonText.ts new file mode 100644 index 00000000..b343f426 --- /dev/null +++ b/src/utils/jsonText.ts @@ -0,0 +1,268 @@ +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 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(): 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()); + 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(): 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(); + 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(): JSONTextNode { + this.skipWhitespace(); + + const char = this.text[this.position]; + if (char === "{") return this.parseObject(); + if (char === "[") return this.parseArray(); + 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); + return parsed ? stringifyCompact(parsed) : text; +} + +export function formatJSONText(text: string): string { + const parsed = parseJSONText(text); + return parsed ? stringifyPretty(parsed, 0) : text; +} + +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 parseJSONText(text: string): JSONTextNode | undefined { + const parser = new JSONTextParser(text); + return parser.parse(); +} + +function sortedEntries(entries: JSONTextObjectEntry[]): JSONTextObjectEntry[] { + return [...entries].sort((left, right) => 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 stringifyPretty(node: JSONTextNode, depth: number): string { + const indent = " "; + const currentIndent = indent.repeat(depth); + const childIndent = indent.repeat(depth + 1); + + switch (node.kind) { + case "array": + if (node.values.length === 0) return "[]"; + return `[\n${node.values + .map((value) => `${childIndent}${stringifyPretty(value, depth + 1)}`) + .join(",\n")}\n${currentIndent}]`; + case "literal": + return node.value; + case "number": + return node.raw; + case "object": + if (node.entries.length === 0) return "{}"; + return `{\n${sortedEntries(node.entries) + .map( + (entry) => + `${childIndent}${JSON.stringify(entry.key)}: ${stringifyPretty( + entry.value, + depth + 1, + )}`, + ) + .join(",\n")}\n${currentIndent}}`; + case "string": + return JSON.stringify(node.value); + } +}