zod -> valibot (#3364)

This commit is contained in:
Kalle
2026-08-21 17:36:48 +03:00
committed by GitHub
parent a21e43c6b2
commit 4f64b9ec01
246 changed files with 4173 additions and 3455 deletions

View File

@@ -1,4 +1,4 @@
import { z } from "zod";
import * as v from "valibot";
import {
TIER_1_ID,
TIER_2_ID,
@@ -7,68 +7,74 @@ import {
UNKNOWN_TIER_ID,
} from "./constants";
export const patreonRateLimitSchema = z.object({
errors: z.array(
z.object({
retry_after_seconds: z.number().optional(),
export const patreonRateLimitSchema = v.object({
errors: v.array(
v.object({
retry_after_seconds: v.optional(v.number()),
}),
),
});
export const patronResponseSchema = z.object({
data: z.array(
z.object({
attributes: z.object({
pledge_relationship_start: z.string().nullish(),
export const patronResponseSchema = v.object({
data: v.array(
v.object({
attributes: v.object({
pledge_relationship_start: v.nullish(v.string()),
}),
id: z.string(),
relationships: z.object({
currently_entitled_tiers: z.object({
data: z.array(
z.object({
id: z.enum([
id: v.string(),
relationships: v.object({
currently_entitled_tiers: v.object({
data: v.array(
v.object({
id: v.picklist([
TIER_1_ID,
TIER_2_ID,
TIER_3_ID,
TIER_4_ID,
UNKNOWN_TIER_ID,
]),
type: z.string(),
type: v.string(),
}),
),
}),
user: z.object({
data: z.object({ id: z.string(), type: z.string() }),
links: z.object({ related: z.string() }),
user: v.object({
data: v.object({ id: v.string(), type: v.string() }),
links: v.object({ related: v.string() }),
}),
}),
type: z.string(),
type: v.string(),
}),
),
included: z
.array(
z.object({
attributes: z.object({
social_connections: z
.object({
discord: z
.object({
user_id: z.string(),
})
.nullish(),
})
.nullish(),
included: v.optional(
v.nullable(
v.array(
v.object({
attributes: v.object({
social_connections: v.optional(
v.nullable(
v.object({
discord: v.optional(
v.nullable(
v.object({
user_id: v.string(),
}),
),
),
}),
),
),
}),
id: v.string(),
type: v.string(),
}),
id: z.string(),
type: z.string(),
}),
)
.nullish(),
links: z.object({ next: z.string() }).nullish(),
meta: z.object({
pagination: z.object({
cursors: z.object({ next: z.string().nullish() }),
total: z.number(),
),
),
),
links: v.nullish(v.object({ next: v.string() })),
meta: v.object({
pagination: v.object({
cursors: v.object({ next: v.nullish(v.string()) }),
total: v.number(),
}),
}),
});

View File

@@ -1,4 +1,4 @@
import type { z } from "zod";
import * as v from "valibot";
import { ServerConfig } from "~/config.server";
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
@@ -77,10 +77,10 @@ async function fetchPatronData(urlToFetch: string) {
);
}
const parsed = patreonRateLimitSchema.safeParse(await response.json());
const parsed = v.safeParse(patreonRateLimitSchema, await response.json());
const retryAfterSeconds = Math.min(
parsed.success
? (parsed.data.errors[0]?.retry_after_seconds ??
? (parsed.output.errors[0]?.retry_after_seconds ??
DEFAULT_RETRY_AFTER_SECONDS)
: DEFAULT_RETRY_AFTER_SECONDS,
MAX_RETRY_AFTER_SECONDS,
@@ -99,7 +99,7 @@ async function fetchPatronData(urlToFetch: string) {
);
}
return patronResponseSchema.parse(await response.json());
return v.parse(patronResponseSchema, await response.json());
}
throw new Error("Unexpected end of fetch retry loop");
@@ -112,7 +112,7 @@ function sleep(ms: number) {
function parsePatronData({
data,
included,
}: z.infer<typeof patronResponseSchema>) {
}: v.InferOutput<typeof patronResponseSchema>) {
const patronsWithIds: Array<
{
patreonId: string;

View File

@@ -1,20 +1,20 @@
import * as v from "valibot";
import { afterEach, describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { z } from "zod";
import { usePersistedMapState, usePersistedState } from "./hooks";
import * as PersistedState from "./persisted-state";
const recentIds = PersistedState.define({
key: "test-recent-ids",
storage: "local",
schema: z.array(z.number()),
schema: v.array(v.number()),
default: [],
});
const counts = PersistedState.defineMap({
keyPrefix: "test-counts__",
storage: "local",
schema: z.number(),
schema: v.number(),
default: 0,
});

View File

@@ -1,5 +1,5 @@
import * as v from "valibot";
import { describe, expect, test } from "vitest";
import { z } from "zod";
import * as PersistedState from "./persisted-state";
import {
assertDecodesToDefault,
@@ -9,28 +9,28 @@ import {
const numberList = PersistedState.define({
key: "test-number-list",
storage: "local",
schema: z.array(z.number()),
schema: v.array(v.number()),
default: [],
});
const searchType = PersistedState.define({
key: "test-search-type",
storage: "local",
schema: z.enum(["weapons", "users"]),
schema: v.picklist(["weapons", "users"]),
default: "weapons",
});
const dismissed = PersistedState.define({
key: "test-dismissed",
storage: "local",
schema: z.boolean(),
schema: v.boolean(),
default: false,
});
const counts = PersistedState.defineMap({
keyPrefix: "test-counts__",
storage: "local",
schema: z.number(),
schema: v.number(),
default: 0,
});

View File

@@ -1,4 +1,5 @@
import type { z } from "zod";
import * as v from "valibot";
import type { AnySyncSchema } from "~/utils/schema";
const readCaches = new WeakMap<
object,
@@ -34,12 +35,12 @@ export interface PersistedMapDefinition<T> extends DefinitionBase<T> {
* Decoding is total: the default resolves for missing or malformed values,
* legacy plain-string values are accepted where the schema allows them.
*/
export function define<S extends z.ZodType>(options: {
export function define<S extends AnySyncSchema>(options: {
key: string;
storage: StorageKind;
schema: S;
default: z.output<S>;
}): PersistedDefinition<z.output<S>> {
default: v.InferOutput<S>;
}): PersistedDefinition<v.InferOutput<S>> {
return {
key: options.key,
storage: options.storage,
@@ -52,12 +53,12 @@ export function define<S extends z.ZodType>(options: {
* Declares a keyed family of persisted values sharing a storage key prefix,
* for maps whose entries are written independently (e.g. per chat room).
*/
export function defineMap<S extends z.ZodType>(options: {
export function defineMap<S extends AnySyncSchema>(options: {
keyPrefix: string;
storage: StorageKind;
schema: S;
default: z.output<S>;
}): PersistedMapDefinition<z.output<S>> {
default: v.InferOutput<S>;
}): PersistedMapDefinition<v.InferOutput<S>> {
return {
keyPrefix: options.keyPrefix,
storage: options.storage,
@@ -181,14 +182,17 @@ export function prependToRecentList<T>(
);
}
function codec<S extends z.ZodType>(schema: S, defaultValue: z.output<S>) {
function codec<S extends AnySyncSchema>(
schema: S,
defaultValue: v.InferOutput<S>,
) {
return {
decode: (raw: string | null): z.output<S> => {
decode: (raw: string | null): v.InferOutput<S> => {
if (raw === null) return defaultValue;
const parsed = schema.safeParse(rawToJson(raw));
return parsed.success ? parsed.data : defaultValue;
const parsed = v.safeParse(schema, rawToJson(raw));
return parsed.success ? parsed.output : defaultValue;
},
encode: (value: z.output<S>) => JSON.stringify(value),
encode: (value: v.InferOutput<S>) => JSON.stringify(value),
};
}

View File

@@ -1,21 +1,27 @@
import * as React from "react";
import { createBrowserRouter, RouterProvider } from "react-router";
import * as v from "valibot";
import { afterEach, describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { z } from "zod";
import { useSearchParam, useSearchParamsTyped } from "./hooks";
import * as SearchParams from "./search-params";
import { SP } from "./search-params";
const definition = SearchParams.define({
page: SP.param(z.number().int().min(1), { default: 1, loader: true }),
filters: SP.json(z.object({ q: z.string() }), {
page: SP.param(v.pipe(v.number(), v.integer(), v.minValue(1)), {
default: 1,
loader: true,
}),
filters: SP.json(v.object({ q: v.string() }), {
default: { q: "" },
loader: true,
resets: ["page"],
}),
view: SP.param(z.enum(["list", "grid"]), { default: "list", loader: false }),
other: SP.param(z.number(), { default: 0, loader: false }),
view: SP.param(v.picklist(["list", "grid"]), {
default: "list",
loader: false,
}),
other: SP.param(v.number(), { default: 0, loader: false }),
});
let loaderCalls = 0;

View File

@@ -1,6 +1,6 @@
import type { ShouldRevalidateFunction } from "react-router";
import * as v from "valibot";
import { describe, expect, test } from "vitest";
import { z } from "zod";
import * as SearchParams from "./search-params";
import { SP } from "./search-params";
import {
@@ -9,26 +9,34 @@ import {
} from "./search-params-test-utils";
const testDefinition = SearchParams.define({
limit: SP.param(z.number().int().min(1).max(100), {
default: 24,
limit: SP.param(
v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)),
{
default: 24,
loader: true,
},
),
name: SP.param(v.pipe(v.string(), v.maxLength(20)), {
default: "",
loader: true,
}),
name: SP.param(z.string().max(20), { default: "", loader: true }),
enabled: SP.param(z.boolean(), { default: false, loader: false }),
mode: SP.param(z.enum(["TW", "SZ", "TC"]), {
enabled: SP.param(v.boolean(), { default: false, loader: false }),
mode: SP.param(v.picklist(["TW", "SZ", "TC"]), {
default: "TW",
loader: true,
}),
season: SP.param(z.number().int().nullable(), { loader: true }),
ids: SP.param(z.array(z.number().int().positive()), {
season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
loader: true,
}),
ids: SP.param(v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
default: [],
loader: false,
}),
filters: SP.json(
z.object({ minValue: z.number(), tags: z.array(z.string()) }),
v.object({ minValue: v.number(), tags: v.array(v.string()) }),
{ default: { minValue: 0, tags: [] }, loader: true, resets: ["limit"] },
),
blob: SP.json(z.object({ text: z.string() }), {
blob: SP.json(v.object({ text: v.string() }), {
default: { text: "" },
loader: false,
compress: true,
@@ -112,7 +120,7 @@ describe("SearchParams.define", () => {
test("decodes legacy JSON-encoded arrays", () => {
const definitionWithModes = SearchParams.define({
modes: SP.param(z.array(z.enum(["SZ", "TC", "RM", "CB"])), {
modes: SP.param(v.array(v.picklist(["SZ", "TC", "RM", "CB"])), {
default: ["SZ", "TC", "RM", "CB"],
loader: false,
}),
@@ -143,19 +151,25 @@ describe("SearchParams.define", () => {
test("rejects schemas outside the derivation table at define time", () => {
expect(() =>
SP.param(z.object({ a: z.string() }) as any, {
SP.param(v.object({ a: v.string() }) as any, {
default: { a: "" },
loader: true,
}),
).toThrow(/derive/);
expect(() =>
SP.param(z.string().transform((s) => s.length) as any, {
default: 0,
loader: true,
}),
SP.param(
v.pipe(
v.string(),
v.transform((s) => s.length),
) as any,
{
default: 0,
loader: true,
},
),
).toThrow(/derive/);
expect(() =>
SP.param(z.array(z.array(z.number())) as any, {
SP.param(v.array(v.array(v.number())) as any, {
default: [],
loader: true,
}),
@@ -163,8 +177,10 @@ describe("SearchParams.define", () => {
});
test("defaults .nullable() params to null without declaring it", () => {
const omitted = SP.param(z.number().int().nullable(), { loader: true });
const declared = SP.param(z.number().int().nullable(), {
const omitted = SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
loader: true,
});
const declared = SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
default: null,
loader: true,
});
@@ -178,31 +194,26 @@ describe("SearchParams.define", () => {
test("rejects .optional() and non-null defaults for .nullable()", () => {
expect(() =>
SP.param(z.number().optional() as any, { default: 1, loader: true }),
SP.param(v.optional(v.number()) as any, { default: 1, loader: true }),
).toThrow(/nullable/);
expect(() =>
SP.param(z.number().nullable(), { default: 1 as any, loader: true }),
SP.param(v.nullable(v.number()), { default: 1 as any, loader: true }),
).toThrow(/null as its default/);
});
test("supports SP.custom codecs with total decode via issues", () => {
const isoDate = z.codec(z.string(), z.date(), {
decode: (value, payload) => {
test("supports SP.custom codecs with total decode", () => {
const isoDate = SearchParams.codec(v.date(), {
decode: (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
payload.issues.push({
code: "custom",
message: "invalid date",
input: value,
});
return z.NEVER;
}
return date;
return Number.isNaN(date.getTime()) ? undefined : date;
},
encode: (date) => date.toISOString(),
});
const customDefinition = SearchParams.define({
from: SP.custom(isoDate.nullable(), { default: null, loader: true }),
from: SP.custom(SearchParams.nullableCodec(isoDate), {
default: null,
loader: true,
}),
});
const value = new Date("2024-05-01T12:00:00.000Z");
@@ -220,7 +231,7 @@ describe("SearchParams.define", () => {
test("rejects resets pointing at unknown params", () => {
expect(() =>
SearchParams.define({
a: SP.param(z.number(), { default: 0, loader: true, resets: ["b"] }),
a: SP.param(v.number(), { default: 0, loader: true, resets: ["b"] }),
}),
).toThrow(/unknown param/);
});
@@ -310,7 +321,7 @@ describe("SearchParams.href", () => {
test("encodes arrays as repeated keys and empty arrays as one empty value", () => {
const definitionWithDefault = SearchParams.define({
modes: SP.param(z.array(z.enum(["SZ", "TC"])), {
modes: SP.param(v.array(v.picklist(["SZ", "TC"])), {
default: ["SZ", "TC"],
loader: false,
}),

View File

@@ -1,8 +1,10 @@
import type { ShouldRevalidateFunction } from "react-router";
import { isDeepEqual } from "remeda";
import { z } from "zod";
import * as v from "valibot";
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
type AnySchema = v.GenericSchema<any, any>;
const COMPRESSED_PREFIX = "lz~";
const ESCAPED_PREFIX = "lz~~";
const DECODE_CACHE_MAX_SIZE = 300;
@@ -253,6 +255,56 @@ export function pickRelevantSearch(keys: string[], search: string): string {
return picked.toString();
}
/** Bidirectional URL encoding for an `SP.custom` param. */
export interface ParamCodec<Value> {
/** Decodes a plain URL value; `undefined` means malformed, resolving the param to its default. */
decode: (plain: string) => Value | undefined;
/** Encodes a value to its canonical plain URL form. Must succeed for every value of the type. */
encode: (value: Value) => string;
}
/**
* Creates a {@link ParamCodec} whose decode result is validated against
* `schema`. The `decode` implementation returns `undefined` (or any value the
* schema rejects) for malformed input.
*/
export function codec<TSchema extends AnySchema>(
schema: TSchema,
impl: {
decode: (encoded: string) => unknown;
encode: (value: v.InferOutput<TSchema>) => string;
},
): ParamCodec<v.InferOutput<TSchema>> {
return {
decode: (encoded) => {
const parsed = v.safeParse(schema, impl.decode(encoded));
return parsed.success ? parsed.output : undefined;
},
encode: impl.encode,
};
}
/**
* Widens a codec to also accept `null` as its value. `null` must be the
* param's default, so it never reaches `encode` (encoding the default omits
* the param from the URL).
*/
export function nullableCodec<Value>(
inner: ParamCodec<Value>,
): ParamCodec<Value | null> {
return {
decode: inner.decode,
encode: (value) => {
if (value === null) {
throw new Error(
"Cannot encode null; a nullable search param's default is null, which is omitted from the URL",
);
}
return inner.encode(value);
},
};
}
/**
* Param declaration helpers. `SP.param` is the canonical declaration deriving
* the URL encoding from the value schema; `SP.json` and `SP.custom` are the
@@ -260,42 +312,43 @@ export function pickRelevantSearch(keys: string[], search: string): string {
*/
export const SP = {
/**
* Declares a param whose URL encoding is derived from the zod value
* Declares a param whose URL encoding is derived from the valibot value
* schema's type tree. Supported shapes: strings, numbers, booleans, string
* and number enums/literals, same-base-type unions, arrays of those
* (encoded as repeated keys) and a top-level `.nullable()` wrapper (`null`
* encodes as param absent, so `default` is omitted for those). Anything else
* is a `define()`-time error — use `SP.json` or `SP.custom` instead.
*/
param<S extends z.ZodType>(
param<S extends AnySchema>(
schema: S,
opts: ParamOptions<z.output<S>>,
): ParamDef<z.output<S>> {
opts: ParamOptions<v.InferOutput<S>>,
): ParamDef<v.InferOutput<S>> {
const resolved = resolveOptions(opts);
let core: z.ZodType = schema;
let core: AnySchema = schema;
if (core instanceof z.ZodOptional) {
if (core.type === "optional" || core.type === "nullish") {
throw new Error(
"Search params use .nullable() instead of .optional() (null encodes as param absent)",
"Search params use v.nullable() instead of v.optional() (null encodes as param absent)",
);
}
if (core instanceof z.ZodNullable) {
if (core.type === "nullable") {
if (resolved.default !== null) {
throw new Error(
"A .nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL",
"A v.nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL",
);
}
core = core.unwrap() as z.ZodType;
core = (core as unknown as { wrapped: AnySchema }).wrapped;
}
if (core instanceof z.ZodArray) {
const itemBase = deriveScalarBase(core.element as z.ZodType);
if (core.type === "array") {
const itemSchema = (core as unknown as { item: AnySchema }).item;
const itemBase = deriveScalarBase(itemSchema);
if (!itemBase) {
throw new Error(
`Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(core.element as z.ZodType)}). Use SP.json or SP.custom.`,
`Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(itemSchema)}). Use SP.json or SP.custom.`,
);
}
return arrayParam(schema, core, itemBase, resolved);
return arrayParam(schema, itemSchema, itemBase, resolved);
}
const base = deriveScalarBase(core);
@@ -310,20 +363,21 @@ export const SP = {
/** Declares the 1-based `page` param of a paginated route, as `useSearchParamPagination` expects it. */
page(opts?: { max?: number; resets?: string[] }): ParamDef<number> {
return SP.param(
z
.number()
.int()
.min(1)
.max(opts?.max ?? DEFAULT_MAX_PAGE),
v.pipe(
v.number(),
v.integer(),
v.minValue(1),
v.maxValue(opts?.max ?? DEFAULT_MAX_PAGE),
),
{ default: 1, loader: true, resets: opts?.resets },
);
},
/** Declares a param encoded as `JSON.stringify` in a single value. For objects and whole-array-as-one-param values. */
json<S extends z.ZodType>(
json<S extends AnySchema>(
schema: S,
opts: ParamOptions<z.output<S>>,
): ParamDef<z.output<S>> {
opts: ParamOptions<v.InferOutput<S>>,
): ParamDef<v.InferOutput<S>> {
const resolved = resolveOptions(opts);
return {
@@ -338,20 +392,20 @@ export const SP = {
} catch {
return resolved.default;
}
const parsed = schema.safeParse(json);
return parsed.success ? parsed.data : resolved.default;
const parsed = v.safeParse(schema, json);
return parsed.success ? parsed.output : resolved.default;
},
encodePlain: (value) => [JSON.stringify(value)],
};
},
/**
* Escape hatch: declares a param from a `z.codec(z.string(), valueSchema, ...)`
* Escape hatch: declares a param from a {@link ParamCodec} (see `codec`)
* passed directly. The codec's `decode` may accept legacy formats while
* `encode` always emits the canonical one.
*/
custom<Value>(
codec: z.ZodType<Value, string | null>,
paramCodec: ParamCodec<Value>,
opts: ParamOptions<Value>,
): ParamDef<Value> {
const resolved = resolveOptions(opts);
@@ -362,18 +416,10 @@ export const SP = {
if (values.length === 0) return resolved.default;
const plain = unwrapValue(values[0]);
if (plain === DECODE_FAILED) return resolved.default;
const parsed = z.safeDecode(codec, plain);
return parsed.success ? parsed.data : resolved.default;
},
encodePlain: (value) => {
const encoded = z.safeEncode(codec, value);
if (!encoded.success || typeof encoded.data !== "string") {
throw new Error(
"Encoding a search param value failed; SP.custom codecs must encode every value of their type",
);
}
return [encoded.data];
const decoded = paramCodec.decode(plain);
return decoded === undefined ? resolved.default : decoded;
},
encodePlain: (value) => [paramCodec.encode(value)],
};
},
};
@@ -403,7 +449,7 @@ function baseDef<T>(
}
function scalarParam<T>(
schema: z.ZodType,
schema: AnySchema,
base: ScalarBase,
opts: ResolvedParamOptions<T>,
): ParamDef<T> {
@@ -415,21 +461,19 @@ function scalarParam<T>(
if (plain === DECODE_FAILED) return opts.default;
const candidate = plainToScalar(plain, base);
if (candidate === DECODE_FAILED) return opts.default;
const parsed = schema.safeParse(candidate);
return parsed.success ? (parsed.data as T) : opts.default;
const parsed = v.safeParse(schema, candidate);
return parsed.success ? (parsed.output as T) : opts.default;
},
encodePlain: (value) => [String(value)],
};
}
function arrayParam<T>(
schema: z.ZodType,
arraySchema: z.ZodArray,
schema: AnySchema,
itemSchema: AnySchema,
itemBase: ScalarBase,
opts: ResolvedParamOptions<T>,
): ParamDef<T> {
const itemSchema = arraySchema.element as z.ZodType;
return {
...baseDef(opts),
decodeValues: (values) => {
@@ -463,12 +507,12 @@ function arrayParam<T>(
for (const item of items) {
const candidate = plainToScalar(item, itemBase);
if (candidate === DECODE_FAILED) continue;
const parsed = itemSchema.safeParse(candidate);
if (parsed.success) members.push(parsed.data);
const parsed = v.safeParse(itemSchema, candidate);
if (parsed.success) members.push(parsed.output);
}
const parsed = schema.safeParse(members);
return parsed.success ? (parsed.data as T) : opts.default;
const parsed = v.safeParse(schema, members);
return parsed.success ? (parsed.output as T) : opts.default;
},
encodePlain: (value) => {
const items = value as unknown[];
@@ -495,19 +539,22 @@ function plainToScalar(
return DECODE_FAILED;
}
function deriveScalarBase(schema: z.ZodType): ScalarBase | null {
if (schema instanceof z.ZodString) return "string";
if (schema instanceof z.ZodNumber) return "number";
if (schema instanceof z.ZodBoolean) return "boolean";
function deriveScalarBase(schema: AnySchema): ScalarBase | null {
if (hasNonValidationPipeItems(schema)) return null;
if (schema instanceof z.ZodEnum) {
return uniformTypeOf(schema.options);
if (schema.type === "string") return "string";
if (schema.type === "number") return "number";
if (schema.type === "boolean") return "boolean";
if (schema.type === "picklist" || schema.type === "enum") {
return uniformTypeOf((schema as unknown as { options: unknown[] }).options);
}
if (schema instanceof z.ZodLiteral) {
return uniformTypeOf(Array.from(schema.values));
if (schema.type === "literal") {
return uniformTypeOf([(schema as unknown as { literal: unknown }).literal]);
}
if (schema instanceof z.ZodUnion) {
const bases = (schema.options as z.ZodType[]).map(deriveScalarBase);
if (schema.type === "union") {
const options = (schema as unknown as { options: AnySchema[] }).options;
const bases = options.map(deriveScalarBase);
if (bases[0] && bases.every((base) => base === bases[0])) {
return bases[0];
}
@@ -517,6 +564,28 @@ function deriveScalarBase(schema: z.ZodType): ScalarBase | null {
return null;
}
/**
* A pipe may only add validations, metadata and type-preserving
* transformations (e.g. `v.trim()`) on top of its base schema. A custom
* transform or a nested schema changes the value's type, so the URL encoding
* cannot be derived from the base type.
*/
function hasNonValidationPipeItems(schema: AnySchema): boolean {
if (!("pipe" in schema)) return false;
const pipeItems = (
schema as unknown as { pipe: Array<{ kind: string; type: string }> }
).pipe;
return pipeItems
.slice(1)
.some(
(item) =>
item.kind === "schema" ||
item.type === "transform" ||
item.type === "raw_transform",
);
}
function uniformTypeOf(values: unknown[]): ScalarBase | null {
const types = new Set(values.map((value) => typeof value));
if (types.size !== 1) return null;
@@ -528,8 +597,8 @@ function uniformTypeOf(values: unknown[]): ScalarBase | null {
return null;
}
function describeSchema(schema: z.ZodType) {
return schema.constructor.name;
function describeSchema(schema: AnySchema) {
return schema.type;
}
function toSearchParams(

View File

@@ -1,62 +1,62 @@
import { z } from "zod";
import * as v from "valibot";
import type { Unpacked } from "~/utils/types";
export const streamsSchema = z.object({
data: z.array(
z.object({
id: z.string(),
user_id: z.string(),
user_login: z.string(),
user_name: z.string(),
game_id: z.string(),
game_name: z.string(),
type: z.string(),
title: z.string(),
viewer_count: z.number(),
started_at: z.string(),
language: z.string(),
thumbnail_url: z.string(),
tag_ids: z.array(z.unknown()),
tags: z.array(z.string()).nullish(),
is_mature: z.boolean(),
export const streamsSchema = v.object({
data: v.array(
v.object({
id: v.string(),
user_id: v.string(),
user_login: v.string(),
user_name: v.string(),
game_id: v.string(),
game_name: v.string(),
type: v.string(),
title: v.string(),
viewer_count: v.number(),
started_at: v.string(),
language: v.string(),
thumbnail_url: v.string(),
tag_ids: v.array(v.unknown()),
tags: v.nullish(v.array(v.string())),
is_mature: v.boolean(),
}),
),
pagination: z.object({ cursor: z.string().nullish() }),
pagination: v.object({ cursor: v.nullish(v.string()) }),
});
export const tokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
token_type: z.string(),
export const tokenResponseSchema = v.object({
access_token: v.string(),
expires_in: v.number(),
token_type: v.string(),
});
export const usersSchema = z.object({
data: z.array(
z.object({
id: z.string(),
login: z.string(),
display_name: z.string(),
export const usersSchema = v.object({
data: v.array(
v.object({
id: v.string(),
login: v.string(),
display_name: v.string(),
}),
),
});
export const videosSchema = z.object({
data: z.array(
z.object({
id: z.string(),
user_id: z.string(),
user_login: z.string(),
title: z.string(),
created_at: z.string(),
duration: z.string(),
view_count: z.number(),
type: z.string(),
export const videosSchema = v.object({
data: v.array(
v.object({
id: v.string(),
user_id: v.string(),
user_login: v.string(),
title: v.string(),
created_at: v.string(),
duration: v.string(),
view_count: v.number(),
type: v.string(),
}),
),
pagination: z.object({ cursor: z.string().nullish() }),
pagination: v.object({ cursor: v.nullish(v.string()) }),
});
export type StreamsResponse = z.infer<typeof streamsSchema>;
export type RawStream = Unpacked<z.infer<typeof streamsSchema>["data"]>;
export type UsersResponse = z.infer<typeof usersSchema>;
export type RawVideo = Unpacked<z.infer<typeof videosSchema>["data"]>;
export type StreamsResponse = v.InferOutput<typeof streamsSchema>;
export type RawStream = Unpacked<v.InferOutput<typeof streamsSchema>["data"]>;
export type UsersResponse = v.InferOutput<typeof usersSchema>;
export type RawVideo = Unpacked<v.InferOutput<typeof videosSchema>["data"]>;

View File

@@ -1,4 +1,5 @@
import { cachified } from "@epic-web/cachified";
import * as v from "valibot";
import { cache } from "~/utils/cache.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { logger } from "~/utils/logger";
@@ -138,10 +139,10 @@ async function getStreamsChunk(cursor?: string): Promise<StreamsResponse> {
}`,
);
const parsed = streamsSchema.safeParse(await res.json());
const parsed = v.safeParse(streamsSchema, await res.json());
if (!parsed.success) {
throw new Error(parsed.error.message);
throw new Error(v.summarize(parsed.issues));
}
return parsed.data;
return parsed.output;
}

View File

@@ -1,4 +1,5 @@
import { cachified } from "@epic-web/cachified";
import * as v from "valibot";
import { cache } from "~/utils/cache.server";
import { tokenResponseSchema } from "./schemas";
import { getTwitchEnvVars } from "./utils.server";
@@ -16,12 +17,12 @@ async function getFreshToken() {
);
}
const parsed = tokenResponseSchema.safeParse(await res.json());
const parsed = v.safeParse(tokenResponseSchema, await res.json());
if (!parsed.success) {
throw new Error("Token response schema validation failed");
}
return parsed.data.access_token;
return parsed.output.access_token;
}
export function getToken() {

View File

@@ -1,4 +1,5 @@
import * as R from "remeda";
import * as v from "valibot";
import { twitchFetch } from "./fetch";
import {
type RawVideo,
@@ -21,14 +22,14 @@ export async function getUsersByLogin(
`https://api.twitch.tv/helix/users?${params}`,
);
const parsed = usersSchema.safeParse(await res.json());
const parsed = v.safeParse(usersSchema, await res.json());
if (!parsed.success) {
throw new Error(
`Twitch users schema validation failed: ${parsed.error.message}`,
`Twitch users schema validation failed: ${v.summarize(parsed.issues)}`,
);
}
results.push(...parsed.data.data);
results.push(...parsed.output.data);
}
return results;
@@ -49,17 +50,17 @@ export async function getArchiveVideos(userId: string): Promise<RawVideo[]> {
const res = await twitchFetch(url.toString());
const parsed = videosSchema.safeParse(await res.json());
const parsed = v.safeParse(videosSchema, await res.json());
if (!parsed.success) {
throw new Error(
`Twitch videos schema validation failed: ${parsed.error.message}`,
`Twitch videos schema validation failed: ${v.summarize(parsed.issues)}`,
);
}
results.push(...parsed.data.data);
results.push(...parsed.output.data);
if (!parsed.data.pagination.cursor) break;
cursor = parsed.data.pagination.cursor;
if (!parsed.output.pagination.cursor) break;
cursor = parsed.output.pagination.cursor;
}
return results;