convex
Version:
Client for the Convex Cloud
566 lines (517 loc) • 18.4 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unused-vars */
import { test, describe, expect } from "vitest";
import { assert, Equals } from "../test/type_testing.js";
import { v } from "../values/validator.js";
import {
ActionBuilder,
ApiFromModules,
DefaultFunctionArgs,
QueryBuilder,
actionGeneric,
mutationGeneric,
queryGeneric,
} from "./index.js";
import { EmptyObject, MutationBuilder } from "./registration.js";
describe("argument inference", () => {
// Test with mutation, but all the wrappers work the same way.
const mutation: MutationBuilder<any, "public"> = (() => {
// Intentional noop. We're only testing the type
}) as any;
const module = {
inlineNoArg: mutation(() => "result"),
inlineUntypedArg: mutation((_ctx, { _arg }) => "result"),
inlineTypedArg: mutation((_ctx, { _arg }: { _arg: string }) => "result"),
// There are unusual syntaxes.
inlineUntypedDefaultArg: mutation(
(_ctx, { _arg } = { _arg: 1 }) => "result",
),
inlineTypedDefaultArg: mutation(
// @ts-expect-error This syntax has never been allowed.
(_ctx, { _arg }: { _arg: string } = { _arg: "default" }) => "result",
),
inlineTypedOptionalDefaultArg: mutation(
(_ctx, { _arg }: { _arg?: string } = { _arg: "default" }) => "result",
),
configNoArg: mutation({
handler: () => "result",
}),
configValidatorNoArg: mutation({
args: {},
handler: () => "result",
}),
configUntypedArg: mutation({
handler: (_, { arg }) => {
assert<Equals<typeof arg, unknown>>;
return "result";
},
}),
configTypedArg: mutation({
handler: (_, { arg }: { arg: string }) => {
assert<Equals<typeof arg, string>>;
return "result";
},
}),
configOptionalValidatorUntypedArg: mutation({
args: {
arg: v.optional(v.string()),
},
handler: (_, { arg }) => {
assert<Equals<typeof arg, string | undefined>>;
return "result";
},
}),
configValidatorUntypedArg: mutation({
args: {
arg: v.string(),
},
handler: (_, { arg }: { arg: string }) => {
assert<Equals<typeof arg, string>>;
return "result";
},
}),
configValidatorTypedArg: mutation({
args: {
arg: v.string(),
},
handler: (_, { arg }: { arg: string }) => {
assert<Equals<typeof arg, string>>;
return "result";
},
}),
configValidatorMismatchedTypedArg: mutation({
args: {
_arg: v.number(),
},
// @ts-expect-error The arg type mismatches
handler: (_, { _arg }: { _arg: string }) => {
return "result";
},
}),
configValidatorReturn: mutation({
args: {
_arg: v.number(),
},
returns: v.number(),
// @ts-expect-error The return type mismatches
handler: (_, { _arg }) => {
return "result";
},
}),
// These are unusual syntaxes. We'd like to break some of them.
// Let's document them here so it's clear when we do that.
configUntypedDefaultArg: mutation({
handler: (_, { arg } = { arg: "default" }) => {
assert<Equals<typeof arg, unknown>>;
return "result";
},
}),
configTypedDefaultArg: mutation({
// @ts-expect-error This syntax has never been allowed.
handler: (_, { arg }: { arg: string } = { arg: "default" }) => {
assert<Equals<typeof arg, string>>;
return "result";
},
}),
configTypedOptionalDefaultArg: mutation({
// This syntax is incidentally allowed, it is not supported.
handler: (_, { arg }: { arg?: string } = { arg: "default" }) => {
assert<Equals<typeof arg, string | undefined>>;
return "result";
},
}),
configValidatorUntypedDefaultArg: mutation({
args: {
arg: v.string(),
},
handler: (_, { arg } = { arg: "default" }) => {
assert<Equals<typeof arg, string>>;
return "result";
},
}),
configValidatorTypedDefaultArg: mutation({
args: {
arg: v.string(),
},
handler: (_, { arg }: { arg: string } = { arg: "default" }) => {
assert<Equals<typeof arg, string>>;
return "result";
},
}),
configValidatorTypedOptionalDefaultArg: mutation({
args: {
arg: v.string(),
},
handler: (_, { arg }: { arg?: string } = { arg: "default" }) => {
assert<Equals<typeof arg, string | undefined>>;
return "result";
},
}),
};
type API = ApiFromModules<{ module: typeof module }>;
test("inline with no arg", () => {
type Args = API["module"]["inlineNoArg"]["_args"];
assert<Equals<Args, EmptyObject>>();
type ReturnType = API["module"]["inlineNoArg"]["_returnType"];
assert<Equals<ReturnType, string>>();
});
test("inline with untyped arg", () => {
type Args = API["module"]["inlineUntypedArg"]["_args"];
type ExpectedArgs = DefaultFunctionArgs;
assert<Equals<Args, ExpectedArgs>>;
});
test("inline with typed arg", () => {
type Args = API["module"]["inlineTypedArg"]["_args"];
type ExpectedArgs = { _arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
// This is not a very useful type (allows any key) but let's
// test it so we know if it's changing.
test("inline with untyped arg with default value", () => {
type Args = API["module"]["inlineUntypedDefaultArg"]["_args"];
type ExpectedArgs = DefaultFunctionArgs | EmptyObject;
assert<Equals<Args, ExpectedArgs>>;
});
// This syntax is a type error where it is defined so it falls back.
test("inline with typed arg with default value", () => {
type Args = API["module"]["inlineTypedDefaultArg"]["_args"];
type ExpectedArgs = Record<string, unknown>;
assert<Equals<Args, ExpectedArgs>>;
});
// This is not a very useful type (allows any key) but add let's
// test it so we know if it's changing.
test("inline with typed arg with optional default value", () => {
type Args = API["module"]["inlineTypedOptionalDefaultArg"]["_args"];
type ExpectedArgs = DefaultFunctionArgs | EmptyObject;
assert<Equals<Args, ExpectedArgs>>;
});
test("config with no arg", () => {
type Args = API["module"]["configNoArg"]["_args"];
type ExpectedArgs = EmptyObject;
assert<Equals<Args, ExpectedArgs>>;
});
test("config with no arg and validator", () => {
type Args = API["module"]["configValidatorNoArg"]["_args"];
type ExpectedArgs = {};
assert<Equals<Args, ExpectedArgs>>;
});
test("config with untyped arg", () => {
type Args = API["module"]["configUntypedArg"]["_args"];
type ExpectedArgs = DefaultFunctionArgs;
assert<Equals<Args, ExpectedArgs>>;
});
test("config with typed arg", () => {
type Args = API["module"]["configTypedArg"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
test("config with untyped arg and validator", () => {
type Args = API["module"]["configValidatorUntypedArg"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
test("config with untyped arg and optional validator", () => {
type Args = API["module"]["configOptionalValidatorUntypedArg"]["_args"];
type ExpectedArgs = { arg?: string };
assert<Equals<Args, ExpectedArgs>>;
});
test("config with typed arg and validator", () => {
type Args = API["module"]["configValidatorTypedArg"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
test("config with untyped arg and a default", () => {
type Args = API["module"]["configUntypedDefaultArg"]["_args"];
// This is not a very useful type
type ExpectedArgs = DefaultFunctionArgs | EmptyObject;
assert<Equals<Args, ExpectedArgs>>;
});
test("config with typed arg and a default", () => {
type Args = API["module"]["configTypedDefaultArg"]["_args"];
// This is a type error at the definition site so this is the fallback.
type ExpectedArgs = Record<string, unknown>;
assert<Equals<Args, ExpectedArgs>>;
});
test("config with typed optional arg and a default", () => {
type Args = API["module"]["configTypedOptionalDefaultArg"]["_args"];
// This is not a very useful type
type ExpectedArgs = DefaultFunctionArgs | EmptyObject;
assert<Equals<Args, ExpectedArgs>>;
});
test("config with untyped arg and a validator and a default", () => {
type Args = API["module"]["configValidatorUntypedDefaultArg"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
test("config with typed arg and a validator and a default", () => {
type Args = API["module"]["configValidatorTypedDefaultArg"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
test("config with typed optional arg and a validator and a default", () => {
type Args =
API["module"]["configValidatorTypedOptionalDefaultArg"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
});
});
describe("argument inference 2: args and validators", () => {
// Test with mutation, but all the wrappers work the same way.
const mutation: MutationBuilder<any, "public"> = (() => {
// Intentional noop. We're only testing the type
}) as any;
const module = {
argsSubtypeOfArgsValidator: mutation({
args: { foo: v.string() },
handler: (_, { foo }: { foo: string | number }) => 1,
}),
argsSupertypeOfArgsValidator: mutation({
args: { foo: v.union(v.string(), v.number()) },
handler: (_, { foo }: { foo: string }) => 1,
}),
argsPartiallyIntersectArgsValidator: mutation({
args: { foo: v.union(v.string(), v.number()) },
// @ts-expect-error when neither type is a subtype of the other, error
handler: (_, { foo }: { foo: string | Array<number> }) => 1,
}),
argsDontIntersectArgsValidator: mutation({
args: { foo: v.string() },
// @ts-expect-error when neither type is a subtype of the other, error
handler: (_, { foo }: { foo: number }) => 1,
}),
};
type API = ApiFromModules<{ module: typeof module }>;
// If the validator is more specific, that's the type that will be used.
// That seems right.
test("Handler args are a subtype of args validator", () => {
type Args = API["module"]["argsSubtypeOfArgsValidator"]["_args"];
assert<Equals<Args, { foo: string }>>();
});
// If the argument type is more specific, that's the type used.
// It would be nice to change this. If the validator type
// could entirely determine the inferred type on the client
// then you could skip the function for inference!
test("Handler args are a supertype of args validator", () => {
type Args = API["module"]["argsSupertypeOfArgsValidator"]["_args"];
assert<Equals<Args, { foo: string }>>();
});
});
describe("argument inference 3: outputs and validators", () => {
// Test with mutation, but all the wrappers work the same way.
const mutation: MutationBuilder<any, "public"> = (() => {
// Intentional noop. We're only testing the type
}) as any;
const module = {
returnSubtypeOfReturnsValidator: mutation({
returns: v.string(),
handler: (_) => "a" as const,
}),
returnSupertypeOfReturnsValidator: mutation({
returns: v.literal("a"),
// @ts-expect-error when return value is not a subtype of validator, error
handler: (_) => "b" as "b" | "a",
}),
returnPartiallyIntersectReturnsValidator: mutation({
returns: v.union(v.literal("a"), v.literal("b")),
// @ts-expect-error when return value is not a subtype of validator, error
handler: (_) => "b" as "b" | "c",
}),
returnDoesntIntersectReturnsValidator: mutation({
returns: v.literal("a"),
// @ts-expect-error when neither type is a subtype of the other, error
handler: (_) => "b" as const,
}),
};
type API = ApiFromModules<{ module: typeof module }>;
// Output validators seem to work about right.
test("Handler return value is a subtype of returns validator", () => {
type ReturnType =
API["module"]["returnSubtypeOfReturnsValidator"]["_returnType"];
assert<Equals<ReturnType, "a">>();
});
test("Handler return value is a supertype of returns validator", () => {
type ReturnType =
API["module"]["returnSupertypeOfReturnsValidator"]["_returnType"];
assert<Equals<ReturnType, any>>();
});
});
describe("argument and return value validators can be objects or validators", () => {
// Test with mutation, we aim for all the wrappers work the same way.
const mutation: MutationBuilder<any, "public"> = mutationGeneric;
const query: QueryBuilder<any, "public"> = queryGeneric;
const action: ActionBuilder<any, "public"> = actionGeneric;
const module = {
configArgsObject: mutation({
args: {
arg: v.string(),
},
handler: (_, args) => {
assert<Equals<(typeof args)["arg"], string>>;
return "result";
},
}),
configArgsValidatorIsNotSupported: mutation({
args: v.object({
arg: v.string(),
}),
handler: (_, args) => {
assert<Equals<(typeof args)["arg"], string>>;
return "result";
},
}),
configOutputObject: mutation({
returns: {
arg: v.string(),
},
handler: () => {
return { arg: "result" };
},
}),
configOutputValidator: mutation({
returns: v.object({
arg: v.string(),
}),
handler: () => {
return { arg: "result" };
},
}),
// test queries and actions just a bit too
q1: query({
args: v.object({
arg: v.string(),
}),
returns: { arg: v.string() },
handler: (_, { arg }) => {
return { arg: arg };
},
}),
a1: action({
args: v.object({
arg: v.string(),
}),
returns: { arg: v.string() },
handler: (_, { arg }) => {
return { arg: arg };
},
}),
queryAsync: query({
args: v.object({
arg: v.string(),
}),
returns: { arg: v.string() },
handler: async (_, { arg }) => {
return { arg: arg };
},
}),
mutationAsync: mutation({
args: v.object({
arg: v.string(),
}),
returns: { arg: v.string() },
handler: async (_, { arg }) => {
return { arg: arg };
},
}),
actionAsync: action({
args: v.object({
arg: v.string(),
}),
returns: { arg: v.string() },
handler: async (_, { arg }) => {
return { arg: arg };
},
}),
// This is syntx that we no longer want to support when typechecking because they result in undefined behavior.
mutationNoOptionalValidators: mutation({
// @ts-expect-error Optional validators are not supported at the top level
args: v.optional(v.string()),
// @ts-expect-error Optional validators are not supported at the top level
returns: v.optional(v.string()),
handler: () => {
return "result";
},
}),
queryNoOptionalValidators: query({
// @ts-expect-error Optional validators are not supported at the top level
args: v.optional(v.string()),
// @ts-expect-error Optional validators are not supported at the top level
returns: v.optional(v.string()),
handler: () => {
return "result";
},
}),
actionNoOptionalValidators: action({
// @ts-expect-error Optional validators are not supported at the top level
args: v.optional(v.string()),
// @ts-expect-error Optional validators are not supported at the top level
returns: v.optional(v.string()),
handler: () => {
return "result";
},
}),
};
type API = ApiFromModules<{ module: typeof module }>;
const expectedArgsExport = {
type: "object",
value: {
arg: {
fieldType: {
type: "string",
},
optional: false,
},
},
};
const expectedReturnsExport = {
type: "object",
value: {
arg: {
fieldType: {
type: "string",
},
optional: false,
},
},
};
test("config with args validator", () => {
type Args = API["module"]["configArgsObject"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
const argsString = module.configArgsObject.exportArgs();
expect(JSON.parse(argsString)).toEqual(expectedArgsExport);
});
test("config with args object", () => {
type Args = API["module"]["configArgsValidatorIsNotSupported"]["_args"];
type ExpectedArgs = { arg: string };
assert<Equals<Args, ExpectedArgs>>;
const argsString = module.configArgsObject.exportArgs();
expect(JSON.parse(argsString)).toEqual(expectedArgsExport);
});
test("config with output validator", () => {
type ReturnType = API["module"]["configOutputObject"]["_returnType"];
type Expected = { arg: string };
assert<Equals<ReturnType, Expected>>;
const returnString = module.configOutputObject.exportReturns();
expect(JSON.parse(returnString)).toEqual(expectedReturnsExport);
});
test("config with output object", () => {
type ReturnType = API["module"]["configOutputValidator"]["_returnType"];
type Expected = { arg: string };
assert<Equals<ReturnType, Expected>>;
const returnString = module.configOutputValidator.exportReturns();
expect(JSON.parse(returnString)).toEqual(expectedReturnsExport);
});
test("queries", () => {
type ReturnType = API["module"]["q1"]["_returnType"];
type Expected = { arg: string };
assert<Equals<ReturnType, Expected>>;
const returnString = module.configOutputValidator.exportReturns();
expect(JSON.parse(returnString)).toEqual(expectedReturnsExport);
});
test("actions", () => {
type ReturnType = API["module"]["configOutputValidator"]["_returnType"];
type Expected = { arg: string };
assert<Equals<ReturnType, Expected>>;
const returnString = module.configOutputValidator.exportReturns();
expect(JSON.parse(returnString)).toEqual(expectedReturnsExport);
});
});