Skip to content

Commit 9a00d4f

Browse files
committed
refactor(webapp): consolidate id filter validators into a tested helper
Replace the per-filter isClassifiable checks with a single makeFriendlyIdValidator helper covered by unit tests. isClassifiable only recognizes cuid and ksuid bodies, so it rejected legacy nanoid ids (run_ / batch_ + 21-char body) that the filters previously accepted; the helper accepts all three body lengths (21 nanoid, 25 cuid, 27 ksuid) and is now shared by the run, batch, waitpoint, and schedule filters.
1 parent 31f453e commit 9a00d4f

8 files changed

Lines changed: 131 additions & 34 deletions

File tree

.server-changes/fix-ksuid-id-filters.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
The Run ID and Batch ID filters on the runs, batches, and logs pages now accept all valid run and batch IDs, fixing a case where recently created IDs were incorrectly rejected.
6+
The Run ID and Batch ID filters on the runs, batches, and logs pages now accept every valid ID format, fixing a case where valid IDs were rejected and the Apply button stayed disabled.

apps/webapp/app/components/logs/LogsRunIdFilter.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import * as Ariakit from "@ariakit/react";
22
import { FingerPrintIcon } from "@heroicons/react/20/solid";
3-
import { isClassifiable } from "@trigger.dev/core/v3/isomorphic";
43
import { useCallback, useState } from "react";
54
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
65
import { Button } from "~/components/primitives/Buttons";
@@ -10,8 +9,10 @@ import { Label } from "~/components/primitives/Label";
109
import { SelectPopover, SelectProvider, SelectTrigger } from "~/components/primitives/Select";
1110
import { useSearchParams } from "~/hooks/useSearchParam";
1211
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
12+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
1313

1414
const shortcut = { key: "i" };
15+
const validateRunId = makeFriendlyIdValidator("run", "Run");
1516

1617
export function LogsRunIdFilter() {
1718
const { value } = useSearchParams();
@@ -69,14 +70,7 @@ function RunIdDropdown({
6970
setOpen(false);
7071
}, [runId, replace, clearSearchValue]);
7172

72-
let error: string | undefined = undefined;
73-
if (runId) {
74-
if (!runId.startsWith("run_")) {
75-
error = "Run IDs start with 'run_'";
76-
} else if (!isClassifiable(runId)) {
77-
error = "That doesn't look like a valid run ID";
78-
}
79-
}
73+
const error = runId ? validateRunId(runId) : undefined;
8074

8175
return (
8276
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>

apps/webapp/app/components/runs/v3/BatchFilters.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as Ariakit from "@ariakit/react";
22
import { Squares2X2Icon, XMarkIcon } from "@heroicons/react/20/solid";
33
import { Form } from "@remix-run/react";
4-
import { isClassifiable } from "@trigger.dev/core/v3/isomorphic";
54
import type { BatchTaskRunStatus } from "@trigger.dev/database";
65
import { type ReactNode, useRef } from "react";
76
import { z } from "zod";
@@ -25,6 +24,7 @@ import {
2524
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
2625
import { useSearchParams } from "~/hooks/useSearchParam";
2726
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
27+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
2828
import { Button } from "../../primitives/Buttons";
2929
import {
3030
allBatchStatuses,
@@ -226,10 +226,7 @@ function PermanentStatusFilter() {
226226
);
227227
}
228228

229-
function validateBatchId(value: string): string | undefined {
230-
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
231-
if (!isClassifiable(value)) return "That doesn't look like a valid batch ID";
232-
}
229+
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
233230

234231
function BatchIdDropdown(
235232
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
import { Form, useFetcher } from "@remix-run/react";
1414
import { IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
1515
import { MachinePresetName } from "@trigger.dev/core/v3";
16-
import { isClassifiable } from "@trigger.dev/core/v3/isomorphic";
1716
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
1817
import { matchSorter } from "match-sorter";
1918
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
@@ -66,6 +65,7 @@ import { useShortcutKeys } from "~/hooks/useShortcutKeys";
6665
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
6766
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
6867
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
68+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
6969
import { Button } from "../../primitives/Buttons";
7070
import { AIFilterInput } from "./AIFilterInput";
7171
import { BulkActionTypeCombo } from "./BulkAction";
@@ -1714,10 +1714,7 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
17141714
);
17151715
}
17161716

1717-
function validateRunId(value: string): string | undefined {
1718-
if (!value.startsWith("run_")) return "Run IDs start with 'run_'";
1719-
if (!isClassifiable(value)) return "That doesn't look like a valid run ID";
1720-
}
1717+
const validateRunId = makeFriendlyIdValidator("run", "Run");
17211718

17221719
function RunIdDropdown(
17231720
props: Omit<
@@ -1769,10 +1766,7 @@ function AppliedRunIdFilter() {
17691766
);
17701767
}
17711768

1772-
function validateBatchId(value: string): string | undefined {
1773-
if (!value.startsWith("batch_")) return "Batch IDs start with 'batch_'";
1774-
if (!isClassifiable(value)) return "That doesn't look like a valid batch ID";
1775-
}
1769+
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
17761770

17771771
function BatchIdDropdown(
17781772
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
@@ -1820,10 +1814,7 @@ function AppliedBatchIdFilter() {
18201814
);
18211815
}
18221816

1823-
function validateScheduleId(value: string): string | undefined {
1824-
if (!value.startsWith("sched_")) return "Schedule IDs start with 'sched_'";
1825-
if (value.length !== 27) return "Schedule IDs are 27 characters long";
1826-
}
1817+
const validateScheduleId = makeFriendlyIdValidator("sched", "Schedule");
18271818

18281819
function ScheduleIdDropdown(
18291820
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">

apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as Ariakit from "@ariakit/react";
22
import { FingerPrintIcon, TagIcon, XMarkIcon } from "@heroicons/react/20/solid";
33
import { Form, useFetcher } from "@remix-run/react";
44
import { WaitpointTokenStatus, waitpointTokenStatuses } from "@trigger.dev/core/v3";
5-
import { isClassifiable } from "@trigger.dev/core/v3/isomorphic";
65
import { ListChecks } from "lucide-react";
76
import { matchSorter } from "match-sorter";
87
import { type ReactNode, useEffect, useMemo, useRef } from "react";
@@ -34,6 +33,7 @@ import { useProject } from "~/hooks/useProject";
3433
import { useSearchParams } from "~/hooks/useSearchParam";
3534
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
3635
import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags";
36+
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
3737
import {
3838
appliedSummary,
3939
FilterMenuProvider,
@@ -399,6 +399,8 @@ function PermanentTagsFilter() {
399399
);
400400
}
401401

402+
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
403+
402404
function WaitpointIdDropdown(
403405
props: Omit<IdFilterDropdownProps, "label" | "placeholder" | "paramKey" | "validate">
404406
) {
@@ -408,11 +410,7 @@ function WaitpointIdDropdown(
408410
label="Waitpoint ID"
409411
placeholder="waitpoint_"
410412
paramKey="id"
411-
validate={(v) => {
412-
if (!v.startsWith("waitpoint_")) return "Waitpoint IDs start with 'waitpoint_'";
413-
if (!isClassifiable(v)) return "That doesn't look like a valid waitpoint ID";
414-
return undefined;
415-
}}
413+
validate={validateWaitpointId}
416414
/>
417415
);
418416
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
BatchId,
4+
generateFriendlyId,
5+
generateKsuidId,
6+
RunId,
7+
} from "@trigger.dev/core/v3/isomorphic";
8+
import { isValidFriendlyId, makeFriendlyIdValidator } from "./friendlyId";
9+
10+
describe("isValidFriendlyId", () => {
11+
it("accepts every id generation the real generators produce", () => {
12+
// nanoid (legacy V1), cuid (run-engine), ksuid (run-ops split)
13+
expect(isValidFriendlyId(generateFriendlyId("run"), "run")).toBe(true);
14+
expect(isValidFriendlyId(RunId.generate().friendlyId, "run")).toBe(true);
15+
expect(isValidFriendlyId(RunId.toFriendlyId(generateKsuidId()), "run")).toBe(true);
16+
17+
expect(isValidFriendlyId(generateFriendlyId("batch"), "batch")).toBe(true);
18+
expect(isValidFriendlyId(BatchId.generate().friendlyId, "batch")).toBe(true);
19+
expect(isValidFriendlyId(BatchId.toFriendlyId(generateKsuidId()), "batch")).toBe(true);
20+
});
21+
22+
it("accepts each valid body length (21 nanoid, 25 cuid, 27 ksuid)", () => {
23+
expect(isValidFriendlyId("run_" + "a".repeat(21), "run")).toBe(true);
24+
expect(isValidFriendlyId("run_" + "a".repeat(25), "run")).toBe(true);
25+
expect(isValidFriendlyId("run_" + "a".repeat(27), "run")).toBe(true);
26+
});
27+
28+
it("accepts mixed-case (uppercase) ksuid bodies", () => {
29+
expect(isValidFriendlyId("run_2ABCdefGHI0123456789jklMN", "run")).toBe(true);
30+
});
31+
32+
it("rejects the wrong prefix", () => {
33+
expect(isValidFriendlyId(RunId.generate().friendlyId, "batch")).toBe(false);
34+
expect(isValidFriendlyId("batch_" + "a".repeat(25), "run")).toBe(false);
35+
});
36+
37+
it("rejects a bare (unprefixed) id", () => {
38+
expect(isValidFriendlyId("a".repeat(25), "run")).toBe(false);
39+
});
40+
41+
it("rejects body lengths that match no generator", () => {
42+
for (const len of [0, 20, 22, 24, 26, 28]) {
43+
expect(isValidFriendlyId("run_" + "a".repeat(len), "run")).toBe(false);
44+
}
45+
});
46+
47+
it("rejects non-base62 characters in the body", () => {
48+
expect(isValidFriendlyId("run_" + "-".repeat(25), "run")).toBe(false);
49+
expect(isValidFriendlyId("run_" + "!".repeat(25), "run")).toBe(false);
50+
// an underscore in the body is not base62
51+
expect(isValidFriendlyId("run_" + "a".repeat(24) + "_", "run")).toBe(false);
52+
});
53+
54+
it("does not treat the prefix separator as optional", () => {
55+
// "runX..." shares the "run" prefix but not the "run_" marker
56+
expect(isValidFriendlyId("run" + "a".repeat(25), "run")).toBe(false);
57+
});
58+
});
59+
60+
describe("makeFriendlyIdValidator", () => {
61+
const validateRunId = makeFriendlyIdValidator("run", "Run");
62+
const validateBatchId = makeFriendlyIdValidator("batch", "Batch");
63+
64+
it("returns undefined for a valid id of any generation", () => {
65+
expect(validateRunId(generateFriendlyId("run"))).toBeUndefined();
66+
expect(validateRunId(RunId.generate().friendlyId)).toBeUndefined();
67+
expect(validateRunId(RunId.toFriendlyId(generateKsuidId()))).toBeUndefined();
68+
expect(validateBatchId(BatchId.toFriendlyId(generateKsuidId()))).toBeUndefined();
69+
});
70+
71+
it("reports a wrong prefix distinctly from a wrong shape", () => {
72+
expect(validateRunId("batch_" + "a".repeat(25))).toBe("Run IDs start with 'run_'");
73+
expect(validateRunId("run_" + "a".repeat(20))).toBe("That doesn't look like a valid run ID");
74+
});
75+
76+
it("derives the marker and label per entity", () => {
77+
const validateWaitpointId = makeFriendlyIdValidator("waitpoint", "Waitpoint");
78+
expect(validateWaitpointId("run_" + "a".repeat(25))).toBe(
79+
"Waitpoint IDs start with 'waitpoint_'"
80+
);
81+
expect(validateWaitpointId("waitpoint_" + "a".repeat(20))).toBe(
82+
"That doesn't look like a valid waitpoint ID"
83+
);
84+
});
85+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { CUID_LENGTH, KSUID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
2+
3+
// The body after `<prefix>_` is a base62 id; three generator lengths remain
4+
// valid in existing data and must all be accepted: 21 (nanoid), 25 (cuid),
5+
// 27 (ksuid). cuid/ksuid come from core so this tracks any future change.
6+
const NANOID_BODY_LENGTH = 21;
7+
const VALID_BODY_LENGTHS: ReadonlySet<number> = new Set([
8+
NANOID_BODY_LENGTH,
9+
CUID_LENGTH,
10+
KSUID_LENGTH,
11+
]);
12+
13+
const BASE62 = /^[0-9A-Za-z]+$/;
14+
15+
export function isValidFriendlyId(value: string, prefix: string): boolean {
16+
const marker = `${prefix}_`;
17+
if (!value.startsWith(marker)) return false;
18+
const body = value.slice(marker.length);
19+
return VALID_BODY_LENGTHS.has(body.length) && BASE62.test(body);
20+
}
21+
22+
export function makeFriendlyIdValidator(prefix: string, label: string) {
23+
const marker = `${prefix}_`;
24+
return (value: string): string | undefined => {
25+
if (!value.startsWith(marker)) return `${label} IDs start with '${marker}'`;
26+
if (!isValidFriendlyId(value, prefix)) {
27+
return `That doesn't look like a valid ${label.toLowerCase()} ID`;
28+
}
29+
return undefined;
30+
};
31+
}

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default defineConfig({
1616
"app/v3/services/bulk/**/*.test.ts",
1717
"app/runEngine/concerns/**/*.test.ts",
1818
"app/runEngine/services/**/*.test.ts",
19+
"app/utils/**/*.test.ts",
1920
],
2021
// *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
2122
// *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts

0 commit comments

Comments
 (0)