From a081162d701bcdcfa10d9e051a6d8183a9f69733 Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:25:58 -0600 Subject: [PATCH 1/8] feat: add ability to filter jobs by tags Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- handler_api_endpoint.go | 13 ++++++++-- src/components/job-search/types.ts | 6 +++++ src/routes/jobs/index.schema.ts | 4 ++++ src/routes/jobs/index.tsx | 38 +++++++++++++++++++++++------- src/services/jobs.ts | 6 ++++- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 1ded8ce3..c3a81f0a 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -151,8 +151,8 @@ func (a *autocompleteListEndpoint[TTx]) Execute(ctx context.Context, req *autoco return listResponseFrom(queuePtrs), nil - default: - return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, queue_name", req.Facet) +default: + return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, job_tag, queue_name", req.Facet) } }) } @@ -423,6 +423,7 @@ type jobListRequest struct { Priorities []int16 `json:"-" validate:"omitempty,min=0,max=10"` // from ExtractRaw Queues []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw State *rivertype.JobState `json:"-" validate:"omitempty,oneof=available cancelled completed discarded pending retryable running scheduled"` // from ExtractRaw + Tags []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw } func (req *jobListRequest) ExtractRaw(r *http.Request) error { @@ -467,6 +468,10 @@ func (req *jobListRequest) ExtractRaw(r *http.Request) error { req.Queues = queues } + if tags := r.URL.Query()["tags"]; len(tags) > 0 { + req.Tags = tags + } + return nil } @@ -492,6 +497,10 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) params = params.Queues(req.Queues...) } + if len(req.Tags) > 0 { + params = params.Where("ARRAY(SELECT lower(t) FROM unnest(tags) t) && ARRAY(SELECT lower(v) FROM unnest(@tags::varchar[]) v)", river.NamedArgs{"tags": req.Tags}) + } + if req.State == nil { params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, river.SortOrderAsc) } else { diff --git a/src/components/job-search/types.ts b/src/components/job-search/types.ts index 853ed5d4..6775b6aa 100644 --- a/src/components/job-search/types.ts +++ b/src/components/job-search/types.ts @@ -3,6 +3,7 @@ export enum JobFilterTypeID { KIND = "kind", PRIORITY = "priority", QUEUE = "queue", + TAGS = "tags", } export interface FilterType { @@ -39,4 +40,9 @@ export const AVAILABLE_FILTERS: FilterType[] = [ label: "queue", match: "queue:", }, + { + id: JobFilterTypeID.TAGS, + label: "tags", + match: "tags:", + }, ]; diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts index 356b9cd0..ab1b4064 100644 --- a/src/routes/jobs/index.schema.ts +++ b/src/routes/jobs/index.schema.ts @@ -49,4 +49,8 @@ export const jobSearchSchema = z.object({ .optional() .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), state: z.nativeEnum(JobState).default(defaultValues.state), + tags: z + .union([z.string(), z.array(z.string().min(1))]) + .optional() + .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), }); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index 903ae4ef..85bede7e 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -50,14 +50,14 @@ export const Route = createFileRoute("/jobs/")({ search: { middlewares: [ stripSearchParams(defaultValues), - retainSearchParams(["id", "kind", "limit", "priority", "queue"]), + retainSearchParams(["id", "kind", "limit", "priority", "queue", "tags"]), ], }, beforeLoad: async ({ context }) => { // No need to check for search.state since it has a default value now return context; }, - loaderDeps: ({ search: { limit, state, kind, queue, priority, id } }) => { + loaderDeps: ({ search: { limit, state, kind, queue, priority, id, tags } }) => { return { kind, limit: limit || defaultValues.limit, @@ -65,15 +65,16 @@ export const Route = createFileRoute("/jobs/")({ queue, state, id, + tags, }; }, loader: async ({ context: { queryClient }, - deps: { limit, state, kind, queue, id }, + deps: { limit, state, kind, queue, id, tags }, }) => { await Promise.all([ queryClient.ensureQueryData({ - ...jobsQueryOptions({ limit, state, kind, queue, id }), + ...jobsQueryOptions({ limit, state, kind, queue, id, tags }), }), queryClient.ensureQueryData(statesQueryOptions()), ]); @@ -84,7 +85,7 @@ export const Route = createFileRoute("/jobs/")({ function JobsIndexComponent() { const navigate = Route.useNavigate(); - const { id, limit, state, kind, queue, priority } = Route.useLoaderDeps(); + const { id, limit, state, kind, queue, priority, tags } = Route.useLoaderDeps(); const refreshSettings = useRefreshSetting(); const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const [pauseRefetches, setJobRefetchesPaused] = useState(false); @@ -99,6 +100,7 @@ function JobsIndexComponent() { kind, queue, priority, + tags, }, { pauseRefetches, @@ -151,6 +153,7 @@ function JobsIndexComponent() { priority: undefined, queue: undefined, id: undefined, + tags: undefined, }; // Only set values for filters that exist and have values @@ -174,6 +177,11 @@ function JobsIndexComponent() { ? filter.values : undefined; break; + case FilterTypeId.TAGS: + searchParams.tags = filter.values.length + ? filter.values + : undefined; + break; } }); @@ -182,6 +190,7 @@ function JobsIndexComponent() { kind, priority: priority?.map(String), queue, + tags, }; // Avoid no-op navigations that can race with route transitions. @@ -192,7 +201,8 @@ function JobsIndexComponent() { currentSearchParams.priority, searchParams.priority, ) && - areStringArraysEqual(currentSearchParams.queue, searchParams.queue) + areStringArraysEqual(currentSearchParams.queue, searchParams.queue) && + areStringArraysEqual(currentSearchParams.tags, searchParams.tags) ) { return; } @@ -208,10 +218,11 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }, - [id, kind, navigate, priority, queue], + [id, kind, navigate, priority, queue, tags], ); // Convert current search params to initial filters @@ -249,8 +260,16 @@ function JobsIndexComponent() { values: queue, }); } + if (tags?.length) { + filters.push({ + id: "tags-filter", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: tags, + }); + } return filters; - }, [id, kind, priority, queue]); + }, [id, kind, priority, queue, tags]); const cancelMutation = useMutation({ mutationFn: async (jobIDs: bigint[], context) => @@ -329,6 +348,7 @@ const jobsQueryOptions = ( kind, queue, priority, + tags, }: { id?: bigint[]; kind?: string[]; @@ -336,6 +356,7 @@ const jobsQueryOptions = ( priority?: number[]; queue?: string[]; state: JobState; + tags?: string[]; }, opts?: { pauseRefetches: boolean; refreshOptions: RefreshQueryOptions }, ) => { @@ -358,6 +379,7 @@ const jobsQueryOptions = ( queues: queue, priorities: priority, ids: id, + tags, }), queryFn: listJobs, placeholderData: keepPreviousDataUnlessStateChanged, diff --git a/src/services/jobs.ts b/src/services/jobs.ts index aae08b5c..487620e1 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -191,6 +191,7 @@ export type ListJobsKey = [ priorities: number[] | undefined; queues: string[] | undefined; state: JobState | undefined; + tags: string[] | undefined; }, ]; @@ -201,6 +202,7 @@ type ListJobsFilters = { priorities?: number[]; queues?: string[]; state?: JobState; + tags?: string[]; }; export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { @@ -213,6 +215,7 @@ export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { priorities: args.priorities, queues: args.queues, state: args.state, + tags: args.tags, }, ]; }; @@ -221,7 +224,7 @@ export const listJobs: QueryFunction = async ({ queryKey, signal, }) => { - const [, { ids, kinds, limit, priorities, queues, state }] = queryKey; + const [, { ids, kinds, limit, priorities, queues, state, tags }] = queryKey; // Build query params object with only defined values const params: Record = { @@ -232,6 +235,7 @@ export const listJobs: QueryFunction = async ({ if (priorities?.length) params.priorities = priorities.map(String); if (queues?.length) params.queues = queues; if (state) params.state = state; + if (tags?.length) params.tags = tags; // Convert to URLSearchParams, handling arrays correctly const query = new URLSearchParams(); From cfe08cac969cbf5451afde150ba08a4fee2062c8 Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:53:29 -0600 Subject: [PATCH 2/8] revert bad spacing change Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- handler_api_endpoint.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index c3a81f0a..e7f01e8c 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -151,7 +151,7 @@ func (a *autocompleteListEndpoint[TTx]) Execute(ctx context.Context, req *autoco return listResponseFrom(queuePtrs), nil -default: + default: return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, job_tag, queue_name", req.Facet) } }) From 6eb065875fce25a6193341183c1dee1e9039e6ea Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 18:27:29 -0500 Subject: [PATCH 3/8] finish tag filter support Tag filtering arrives without changelog coverage or regression tests, and mutations leave active tag-filtered lists stale. The autocomplete error also advertises a `job_tag` facet that no backend implements. Propagate tag filters through every active job-list cache key, remove the unsupported facet claim, and document the user-facing filter. Add coverage for repeated query parameters, route normalization, case-insensitive OR matching, custom schemas, and the handler request path. --- CHANGELOG.md | 4 ++ handler_api_endpoint.go | 7 +++- handler_api_endpoint_test.go | 48 ++++++++++++++++++++++++ handler_test.go | 4 +- src/components/job-search/parser.test.ts | 8 +++- src/routes/jobs/index.test.tsx | 11 ++++++ src/routes/jobs/index.tsx | 30 +++++++++++++-- src/services/jobs.test.ts | 29 ++++++++++++++ 8 files changed, 131 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da77c24..b1587bd0 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] +### Added + +- Job list: filter jobs by one or more case-insensitive tags. [PR #548](https://github.com/riverqueue/riverui/pull/548). + ### 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). diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index e7f01e8c..c541d62e 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -152,7 +152,7 @@ func (a *autocompleteListEndpoint[TTx]) Execute(ctx context.Context, req *autoco return listResponseFrom(queuePtrs), nil default: - return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, job_tag, queue_name", req.Facet) + return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, queue_name", req.Facet) } }) } @@ -498,7 +498,10 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) } if len(req.Tags) > 0 { - params = params.Where("ARRAY(SELECT lower(t) FROM unnest(tags) t) && ARRAY(SELECT lower(v) FROM unnest(@tags::varchar[]) v)", river.NamedArgs{"tags": req.Tags}) + params = params.Where( + "ARRAY(SELECT lower(tag) FROM unnest(tags) AS tag) && ARRAY(SELECT lower(tag) FROM unnest(@tags::varchar[]) AS tag)", + river.NamedArgs{"tags": req.Tags}, + ) } if req.State == nil { diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index fd8e6450..de1d02c7 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -671,6 +671,33 @@ func TestAPIHandlerJobList(t *testing.T) { require.Equal(t, job.ID, resp.Data[0].ID) }) + t.Run("FilterByTags", func(t *testing.T) { + t.Parallel() + + endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint) + + job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"alpha", "shared"}, + }) + job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"beta"}, + }) + _ = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"gamma"}, + }) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + Tags: []string{"ALPHA", "BETA"}, + }) + require.NoError(t, err) + require.Len(t, resp.Data, 2) + require.Equal(t, job1.ID, resp.Data[0].ID) + require.Equal(t, job2.ID, resp.Data[1].ID) + }) + t.Run("FilterByState", func(t *testing.T) { t.Parallel() @@ -713,6 +740,27 @@ func TestAPIHandlerJobList(t *testing.T) { }) } +func TestAPIHandlerJobListCustomSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + endpoint, bundle := setupEndpointWithCustomSchema(ctx, t, newJobListEndpoint) + jobParams := testfactory.Job_Build(t, &testfactory.JobOpts{ + State: ptrutil.Ptr(rivertype.JobStateRunning), + Tags: []string{"custom-schema-tag"}, + }) + jobParams.Schema = bundle.client.Schema() + job, err := bundle.exec.JobInsertFull(ctx, jobParams) + require.NoError(t, err) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ + Tags: []string{"custom-schema-tag"}, + }) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + require.Equal(t, job.ID, resp.Data[0].ID) +} + func TestAPIHandlerJobRetry(t *testing.T) { t.Parallel() diff --git a/handler_test.go b/handler_test.go index 7a52ad6a..94df23eb 100644 --- a/handler_test.go +++ b/handler_test.go @@ -71,7 +71,7 @@ func TestNewHandlerIntegration(t *testing.T) { // Test data // - job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{}) + job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{Tags: []string{"integration"}}) queue := testfactory.Queue(ctx, t, exec, nil) @@ -86,7 +86,7 @@ func TestNewHandlerIntegration(t *testing.T) { makeAPICall(t, "JobCancel", http.MethodPost, makeURL("/api/jobs/cancel"), uicommontest.MustMarshalJSON(t, &jobCancelRequest{JobIDs: []int64String{int64String(job.ID)}})) makeAPICall(t, "JobDelete", http.MethodPost, makeURL("/api/jobs/delete"), uicommontest.MustMarshalJSON(t, &jobCancelRequest{JobIDs: []int64String{int64String(job.ID)}})) makeAPICall(t, "JobGet", http.MethodGet, makeURL("/api/jobs/%d", job.ID), nil) - makeAPICall(t, "JobList", http.MethodGet, makeURL("/api/jobs"), nil) + makeAPICall(t, "JobList", http.MethodGet, makeURL("/api/jobs?tags=integration"), nil) makeAPICall(t, "JobRetry", http.MethodPost, makeURL("/api/jobs/retry"), uicommontest.MustMarshalJSON(t, &jobCancelRequest{JobIDs: []int64String{int64String(job.ID)}})) makeAPICall(t, "QueueGet", http.MethodGet, makeURL("/api/queues/%s", queue.Name), nil) makeAPICall(t, "QueueList", http.MethodGet, makeURL("/api/queues"), nil) diff --git a/src/components/job-search/parser.test.ts b/src/components/job-search/parser.test.ts index 54bcfedc..b6c20bed 100644 --- a/src/components/job-search/parser.test.ts +++ b/src/components/job-search/parser.test.ts @@ -12,12 +12,16 @@ import { JobFilterTypeID } from "./types"; describe("parser", () => { describe("parseFiltersFromText", () => { it("parses simple filters", () => { - const result = parseFiltersFromText("kind:batch queue:priority"); - expect(result).toHaveLength(2); + const result = parseFiltersFromText( + "kind:batch queue:priority tags:customer,urgent", + ); + expect(result).toHaveLength(3); expect(result[0].match).toBe("kind:"); expect(result[0].values).toEqual(["batch"]); expect(result[1].match).toBe("queue:"); expect(result[1].values).toEqual(["priority"]); + expect(result[2].match).toBe("tags:"); + expect(result[2].values).toEqual(["customer", "urgent"]); }); it("parses comma-separated values", () => { diff --git a/src/routes/jobs/index.test.tsx b/src/routes/jobs/index.test.tsx index 542b8483..7a9f09f4 100644 --- a/src/routes/jobs/index.test.tsx +++ b/src/routes/jobs/index.test.tsx @@ -55,4 +55,15 @@ describe("Jobs Route Search Schema", () => { // Test invalid limit type expect(() => jobSearchSchema.parse({ limit: "invalid" })).toThrow(); }); + + it("normalizes tag filters", () => { + expect(jobSearchSchema.parse({ tags: "urgent" })).toMatchObject({ + tags: ["urgent"], + }); + expect( + jobSearchSchema.parse({ tags: ["customer:123", "urgent"] }), + ).toMatchObject({ + tags: ["customer:123", "urgent"], + }); + }); }); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index 85bede7e..c6198f58 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -57,7 +57,9 @@ export const Route = createFileRoute("/jobs/")({ // No need to check for search.state since it has a default value now return context; }, - loaderDeps: ({ search: { limit, state, kind, queue, priority, id, tags } }) => { + loaderDeps: ({ + search: { limit, state, kind, queue, priority, id, tags }, + }) => { return { kind, limit: limit || defaultValues.limit, @@ -85,7 +87,8 @@ export const Route = createFileRoute("/jobs/")({ function JobsIndexComponent() { const navigate = Route.useNavigate(); - const { id, limit, state, kind, queue, priority, tags } = Route.useLoaderDeps(); + const { id, limit, state, kind, queue, priority, tags } = + Route.useLoaderDeps(); const refreshSettings = useRefreshSetting(); const refreshOptions = refreshQueryOptions(refreshSettings.intervalMs); const [pauseRefetches, setJobRefetchesPaused] = useState(false); @@ -127,6 +130,7 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }; @@ -141,6 +145,7 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }; @@ -288,6 +293,7 @@ function JobsIndexComponent() { queues: queue, priorities: priority, ids: id, + tags, }), }); queryClient.invalidateQueries({ queryKey: countsByStateKey() }); @@ -304,7 +310,15 @@ function JobsIndexComponent() { duration: 2000, }); await queryClient.removeQueries({ - queryKey: listJobsKey({ limit, state }), + queryKey: listJobsKey({ + limit, + state, + kinds: kind, + queues: queue, + priorities: priority, + ids: id, + tags, + }), }); queryClient.invalidateQueries({ queryKey: countsByStateKey() }); }, @@ -313,7 +327,15 @@ function JobsIndexComponent() { const retryMutation = useRetryJobs({ onSuccess: () => { queryClient.invalidateQueries({ - queryKey: listJobsKey({ limit, state }), + queryKey: listJobsKey({ + limit, + state, + kinds: kind, + queues: queue, + priorities: priority, + ids: id, + tags, + }), }); queryClient.invalidateQueries({ queryKey: countsByStateKey() }); }, diff --git a/src/services/jobs.test.ts b/src/services/jobs.test.ts index e57f0887..2c007745 100644 --- a/src/services/jobs.test.ts +++ b/src/services/jobs.test.ts @@ -73,6 +73,35 @@ describe("jobs service", () => { expect(jobs[0]?.argsRaw).toBe('{"id":1970670598291982290}'); }); + it("serializes tag filters as repeated query parameters", async () => { + document.body.innerHTML = + ''; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [] }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + + await listJobs({ + client: undefined as never, + meta: undefined, + queryKey: listJobsKey({ + limit: 10, + tags: ["customer:123", "urgent"], + }), + signal: new AbortController().signal, + }); + + const requestURL = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(requestURL.pathname).toBe("/api/jobs"); + expect(requestURL.searchParams.getAll("tags")).toEqual([ + "customer:123", + "urgent", + ]); + }); + it("preserves job detail args as raw JSON text", async () => { document.body.innerHTML = ''; From b9f3f977baeaaa5c55ca3c3f204f8851413e58c8 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 18:59:34 -0500 Subject: [PATCH 4/8] use River tag list filter The job-list endpoint currently implements tag matching with PostgreSQL-only SQL through `JobListParams.Where`, preventing the handler from working with other River drivers. Pin River to the commit from riverqueue/river#1339 and call the new `JobListParams.Tags` method. River now owns the case-insensitive, match-any query semantics for PostgreSQL and SQLite, while RiverUI remains independent of driver-specific SQL. --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- handler_api_endpoint.go | 5 +---- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 712eeac6..a75c0cf8 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,11 @@ require ( github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.10.0 github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e - github.com/riverqueue/river v0.41.1 - github.com/riverqueue/river/riverdriver v0.41.1 - github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.1 - github.com/riverqueue/river/rivershared v0.41.1 - github.com/riverqueue/river/rivertype v0.41.1 + github.com/riverqueue/river v0.42.1-0.20260802001023-7ef57fe6fd78 + github.com/riverqueue/river/riverdriver v0.42.1-0.20260802001023-7ef57fe6fd78 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802001023-7ef57fe6fd78 + github.com/riverqueue/river/rivershared v0.42.1-0.20260802001023-7ef57fe6fd78 + github.com/riverqueue/river/rivertype v0.42.1-0.20260802001023-7ef57fe6fd78 github.com/rs/cors v1.11.1 github.com/samber/slog-http v1.12.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 6c4f1119..2f3eedfd 100644 --- a/go.sum +++ b/go.sum @@ -35,16 +35,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e h1:OwOgxT3MRpOj5Mp6DhFdZP43FOQOf2hhywAuT5XZCR4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e/go.mod h1:O7UmsAMjpMYuToN4au5GNXdmN1gli+5FTldgXqAfaD0= -github.com/riverqueue/river v0.41.1 h1:Eat4tnIL16PTX1StVTwTAm2iKDowG99t2fDZPfwFPE8= -github.com/riverqueue/river v0.41.1/go.mod h1:5KXJLKDPwjVC99ISXv2OHDQtNm1YwAeEAx9VYCd1RF0= -github.com/riverqueue/river/riverdriver v0.41.1 h1:cwmmFCJkJlH6geaUTbOqC8vBUe00vTBm248GVD3HrqY= -github.com/riverqueue/river/riverdriver v0.41.1/go.mod h1:nAZirY7z2clR4gaZxuGzvae8bwgycl9mrvb3+RWOehQ= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.1 h1:9VCL/tebPcMqrg9dwxQnL80/np4b2HnXwSwaICsszGM= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.1/go.mod h1:Ga+M+38X+cDMk6GsONljP3PrY5ylfBYMBn3MRj2+c9U= -github.com/riverqueue/river/rivershared v0.41.1 h1:wHYBCXxCocvPhkH90B4fAUyMMWMVGG+/EFubyH15R2E= -github.com/riverqueue/river/rivershared v0.41.1/go.mod h1:wQJhQ+jP7SILzEF3t7vrUQvuCMRL0sIhCU2DwjFfn/g= -github.com/riverqueue/river/rivertype v0.41.1 h1:wBO2+nRfmpSohBRkC1/dxEkRyLS96FWlkUjukexOydc= -github.com/riverqueue/river/rivertype v0.41.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= +github.com/riverqueue/river v0.42.1-0.20260802001023-7ef57fe6fd78 h1:yAqpL63S+YPfpsL5f6mDjcamS0hKG9I91y4SeLOMb60= +github.com/riverqueue/river v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:pD+hDP0ZW3SbuTwh0CXDlYQ/M3Q7HDntT3Q4lhfYugY= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260802001023-7ef57fe6fd78 h1:fIvZdHAKuKpFFbMJd8pJDEKFA1x47I2l4+lV6N8/qMA= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:b2IBlA29E3H233XwgbiJlezdoALSWhetZKt1LlBJEQU= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802001023-7ef57fe6fd78 h1:khfKX3nHZeidnnJnSR+MhwWPcmr5Hw3tQ3tFbafle9M= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:x+Yx1dcPLuriu8TqDHpAvZ5YQRJVnh9mxuxlqS0I6HA= +github.com/riverqueue/river/rivershared v0.42.1-0.20260802001023-7ef57fe6fd78 h1:76j+rVZUlMfO/ExXWOMZ6TU4mBlIV51b32wTE9Fy5b0= +github.com/riverqueue/river/rivershared v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:EThAIEr49dlUQFhVLJcQGKoMlnPKOq+UxdMi9jevVsk= +github.com/riverqueue/river/rivertype v0.42.1-0.20260802001023-7ef57fe6fd78 h1:fc0tfPlMEynYPyUYjFlA+5vZcVkD8rvTEzUhkGeY92c= +github.com/riverqueue/river/rivertype v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index c541d62e..4d93322c 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -498,10 +498,7 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) } if len(req.Tags) > 0 { - params = params.Where( - "ARRAY(SELECT lower(tag) FROM unnest(tags) AS tag) && ARRAY(SELECT lower(tag) FROM unnest(@tags::varchar[]) AS tag)", - river.NamedArgs{"tags": req.Tags}, - ) + params = params.Tags(req.Tags...) } if req.State == nil { From c1f413905cb8a7c8a02a05eadbfcfaccd1cc6763 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 19:29:14 -0500 Subject: [PATCH 5/8] strengthen tag filter coverage The tag filter tests exercise backend matching directly, but they leave the raw request boundary, route translation, and mutation cache behavior unprotected. Regressions in those paths can silently ignore public API filters or leave filtered job lists stale. Assert repeated query parameter extraction and parser type dispatch. Add a focused route component harness that verifies tags flow from route search state into the job query and filter control, back into navigation updates, and through the cache keys refreshed after cancel, delete, and retry. --- handler_api_endpoint_test.go | 16 ++ src/components/job-search/parser.test.ts | 1 + src/routes/jobs/index.test.tsx | 224 ++++++++++++++++++++++- src/routes/jobs/index.tsx | 2 +- 4 files changed, 241 insertions(+), 2 deletions(-) diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index de1d02c7..d4e9d6c5 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "net/http" + "net/http/httptest" "testing" "time" @@ -761,6 +762,21 @@ func TestAPIHandlerJobListCustomSchema(t *testing.T) { require.Equal(t, job.ID, resp.Data[0].ID) } +func TestJobListRequestExtractRaw(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/api/jobs?tags=ALPHA&tags=customer%3A123", + nil, + ) + params := &jobListRequest{} + + require.NoError(t, params.ExtractRaw(req)) + require.Equal(t, []string{"ALPHA", "customer:123"}, params.Tags) +} + func TestAPIHandlerJobRetry(t *testing.T) { t.Parallel() diff --git a/src/components/job-search/parser.test.ts b/src/components/job-search/parser.test.ts index b6c20bed..9417f16c 100644 --- a/src/components/job-search/parser.test.ts +++ b/src/components/job-search/parser.test.ts @@ -21,6 +21,7 @@ describe("parser", () => { expect(result[1].match).toBe("queue:"); expect(result[1].values).toEqual(["priority"]); expect(result[2].match).toBe("tags:"); + expect(result[2].typeId).toBe(JobFilterTypeID.TAGS); expect(result[2].values).toEqual(["customer", "urgent"]); }); diff --git a/src/routes/jobs/index.test.tsx b/src/routes/jobs/index.test.tsx index 7a9f09f4..46a827a0 100644 --- a/src/routes/jobs/index.test.tsx +++ b/src/routes/jobs/index.test.tsx @@ -1,6 +1,129 @@ +import { Filter, FilterTypeId } from "@components/job-search/JobSearch"; +import { JobsIndexComponent, Route } from "@routes/jobs/index"; import { jobSearchSchema } from "@routes/jobs/index.schema"; +import { listJobsKey } from "@services/jobs"; import { JobState } from "@services/types"; -import { describe, expect, it } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type JobListHarnessProps = { + cancelJobs: (jobIDs: bigint[]) => void; + deleteJobs: (jobIDs: bigint[]) => void; + initialFilters?: Filter[]; + onFiltersChange?: (filters: Filter[]) => void; + retryJobs: (jobIDs: bigint[]) => void; +}; + +type NavigateOptions = { + search: ( + old: Record, + ) => Record; +}; + +const { + mockCancelJobs, + mockCountsByState, + mockDeleteJobs, + mockJobList, + mockListJobs, + mockNavigate, + mockUseRetryJobs, +} = vi.hoisted(() => ({ + mockCancelJobs: vi.fn(), + mockCountsByState: vi.fn(), + mockDeleteJobs: vi.fn(), + mockJobList: vi.fn(), + mockListJobs: vi.fn(), + mockNavigate: vi.fn(), + mockUseRetryJobs: vi.fn(), +})); + +vi.mock("@components/JobList", () => ({ + default: (props: JobListHarnessProps) => { + mockJobList(props); + return null; + }, +})); + +vi.mock("@contexts/RefreshSettings.hook", () => ({ + useRefreshSetting: () => ({ intervalMs: 0 }), +})); + +vi.mock("@hooks/use-retry-jobs", () => ({ + useRetryJobs: (opts: { onSuccess: () => void }) => { + mockUseRetryJobs(opts); + return { mutate: (_jobIDs: bigint[]) => opts.onSuccess() }; + }, +})); + +vi.mock("@services/jobs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cancelJobs: mockCancelJobs, + deleteJobs: mockDeleteJobs, + listJobs: mockListJobs, + }; +}); + +vi.mock("@services/states", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countsByState: mockCountsByState, + }; +}); + +vi.mock("@services/toast", () => ({ + toastError: vi.fn(), +})); + +const loaderDeps = { + id: undefined, + kind: ["email"], + limit: 20, + priority: [1], + queue: ["default"], + state: JobState.Running, + tags: ["customer:123", "urgent"], +}; + +const activeJobsKey = listJobsKey({ + ids: loaderDeps.id, + kinds: loaderDeps.kind, + limit: loaderDeps.limit, + priorities: loaderDeps.priority, + queues: loaderDeps.queue, + state: loaderDeps.state, + tags: loaderDeps.tags, +}); + +const latestJobListProps = (): JobListHarnessProps => { + const props = mockJobList.mock.calls.at(-1)?.[0] as + JobListHarnessProps | undefined; + expect(props).toBeDefined(); + if (!props) throw new Error("JobList was not rendered"); + return props; +}; + +const renderJobsIndex = () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + + return { + queryClient, + ...render( + + + , + ), + }; +}; describe("Jobs Route Search Schema", () => { it("validates search parameters correctly", () => { @@ -67,3 +190,102 @@ describe("Jobs Route Search Schema", () => { }); }); }); + +describe("JobsIndexComponent", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCancelJobs.mockResolvedValue(undefined); + mockCountsByState.mockResolvedValue({}); + mockDeleteJobs.mockResolvedValue(undefined); + mockListJobs.mockResolvedValue([]); + vi.spyOn(Route, "useLoaderDeps").mockReturnValue(loaderDeps); + vi.spyOn(Route, "useNavigate").mockReturnValue(mockNavigate); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("round trips tags between route search and job filters", async () => { + renderJobsIndex(); + + await waitFor(() => expect(mockListJobs).toHaveBeenCalled()); + expect(mockListJobs.mock.calls.at(-1)?.[0]).toMatchObject({ + queryKey: activeJobsKey, + }); + + const props = latestJobListProps(); + expect(props.initialFilters).toEqual( + expect.arrayContaining([ + { + id: "tags-filter", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: loaderDeps.tags, + }, + ]), + ); + + act(() => { + props.onFiltersChange?.([ + { + id: "replacement-tags", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: ["replacement"], + }, + ]); + }); + let navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect(navigateOpts?.search({ state: JobState.Running })).toMatchObject({ + tags: ["replacement"], + }); + + act(() => props.onFiltersChange?.([])); + navigateOpts = mockNavigate.mock.calls.at(-1)?.[0] as + NavigateOptions | undefined; + expect( + navigateOpts?.search({ + state: JobState.Running, + tags: loaderDeps.tags, + }), + ).toEqual({ + id: undefined, + kind: undefined, + priority: undefined, + queue: undefined, + state: JobState.Running, + tags: undefined, + }); + }); + + it("refreshes the active tag-filtered query after mutations", async () => { + const { queryClient } = renderJobsIndex(); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + const removeQueries = vi.spyOn(queryClient, "removeQueries"); + + await waitFor(() => expect(mockListJobs).toHaveBeenCalled()); + const props = latestJobListProps(); + + act(() => props.cancelJobs([123n])); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: activeJobsKey, + }), + ); + + act(() => props.deleteJobs([123n])); + await waitFor(() => + expect(removeQueries).toHaveBeenCalledWith({ + queryKey: activeJobsKey, + }), + ); + + invalidateQueries.mockClear(); + act(() => props.retryJobs([123n])); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: activeJobsKey, + }); + }); +}); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index c6198f58..69c4fe0f 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -85,7 +85,7 @@ export const Route = createFileRoute("/jobs/")({ component: JobsIndexComponent, }); -function JobsIndexComponent() { +export function JobsIndexComponent() { const navigate = Route.useNavigate(); const { id, limit, state, kind, queue, priority, tags } = Route.useLoaderDeps(); From 7e9c10b3cc75200bea0eb2ef1eb21478013e198a Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 1 Aug 2026 20:16:25 -0500 Subject: [PATCH 6/8] use explicit River tag matching RiverUI still calls the ambiguous tag filter and describes its matching as case-insensitive after River splits the API into explicit any and all forms. Pin the River modules to the revised PR commit and use TagsAny for the existing multi-selection behavior. Describe the exact-match contract in the changelog and include a mixed-case decoy in handler coverage so accidental case folding is visible. --- CHANGELOG.md | 2 +- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- handler_api_endpoint.go | 2 +- handler_api_endpoint_test.go | 6 +++--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1587bd0..3c6c2169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Job list: filter jobs by one or more case-insensitive tags. [PR #548](https://github.com/riverqueue/riverui/pull/548). +- Job list: filter jobs matching any of the selected exact tags. [PR #548](https://github.com/riverqueue/riverui/pull/548). ### Fixed diff --git a/go.mod b/go.mod index a75c0cf8..4e735844 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,11 @@ require ( github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.10.0 github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e - github.com/riverqueue/river v0.42.1-0.20260802001023-7ef57fe6fd78 - github.com/riverqueue/river/riverdriver v0.42.1-0.20260802001023-7ef57fe6fd78 - github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802001023-7ef57fe6fd78 - github.com/riverqueue/river/rivershared v0.42.1-0.20260802001023-7ef57fe6fd78 - github.com/riverqueue/river/rivertype v0.42.1-0.20260802001023-7ef57fe6fd78 + github.com/riverqueue/river v0.42.1-0.20260802011204-478b870113b1 + github.com/riverqueue/river/riverdriver v0.42.1-0.20260802011204-478b870113b1 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802011204-478b870113b1 + github.com/riverqueue/river/rivershared v0.42.1-0.20260802011204-478b870113b1 + github.com/riverqueue/river/rivertype v0.42.1-0.20260802011204-478b870113b1 github.com/rs/cors v1.11.1 github.com/samber/slog-http v1.12.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 2f3eedfd..9995a9a1 100644 --- a/go.sum +++ b/go.sum @@ -35,16 +35,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e h1:OwOgxT3MRpOj5Mp6DhFdZP43FOQOf2hhywAuT5XZCR4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e/go.mod h1:O7UmsAMjpMYuToN4au5GNXdmN1gli+5FTldgXqAfaD0= -github.com/riverqueue/river v0.42.1-0.20260802001023-7ef57fe6fd78 h1:yAqpL63S+YPfpsL5f6mDjcamS0hKG9I91y4SeLOMb60= -github.com/riverqueue/river v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:pD+hDP0ZW3SbuTwh0CXDlYQ/M3Q7HDntT3Q4lhfYugY= -github.com/riverqueue/river/riverdriver v0.42.1-0.20260802001023-7ef57fe6fd78 h1:fIvZdHAKuKpFFbMJd8pJDEKFA1x47I2l4+lV6N8/qMA= -github.com/riverqueue/river/riverdriver v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:b2IBlA29E3H233XwgbiJlezdoALSWhetZKt1LlBJEQU= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802001023-7ef57fe6fd78 h1:khfKX3nHZeidnnJnSR+MhwWPcmr5Hw3tQ3tFbafle9M= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:x+Yx1dcPLuriu8TqDHpAvZ5YQRJVnh9mxuxlqS0I6HA= -github.com/riverqueue/river/rivershared v0.42.1-0.20260802001023-7ef57fe6fd78 h1:76j+rVZUlMfO/ExXWOMZ6TU4mBlIV51b32wTE9Fy5b0= -github.com/riverqueue/river/rivershared v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:EThAIEr49dlUQFhVLJcQGKoMlnPKOq+UxdMi9jevVsk= -github.com/riverqueue/river/rivertype v0.42.1-0.20260802001023-7ef57fe6fd78 h1:fc0tfPlMEynYPyUYjFlA+5vZcVkD8rvTEzUhkGeY92c= -github.com/riverqueue/river/rivertype v0.42.1-0.20260802001023-7ef57fe6fd78/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= +github.com/riverqueue/river v0.42.1-0.20260802011204-478b870113b1 h1:pIQ1cG2EOM4j0mrwQk0VXz3Ztw93L9K1CzU/opzB1Uk= +github.com/riverqueue/river v0.42.1-0.20260802011204-478b870113b1/go.mod h1:pD+hDP0ZW3SbuTwh0CXDlYQ/M3Q7HDntT3Q4lhfYugY= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260802011204-478b870113b1 h1:FCn7VTWokypPLNuF3+e3kcVJ8JvptJd1QmUuKpDIlIA= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260802011204-478b870113b1/go.mod h1:b2IBlA29E3H233XwgbiJlezdoALSWhetZKt1LlBJEQU= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802011204-478b870113b1 h1:ZHGXTJobXSRnWBVbAFlkKvObN6YPR4RsWQnv0rVKSF4= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802011204-478b870113b1/go.mod h1:x+Yx1dcPLuriu8TqDHpAvZ5YQRJVnh9mxuxlqS0I6HA= +github.com/riverqueue/river/rivershared v0.42.1-0.20260802011204-478b870113b1 h1:tLV6vbP8J1q9ecp3V61S9sb4ZPPA9eaDE4jRcqOpvn8= +github.com/riverqueue/river/rivershared v0.42.1-0.20260802011204-478b870113b1/go.mod h1:EThAIEr49dlUQFhVLJcQGKoMlnPKOq+UxdMi9jevVsk= +github.com/riverqueue/river/rivertype v0.42.1-0.20260802011204-478b870113b1 h1:pAHd+PK2Us8zQxrLZMNbBR15M0ieJV1jKXNzqxfEs54= +github.com/riverqueue/river/rivertype v0.42.1-0.20260802011204-478b870113b1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 4d93322c..dce312f1 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -498,7 +498,7 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) } if len(req.Tags) > 0 { - params = params.Tags(req.Tags...) + params = params.TagsAny(req.Tags...) } if req.State == nil { diff --git a/handler_api_endpoint_test.go b/handler_api_endpoint_test.go index d4e9d6c5..a5b918a9 100644 --- a/handler_api_endpoint_test.go +++ b/handler_api_endpoint_test.go @@ -679,7 +679,7 @@ func TestAPIHandlerJobList(t *testing.T) { job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ State: ptrutil.Ptr(rivertype.JobStateRunning), - Tags: []string{"alpha", "shared"}, + Tags: []string{"alpha-tag", "shared"}, }) job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ State: ptrutil.Ptr(rivertype.JobStateRunning), @@ -687,11 +687,11 @@ func TestAPIHandlerJobList(t *testing.T) { }) _ = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{ State: ptrutil.Ptr(rivertype.JobStateRunning), - Tags: []string{"gamma"}, + Tags: []string{"ALPHA-TAG"}, }) resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobListRequest{ - Tags: []string{"ALPHA", "BETA"}, + Tags: []string{"alpha-tag", "beta"}, }) require.NoError(t, err) require.Len(t, resp.Data, 2) From c1bcdc70ac1513a9799839028b8e92bb8701bfa2 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 2 Aug 2026 19:45:11 -0500 Subject: [PATCH 7/8] pin River merge commit RiverUI currently points at the tag-filter PR's branch commit even though the River change is now merged. Move every River module to the resulting master merge commit. The dependency now tracks the authoritative repository history while retaining the same tag filter API and behavior. --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 4e735844..d2cc2b8e 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,11 @@ require ( github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.10.0 github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e - github.com/riverqueue/river v0.42.1-0.20260802011204-478b870113b1 - github.com/riverqueue/river/riverdriver v0.42.1-0.20260802011204-478b870113b1 - github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802011204-478b870113b1 - github.com/riverqueue/river/rivershared v0.42.1-0.20260802011204-478b870113b1 - github.com/riverqueue/river/rivertype v0.42.1-0.20260802011204-478b870113b1 + github.com/riverqueue/river v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/riverdriver v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/rivershared v0.42.1-0.20260803004224-dc39f530d6db + github.com/riverqueue/river/rivertype v0.42.1-0.20260803004224-dc39f530d6db github.com/rs/cors v1.11.1 github.com/samber/slog-http v1.12.1 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 9995a9a1..9f51f6b8 100644 --- a/go.sum +++ b/go.sum @@ -35,16 +35,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e h1:OwOgxT3MRpOj5Mp6DhFdZP43FOQOf2hhywAuT5XZCR4= github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e/go.mod h1:O7UmsAMjpMYuToN4au5GNXdmN1gli+5FTldgXqAfaD0= -github.com/riverqueue/river v0.42.1-0.20260802011204-478b870113b1 h1:pIQ1cG2EOM4j0mrwQk0VXz3Ztw93L9K1CzU/opzB1Uk= -github.com/riverqueue/river v0.42.1-0.20260802011204-478b870113b1/go.mod h1:pD+hDP0ZW3SbuTwh0CXDlYQ/M3Q7HDntT3Q4lhfYugY= -github.com/riverqueue/river/riverdriver v0.42.1-0.20260802011204-478b870113b1 h1:FCn7VTWokypPLNuF3+e3kcVJ8JvptJd1QmUuKpDIlIA= -github.com/riverqueue/river/riverdriver v0.42.1-0.20260802011204-478b870113b1/go.mod h1:b2IBlA29E3H233XwgbiJlezdoALSWhetZKt1LlBJEQU= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802011204-478b870113b1 h1:ZHGXTJobXSRnWBVbAFlkKvObN6YPR4RsWQnv0rVKSF4= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260802011204-478b870113b1/go.mod h1:x+Yx1dcPLuriu8TqDHpAvZ5YQRJVnh9mxuxlqS0I6HA= -github.com/riverqueue/river/rivershared v0.42.1-0.20260802011204-478b870113b1 h1:tLV6vbP8J1q9ecp3V61S9sb4ZPPA9eaDE4jRcqOpvn8= -github.com/riverqueue/river/rivershared v0.42.1-0.20260802011204-478b870113b1/go.mod h1:EThAIEr49dlUQFhVLJcQGKoMlnPKOq+UxdMi9jevVsk= -github.com/riverqueue/river/rivertype v0.42.1-0.20260802011204-478b870113b1 h1:pAHd+PK2Us8zQxrLZMNbBR15M0ieJV1jKXNzqxfEs54= -github.com/riverqueue/river/rivertype v0.42.1-0.20260802011204-478b870113b1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= +github.com/riverqueue/river v0.42.1-0.20260803004224-dc39f530d6db h1:a8TDxboPR2Yyf+k9ifyzlEvGOoYBeKQWu3r1HCKAQh8= +github.com/riverqueue/river v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:pD+hDP0ZW3SbuTwh0CXDlYQ/M3Q7HDntT3Q4lhfYugY= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260803004224-dc39f530d6db h1:YmnE4JbUsi+XfvhkJD6IAbuiXmc/GB/0RzM7LEFd3SQ= +github.com/riverqueue/river/riverdriver v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:b2IBlA29E3H233XwgbiJlezdoALSWhetZKt1LlBJEQU= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260803004224-dc39f530d6db h1:1aDHGtTlxeA9f0oE4/MsdGPXX18YaB5ngTDh/tb2eEQ= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:x+Yx1dcPLuriu8TqDHpAvZ5YQRJVnh9mxuxlqS0I6HA= +github.com/riverqueue/river/rivershared v0.42.1-0.20260803004224-dc39f530d6db h1:DK/GXyMms5565iH7SSxSQ4I19tNa35cOdUjiqAUap9M= +github.com/riverqueue/river/rivershared v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:EThAIEr49dlUQFhVLJcQGKoMlnPKOq+UxdMi9jevVsk= +github.com/riverqueue/river/rivertype v0.42.1-0.20260803004224-dc39f530d6db h1:02uJDqbCs41J3IPL9YWdxLabiIpdxWGpt/AfAo+T1d8= +github.com/riverqueue/river/rivertype v0.42.1-0.20260803004224-dc39f530d6db/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= From f1057454d22792cee34de4b14a6f7af0846df546 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sun, 2 Aug 2026 19:48:20 -0500 Subject: [PATCH 8/8] keep integration smoke test generic --- handler_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handler_test.go b/handler_test.go index 94df23eb..7a52ad6a 100644 --- a/handler_test.go +++ b/handler_test.go @@ -71,7 +71,7 @@ func TestNewHandlerIntegration(t *testing.T) { // Test data // - job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{Tags: []string{"integration"}}) + job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{}) queue := testfactory.Queue(ctx, t, exec, nil) @@ -86,7 +86,7 @@ func TestNewHandlerIntegration(t *testing.T) { makeAPICall(t, "JobCancel", http.MethodPost, makeURL("/api/jobs/cancel"), uicommontest.MustMarshalJSON(t, &jobCancelRequest{JobIDs: []int64String{int64String(job.ID)}})) makeAPICall(t, "JobDelete", http.MethodPost, makeURL("/api/jobs/delete"), uicommontest.MustMarshalJSON(t, &jobCancelRequest{JobIDs: []int64String{int64String(job.ID)}})) makeAPICall(t, "JobGet", http.MethodGet, makeURL("/api/jobs/%d", job.ID), nil) - makeAPICall(t, "JobList", http.MethodGet, makeURL("/api/jobs?tags=integration"), nil) + makeAPICall(t, "JobList", http.MethodGet, makeURL("/api/jobs"), nil) makeAPICall(t, "JobRetry", http.MethodPost, makeURL("/api/jobs/retry"), uicommontest.MustMarshalJSON(t, &jobCancelRequest{JobIDs: []int64String{int64String(job.ID)}})) makeAPICall(t, "QueueGet", http.MethodGet, makeURL("/api/queues/%s", queue.Name), nil) makeAPICall(t, "QueueList", http.MethodGet, makeURL("/api/queues"), nil)