@vllnt/convex-helpers 0.1.0-canary.1e0f54a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/auth.d.ts +11 -0
- package/dist/mcp/auth.d.ts.map +1 -0
- package/dist/mcp/auth.js +36 -0
- package/dist/mcp/auth.js.map +1 -0
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/pagination/context.d.ts +9 -0
- package/dist/mcp/pagination/context.d.ts.map +1 -0
- package/dist/mcp/pagination/context.js +21 -0
- package/dist/mcp/pagination/context.js.map +1 -0
- package/dist/mcp/pagination/cursor.d.ts +7 -0
- package/dist/mcp/pagination/cursor.d.ts.map +1 -0
- package/dist/mcp/pagination/cursor.js +60 -0
- package/dist/mcp/pagination/cursor.js.map +1 -0
- package/dist/mcp/pagination/handlers.d.ts +12 -0
- package/dist/mcp/pagination/handlers.d.ts.map +1 -0
- package/dist/mcp/pagination/handlers.js +91 -0
- package/dist/mcp/pagination/handlers.js.map +1 -0
- package/dist/mcp/pagination/types.d.ts +26 -0
- package/dist/mcp/pagination/types.d.ts.map +1 -0
- package/dist/mcp/pagination/types.js +2 -0
- package/dist/mcp/pagination/types.js.map +1 -0
- package/dist/mcp/resources/helpers.d.ts +9 -0
- package/dist/mcp/resources/helpers.d.ts.map +1 -0
- package/dist/mcp/resources/helpers.js +8 -0
- package/dist/mcp/resources/helpers.js.map +1 -0
- package/dist/mcp/resources/register.d.ts +14 -0
- package/dist/mcp/resources/register.d.ts.map +1 -0
- package/dist/mcp/resources/register.js +40 -0
- package/dist/mcp/resources/register.js.map +1 -0
- package/dist/mcp/resources/types.d.ts +7 -0
- package/dist/mcp/resources/types.d.ts.map +1 -0
- package/dist/mcp/resources/types.js +2 -0
- package/dist/mcp/resources/types.js.map +1 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +139 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/helpers.d.ts +16 -0
- package/dist/mcp/tools/helpers.d.ts.map +1 -0
- package/dist/mcp/tools/helpers.js +34 -0
- package/dist/mcp/tools/helpers.js.map +1 -0
- package/dist/mcp/tools/register.d.ts +23 -0
- package/dist/mcp/tools/register.d.ts.map +1 -0
- package/dist/mcp/tools/register.js +195 -0
- package/dist/mcp/tools/register.js.map +1 -0
- package/dist/mcp/tools/types.d.ts +71 -0
- package/dist/mcp/tools/types.d.ts.map +1 -0
- package/dist/mcp/tools/types.js +2 -0
- package/dist/mcp/tools/types.js.map +1 -0
- package/dist/mcp/types.d.ts +50 -0
- package/dist/mcp/types.d.ts.map +1 -0
- package/dist/mcp/types.js +2 -0
- package/dist/mcp/types.js.map +1 -0
- package/dist/mcp/validators.d.ts +8 -0
- package/dist/mcp/validators.d.ts.map +1 -0
- package/dist/mcp/validators.js +135 -0
- package/dist/mcp/validators.js.map +1 -0
- package/package.json +95 -0
- package/src/index.test.ts +164 -0
- package/src/index.ts +62 -0
- package/src/mcp/auth.ts +53 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/pagination/context.ts +27 -0
- package/src/mcp/pagination/cursor.ts +99 -0
- package/src/mcp/pagination/handlers.ts +172 -0
- package/src/mcp/pagination/types.ts +27 -0
- package/src/mcp/resources/helpers.ts +19 -0
- package/src/mcp/resources/register.ts +63 -0
- package/src/mcp/resources/types.ts +7 -0
- package/src/mcp/server.ts +209 -0
- package/src/mcp/tools/helpers.ts +47 -0
- package/src/mcp/tools/register.ts +256 -0
- package/src/mcp/tools/types.ts +74 -0
- package/src/mcp/types.ts +65 -0
- package/src/mcp/validators.ts +175 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import type { ConvexClient } from "../types.js";
|
|
5
|
+
import { convexArgsToZod as convexArgumentsToZod } from "../validators.js";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
CallContext,
|
|
9
|
+
LifecycleHooks,
|
|
10
|
+
OnCallResult,
|
|
11
|
+
ToolDef,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
type PreparedTool = {
|
|
15
|
+
description: string;
|
|
16
|
+
name: string;
|
|
17
|
+
toolDef: ToolDef;
|
|
18
|
+
zodShape: Record<string, z.ZodTypeAny>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Underscore-prefixed arg keys are reserved for framework-injected context.
|
|
23
|
+
*
|
|
24
|
+
* - Request args containing `_*` keys are rejected before hook invocation.
|
|
25
|
+
* - Tool-level Convex validators MAY declare `_*` fields so the action
|
|
26
|
+
* handler receives server-injected values; these are stripped from the
|
|
27
|
+
* published JSON Schema so MCP clients neither see nor pass them.
|
|
28
|
+
*/
|
|
29
|
+
function isReservedKey(key: string): boolean {
|
|
30
|
+
return key.startsWith("_");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stripReservedFromShape(
|
|
34
|
+
shape: Record<string, z.ZodTypeAny>,
|
|
35
|
+
): Record<string, z.ZodTypeAny> {
|
|
36
|
+
const filtered: Record<string, z.ZodTypeAny> = {};
|
|
37
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
38
|
+
if (!isReservedKey(key)) {
|
|
39
|
+
filtered[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return filtered;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function prepareTools(tools: Record<string, ToolDef>): PreparedTool[] {
|
|
46
|
+
return Object.entries(tools).map(([name, toolDef]) => {
|
|
47
|
+
const zodSchema = toolDef.args
|
|
48
|
+
? convexArgumentsToZod(toolDef.args)
|
|
49
|
+
: undefined;
|
|
50
|
+
const fullShape = zodSchema?.shape ?? {};
|
|
51
|
+
return {
|
|
52
|
+
description: toolDef.description ?? "",
|
|
53
|
+
name,
|
|
54
|
+
toolDef,
|
|
55
|
+
zodShape: stripReservedFromShape(fullShape),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns tools whose top-level args contain reserved `_*` keys.
|
|
62
|
+
*
|
|
63
|
+
* Used by `createMCPServer` at construction to surface a footgun: a tool that
|
|
64
|
+
* declares `_*` args without an `onToolCall` hook will have those args stripped
|
|
65
|
+
* from the published schema and never injected, so every dispatched call will
|
|
66
|
+
* fail Convex's own validator with "missing required arg".
|
|
67
|
+
*/
|
|
68
|
+
export function findToolsWithReservedArgs(
|
|
69
|
+
tools: Record<string, ToolDef>,
|
|
70
|
+
): Map<string, string[]> {
|
|
71
|
+
const result = new Map<string, string[]>();
|
|
72
|
+
for (const [name, toolDef] of Object.entries(tools)) {
|
|
73
|
+
if (toolDef.args?.kind !== "object" || !toolDef.args.fields) continue;
|
|
74
|
+
const reserved = Object.keys(toolDef.args.fields).filter(isReservedKey);
|
|
75
|
+
if (reserved.length > 0) result.set(name, reserved);
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function invokeHook(
|
|
81
|
+
hooks: LifecycleHooks | undefined,
|
|
82
|
+
ctx: CallContext,
|
|
83
|
+
toolDef: ToolDef,
|
|
84
|
+
): Promise<OnCallResult | undefined> {
|
|
85
|
+
try {
|
|
86
|
+
if (ctx.phase === "error" && toolDef.onError) {
|
|
87
|
+
const result = await toolDef.onError(
|
|
88
|
+
ctx as CallContext & { phase: "error" },
|
|
89
|
+
);
|
|
90
|
+
return result ?? undefined;
|
|
91
|
+
}
|
|
92
|
+
if (hooks?.onToolCall) {
|
|
93
|
+
const result = await hooks.onToolCall(ctx);
|
|
94
|
+
return result ?? undefined;
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
} catch (hookError) {
|
|
98
|
+
console.error("[convex-mcp] hook error (swallowed)", {
|
|
99
|
+
error: hookError,
|
|
100
|
+
phase: ctx.phase,
|
|
101
|
+
requestId: ctx.requestId,
|
|
102
|
+
tool: ctx.toolName,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function executeWithTimeout<T>(
|
|
109
|
+
promise: Promise<T>,
|
|
110
|
+
timeoutMs: number | undefined,
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
if (!timeoutMs) return promise;
|
|
113
|
+
|
|
114
|
+
let handle: ReturnType<typeof setTimeout>;
|
|
115
|
+
return Promise.race([
|
|
116
|
+
promise.finally(() => {
|
|
117
|
+
clearTimeout(handle);
|
|
118
|
+
}),
|
|
119
|
+
new Promise<never>((_, reject) => {
|
|
120
|
+
handle = setTimeout(() => {
|
|
121
|
+
reject(new Error("Tool execution timed out"));
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function registerTools(
|
|
128
|
+
mcpServer: McpServer,
|
|
129
|
+
client: ConvexClient,
|
|
130
|
+
tools: PreparedTool[],
|
|
131
|
+
hooks: LifecycleHooks | undefined,
|
|
132
|
+
requestId: string,
|
|
133
|
+
apiKey: string | undefined,
|
|
134
|
+
): void {
|
|
135
|
+
for (const { description, name, toolDef, zodShape } of tools) {
|
|
136
|
+
mcpServer.tool(
|
|
137
|
+
name,
|
|
138
|
+
description,
|
|
139
|
+
zodShape,
|
|
140
|
+
async (arguments_: Record<string, unknown>) => {
|
|
141
|
+
const startedAt = Date.now();
|
|
142
|
+
const { onError: _onError, ref: _ref, ...safeDef } = toolDef;
|
|
143
|
+
|
|
144
|
+
const reservedKeys = Object.keys(arguments_).filter(isReservedKey);
|
|
145
|
+
if (reservedKeys.length > 0) {
|
|
146
|
+
console.warn("[convex-mcp] reserved-key reject", {
|
|
147
|
+
keys: reservedKeys,
|
|
148
|
+
requestId,
|
|
149
|
+
tool: name,
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
content: [
|
|
153
|
+
{
|
|
154
|
+
text:
|
|
155
|
+
`Reserved arg keys not allowed in request: ${reservedKeys.join(", ")}. ` +
|
|
156
|
+
`Keys starting with "_" are reserved for framework-injected context.`,
|
|
157
|
+
type: "text" as const,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const baseCtx = {
|
|
165
|
+
apiKey,
|
|
166
|
+
args: arguments_,
|
|
167
|
+
requestId,
|
|
168
|
+
startedAt,
|
|
169
|
+
toolDef: safeDef,
|
|
170
|
+
toolName: name,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const beforeCtx: CallContext = { ...baseCtx, phase: "before" };
|
|
174
|
+
const beforeResult = await invokeHook(hooks, beforeCtx, toolDef);
|
|
175
|
+
if (beforeResult?.abort) {
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
text: beforeResult.errorMessage ?? "Tool call rejected",
|
|
180
|
+
type: "text" as const,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
isError: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const dispatchArguments =
|
|
188
|
+
beforeResult?.extendArgs &&
|
|
189
|
+
Object.keys(beforeResult.extendArgs).length > 0
|
|
190
|
+
? { ...arguments_, ...beforeResult.extendArgs }
|
|
191
|
+
: arguments_;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const callPromise = (async () => {
|
|
195
|
+
switch (toolDef.type) {
|
|
196
|
+
case "query":
|
|
197
|
+
return await client.query(toolDef.ref, dispatchArguments);
|
|
198
|
+
case "mutation":
|
|
199
|
+
return await client.mutation(toolDef.ref, dispatchArguments);
|
|
200
|
+
case "action":
|
|
201
|
+
return await client.action(toolDef.ref, dispatchArguments);
|
|
202
|
+
default:
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Unknown function type: ${String(toolDef.type)}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
})();
|
|
208
|
+
|
|
209
|
+
const result = await executeWithTimeout(callPromise, toolDef.timeout);
|
|
210
|
+
const durationMs = Date.now() - startedAt;
|
|
211
|
+
|
|
212
|
+
const successCtx: CallContext = {
|
|
213
|
+
...baseCtx,
|
|
214
|
+
durationMs,
|
|
215
|
+
phase: "success",
|
|
216
|
+
result,
|
|
217
|
+
};
|
|
218
|
+
await invokeHook(hooks, successCtx, toolDef);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
text: JSON.stringify(result ?? null, null, 2),
|
|
224
|
+
type: "text" as const,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const durationMs = Date.now() - startedAt;
|
|
230
|
+
const errorCtx: CallContext = {
|
|
231
|
+
...baseCtx,
|
|
232
|
+
durationMs,
|
|
233
|
+
error,
|
|
234
|
+
phase: "error",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const errorResult = await invokeHook(hooks, errorCtx, toolDef);
|
|
238
|
+
const errorMessage =
|
|
239
|
+
errorResult?.message ?? "Function execution failed";
|
|
240
|
+
|
|
241
|
+
console.error("[convex-mcp] tool execution failed", {
|
|
242
|
+
durationMs,
|
|
243
|
+
error,
|
|
244
|
+
requestId,
|
|
245
|
+
tool: name,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
content: [{ text: errorMessage, type: "text" as const }],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ConvexValidator } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export type FunctionType = "action" | "mutation" | "query";
|
|
4
|
+
|
|
5
|
+
export type HookResult = OnCallResult | undefined;
|
|
6
|
+
export type HookReturn = HookResult | Promise<HookResult> | Promise<void>;
|
|
7
|
+
|
|
8
|
+
export type ToolDef = {
|
|
9
|
+
args?: ConvexValidator;
|
|
10
|
+
description?: string;
|
|
11
|
+
onError?: (ctx: CallContext & { phase: "error" }) => HookReturn;
|
|
12
|
+
ref: unknown;
|
|
13
|
+
tags?: Record<string, string>;
|
|
14
|
+
timeout?: number;
|
|
15
|
+
type: FunctionType;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CallContext = {
|
|
19
|
+
apiKey: string | undefined;
|
|
20
|
+
args: Record<string, unknown>;
|
|
21
|
+
durationMs?: number;
|
|
22
|
+
error?: unknown;
|
|
23
|
+
phase: "before" | "error" | "success";
|
|
24
|
+
requestId: string;
|
|
25
|
+
result?: unknown;
|
|
26
|
+
startedAt: number;
|
|
27
|
+
toolDef: Omit<ToolDef, "onError" | "ref">;
|
|
28
|
+
toolName: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type OnCallResult = {
|
|
32
|
+
/** Set true to abort execution. Only checked during "before" phase. */
|
|
33
|
+
abort?: boolean;
|
|
34
|
+
/** Custom error message when aborting (before phase). Default: "Tool call rejected" */
|
|
35
|
+
errorMessage?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Server-resolved key/value pairs merged into the dispatched function's args
|
|
38
|
+
* before the Convex call. Only honored during the "before" phase.
|
|
39
|
+
*
|
|
40
|
+
* On key collision, hook-supplied values take precedence over request args
|
|
41
|
+
* (server-side wins).
|
|
42
|
+
*
|
|
43
|
+
* Convention: keys MUST use the framework-reserved underscore prefix
|
|
44
|
+
* (e.g., `_mcp_apiKey`, `_request_id`). Request args containing `_`-prefixed
|
|
45
|
+
* keys are rejected before the hook runs, so this prefix is safe for
|
|
46
|
+
* server-side context injection (auth, tracing, multi-tenancy, audit, etc.).
|
|
47
|
+
*
|
|
48
|
+
* Empty objects and undefined are no-ops (no allocation, no merge).
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* hooks: {
|
|
53
|
+
* onToolCall: async ({ apiKey, phase }) => {
|
|
54
|
+
* if (phase !== "before") return;
|
|
55
|
+
* const validated = await validateKey(apiKey);
|
|
56
|
+
* if (!validated.valid) return { abort: true, errorMessage: "Invalid key" };
|
|
57
|
+
* return {
|
|
58
|
+
* extendArgs: {
|
|
59
|
+
* _mcp_apiKey: apiKey,
|
|
60
|
+
* _mcp_scopes: validated.scopes,
|
|
61
|
+
* },
|
|
62
|
+
* };
|
|
63
|
+
* },
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
extendArgs?: Record<string, unknown>;
|
|
68
|
+
/** Custom error message on failure (error phase). Default: "Function execution failed" */
|
|
69
|
+
message?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type LifecycleHooks = {
|
|
73
|
+
onToolCall?: (ctx: CallContext) => HookReturn;
|
|
74
|
+
};
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type ConvexValidator = {
|
|
2
|
+
element?: ConvexValidator;
|
|
3
|
+
fields?: Record<string, ConvexValidator>;
|
|
4
|
+
isOptional: "optional" | "required";
|
|
5
|
+
key?: ConvexValidator;
|
|
6
|
+
kind: string;
|
|
7
|
+
members?: ConvexValidator[];
|
|
8
|
+
tableName?: string;
|
|
9
|
+
value?: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Injectable Convex client interface. Compatible with both ConvexHttpClient
|
|
14
|
+
* (production) and convex-test's `t` (testing).
|
|
15
|
+
*
|
|
16
|
+
* Uses `any` for functionRef because Convex's FunctionReference is a complex
|
|
17
|
+
* generic that cannot be expressed without importing convex internals.
|
|
18
|
+
*/
|
|
19
|
+
export type ConvexClient = {
|
|
20
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
21
|
+
action(functionRef: any, ...arguments_: any[]): Promise<any>;
|
|
22
|
+
mutation(functionRef: any, ...arguments_: any[]): Promise<any>;
|
|
23
|
+
query(functionRef: any, ...arguments_: any[]): Promise<any>;
|
|
24
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type AuthConfig = {
|
|
28
|
+
convexToken?: (
|
|
29
|
+
apiKey: string,
|
|
30
|
+
) => Promise<string | undefined> | string | undefined;
|
|
31
|
+
validate: (apiKey: string) => boolean | Promise<boolean>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
import type { PaginationConfig } from "./pagination/types.js";
|
|
35
|
+
import type { ResourceDef } from "./resources/types.js";
|
|
36
|
+
import type { LifecycleHooks, ToolDef } from "./tools/types.js";
|
|
37
|
+
|
|
38
|
+
export type { PaginationConfig, ResourceDef, ToolDef };
|
|
39
|
+
|
|
40
|
+
export type ServerConfig = {
|
|
41
|
+
auth: AuthConfig;
|
|
42
|
+
client?: ConvexClient;
|
|
43
|
+
convexUrl?: string;
|
|
44
|
+
hooks?: LifecycleHooks;
|
|
45
|
+
name?: string;
|
|
46
|
+
pagination?: PaginationConfig;
|
|
47
|
+
resources?: Record<string, ResourceDef>;
|
|
48
|
+
tools?: Record<string, ToolDef>;
|
|
49
|
+
version?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ConvexMCPServer = {
|
|
53
|
+
handler: () => {
|
|
54
|
+
GET: (request: Request) => Promise<Response>;
|
|
55
|
+
POST: (request: Request) => Promise<Response>;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export { type ToolPage, type ToolSummary } from "./pagination/types.js";
|
|
60
|
+
export {
|
|
61
|
+
type CallContext,
|
|
62
|
+
type FunctionType,
|
|
63
|
+
type LifecycleHooks,
|
|
64
|
+
type OnCallResult,
|
|
65
|
+
} from "./tools/types.js";
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { ConvexValidator } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export class UnsupportedValidatorError extends Error {
|
|
6
|
+
constructor(kind: string) {
|
|
7
|
+
super(
|
|
8
|
+
`Unsupported Convex validator kind: "${kind}". This may be from a newer version of Convex.`,
|
|
9
|
+
);
|
|
10
|
+
this.name = "UnsupportedValidatorError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isConvexValidator(value: unknown): value is ConvexValidator {
|
|
15
|
+
return (
|
|
16
|
+
typeof value === "object" &&
|
|
17
|
+
value !== null &&
|
|
18
|
+
"kind" in value &&
|
|
19
|
+
"isOptional" in value
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLiteralValue(
|
|
24
|
+
value: unknown,
|
|
25
|
+
): value is boolean | null | number | string {
|
|
26
|
+
const t = typeof value;
|
|
27
|
+
return t === "string" || t === "number" || t === "boolean" || value === null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isAllStringLiteralUnion(
|
|
31
|
+
members: ConvexValidator[],
|
|
32
|
+
): members is (ConvexValidator & { value: string })[] {
|
|
33
|
+
return (
|
|
34
|
+
members.length > 1 &&
|
|
35
|
+
members.every((m) => m.kind === "literal" && typeof m.value === "string")
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasAtLeastTwo<T>(array: T[]): array is [T, T, ...T[]] {
|
|
40
|
+
return array.length >= 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Callers guarantee arr.length >= 2 (pre-checked by isAllStringLiteralUnion or length === 1 early return)
|
|
44
|
+
function asTuple<T>(array: T[]): [T, T, ...T[]] {
|
|
45
|
+
/* v8 ignore start -- callers guarantee length >= 2 */
|
|
46
|
+
if (!hasAtLeastTwo(array)) throw new Error("Expected at least 2 elements");
|
|
47
|
+
/* v8 ignore stop */
|
|
48
|
+
return array;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function convertKind(validator: ConvexValidator): z.ZodTypeAny {
|
|
52
|
+
switch (validator.kind) {
|
|
53
|
+
case "string":
|
|
54
|
+
return z.string();
|
|
55
|
+
|
|
56
|
+
case "float64":
|
|
57
|
+
return z.number();
|
|
58
|
+
|
|
59
|
+
case "boolean":
|
|
60
|
+
return z.boolean();
|
|
61
|
+
|
|
62
|
+
case "null":
|
|
63
|
+
return z.null();
|
|
64
|
+
|
|
65
|
+
case "int64":
|
|
66
|
+
return z
|
|
67
|
+
.string()
|
|
68
|
+
.describe(
|
|
69
|
+
"64-bit integer as string (BigInt — JSON cannot represent bigint)",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
case "bytes":
|
|
73
|
+
return z.string().describe("Binary data as base64-encoded string");
|
|
74
|
+
|
|
75
|
+
case "id":
|
|
76
|
+
return z
|
|
77
|
+
.string()
|
|
78
|
+
.describe(
|
|
79
|
+
validator.tableName
|
|
80
|
+
? `Convex document ID for table '${validator.tableName}'`
|
|
81
|
+
: "Convex document ID",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
case "literal":
|
|
85
|
+
if (!isLiteralValue(validator.value)) {
|
|
86
|
+
throw new UnsupportedValidatorError("literal (unsupported value type)");
|
|
87
|
+
}
|
|
88
|
+
return z.literal(validator.value);
|
|
89
|
+
|
|
90
|
+
case "array":
|
|
91
|
+
if (!validator.element) {
|
|
92
|
+
throw new UnsupportedValidatorError("array (missing element)");
|
|
93
|
+
}
|
|
94
|
+
return z.array(convertValidator(validator.element));
|
|
95
|
+
|
|
96
|
+
case "object": {
|
|
97
|
+
if (!validator.fields) {
|
|
98
|
+
return z.object({});
|
|
99
|
+
}
|
|
100
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
101
|
+
for (const [key, field] of Object.entries(validator.fields)) {
|
|
102
|
+
shape[key] = convertValidator(field);
|
|
103
|
+
}
|
|
104
|
+
return z.object(shape);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "union": {
|
|
108
|
+
if (!validator.members || validator.members.length === 0) {
|
|
109
|
+
throw new UnsupportedValidatorError("union (missing members)");
|
|
110
|
+
}
|
|
111
|
+
if (isAllStringLiteralUnion(validator.members)) {
|
|
112
|
+
const values = validator.members.map((m) => m.value);
|
|
113
|
+
return z.enum(asTuple(values));
|
|
114
|
+
}
|
|
115
|
+
const converted = validator.members.map((m) => convertValidator(m));
|
|
116
|
+
if (converted.length === 1) {
|
|
117
|
+
const [single] = converted;
|
|
118
|
+
/* v8 ignore start -- impossible after converted.length === 1; needed for noUncheckedIndexedAccess */
|
|
119
|
+
if (!single)
|
|
120
|
+
throw new UnsupportedValidatorError("union (missing member)");
|
|
121
|
+
/* v8 ignore stop */
|
|
122
|
+
return single;
|
|
123
|
+
}
|
|
124
|
+
return z.union(asTuple(converted));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "record": {
|
|
128
|
+
if (!validator.key || !validator.value) {
|
|
129
|
+
throw new UnsupportedValidatorError("record (missing key or value)");
|
|
130
|
+
}
|
|
131
|
+
const keyKind = validator.key.kind;
|
|
132
|
+
if (keyKind !== "string" && keyKind !== "id") {
|
|
133
|
+
throw new UnsupportedValidatorError(
|
|
134
|
+
`record key must be string or id, got "${keyKind}"`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const valueValidator = validator.value;
|
|
138
|
+
if (!isConvexValidator(valueValidator)) {
|
|
139
|
+
throw new UnsupportedValidatorError("record (invalid value validator)");
|
|
140
|
+
}
|
|
141
|
+
// Key kind validated as "string" | "id" above — always maps to z.string()
|
|
142
|
+
return z.record(z.string(), convertValidator(valueValidator));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "any":
|
|
146
|
+
return z.any();
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
throw new UnsupportedValidatorError(validator.kind);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function convertValidator(validator: ConvexValidator): z.ZodTypeAny {
|
|
154
|
+
const base = convertKind(validator);
|
|
155
|
+
if (validator.isOptional === "optional") {
|
|
156
|
+
return base.optional();
|
|
157
|
+
}
|
|
158
|
+
return base;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function convexArgsToZod(
|
|
162
|
+
argumentsValidator: ConvexValidator,
|
|
163
|
+
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
164
|
+
if (argumentsValidator.kind !== "object") {
|
|
165
|
+
throw new Error("Convex function args must be a v.object() validator");
|
|
166
|
+
}
|
|
167
|
+
if (!argumentsValidator.fields) {
|
|
168
|
+
return z.object({});
|
|
169
|
+
}
|
|
170
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
171
|
+
for (const [key, field] of Object.entries(argumentsValidator.fields)) {
|
|
172
|
+
shape[key] = convertValidator(field);
|
|
173
|
+
}
|
|
174
|
+
return z.object(shape);
|
|
175
|
+
}
|