Skip to content

Commit 734df16

Browse files
thomasballingerConvex, Inc.
authored andcommitted
Queue mutations in ConvexHttpClient (#37818)
Queue mutations in the ConvexHttpClient to match the behavior of ConvexClient and ConvexReactClient. This makes switching between these safer. If you need unqueued mutations (you need to run multiple mutations concurrently), pass the `unqueued: true` option or create a separate ConvexHttpClient for each queue of mutations you need. Also allow passing auth as an option in the constructor. This is appropriate for short-lived ConvexHttpClients and it more convenient for instantiating a client and using it in a single expression. GitOrigin-RevId: fea037a6e10672648b0d71ddf1071b7ca5915eb7
1 parent f0bf297 commit 734df16

File tree

3 files changed

+296
-19
lines changed

3 files changed

+296
-19
lines changed

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- ConvexHttpClient mutations are now queued by default, making the
6+
ConvexHttpClient match the behavior of ConvexClient and ConvexReactClient.
7+
This makes switching between these safer.
8+
9+
If you need unqueued mutations (you need to run multiple mutations
10+
concurrently), pass the unqueued: true option or create a separate
11+
ConvexHttpClient for each queue of mutations you need.
12+
13+
- Allow passing auth to ConvexHttpClient as an option in the constructor. This
14+
is appropriate for short-lived ConvexHttpClients and it more convenient for
15+
instantiating a client and using it in a single expression.
16+
17+
- Restore check that Convex functions are not imported in the browser.
18+
19+
Convex functions run in a Convex deployment; including their source in a
20+
frontend bundle is never necessary and can unintentionally reveal
21+
implementation details (and even hardcoded secrets).
22+
23+
This check current causes a `console.warn()` warning, but in future versions
24+
this will become an error. If you see the warning "Convex functions should not
25+
be imported in the browser" you should address this by investigating where
26+
this is being logged from; that's code you don't want in your frontend bundle.
27+
If you really want your Convex functions in the browser it's possible to
28+
disable this warning but this is not recommended.
29+
30+
- TypeScript error when async callbacks are passed to
31+
`mutation.withOptimisticUpdate()`: an optimistic update function is expected
32+
to run synchronously.
33+
334
## 1.24.8
435

536
- Restore short retry timer for WebSocket reconnects initiated by an error on

src/browser/http_client.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { test, expect, afterEach, vi } from "vitest";
2+
import { ConvexHttpClient, setFetch } from "./http_client.js";
3+
import { makeFunctionReference } from "../server/index.js";
4+
5+
const apiMutationFunc = makeFunctionReference<
6+
"mutation",
7+
{ value: string },
8+
string
9+
>("test:mutation");
10+
11+
afterEach(() => {
12+
setFetch(globalThis.fetch);
13+
});
14+
15+
test("mutation queue processes mutations sequentially", async () => {
16+
const client = new ConvexHttpClient("http://test");
17+
18+
// Mock fetch to simulate network delays
19+
const fetchMock = vi.fn();
20+
let resolveFirst: (value: any) => void;
21+
let resolveSecond: (value: any) => void;
22+
23+
fetchMock.mockImplementation((url, options) => {
24+
const body = JSON.parse(options.body);
25+
if (body.path === "test:mutation" && body.args[0].value === "first") {
26+
return new Promise((resolve) => {
27+
resolveFirst = resolve;
28+
});
29+
}
30+
if (body.path === "test:mutation" && body.args[0].value === "second") {
31+
return new Promise((resolve) => {
32+
resolveSecond = resolve;
33+
});
34+
}
35+
return Promise.reject(new Error("Unexpected mutation"));
36+
});
37+
38+
setFetch(fetchMock);
39+
40+
// Start two queued mutations
41+
const firstPromise = client.mutation(apiMutationFunc, { value: "first" });
42+
const secondPromise = client.mutation(apiMutationFunc, { value: "second" });
43+
44+
// Verify first mutation started but second hasn't
45+
expect(fetchMock).toHaveBeenCalledTimes(1);
46+
expect(JSON.parse(fetchMock.mock.calls[0][1].body).args[0].value).toBe(
47+
"first",
48+
);
49+
50+
// Resolve first mutation
51+
resolveFirst!({
52+
ok: true,
53+
json: () => Promise.resolve({ status: "success", value: "first result" }),
54+
});
55+
await new Promise((resolve) => setTimeout(resolve, 0));
56+
57+
// Verify second mutation started
58+
expect(fetchMock).toHaveBeenCalledTimes(2);
59+
expect(JSON.parse(fetchMock.mock.calls[1][1].body).args[0].value).toBe(
60+
"second",
61+
);
62+
63+
// Resolve second mutation
64+
resolveSecond!({
65+
ok: true,
66+
json: () => Promise.resolve({ status: "success", value: "second result" }),
67+
});
68+
69+
// Verify both promises resolve
70+
await expect(firstPromise).resolves.toBe("first result");
71+
await expect(secondPromise).resolves.toBe("second result");
72+
});
73+
74+
test("unqueued mutations skip the queue", async () => {
75+
const client = new ConvexHttpClient("http://test");
76+
77+
const fetchMock = vi.fn();
78+
let resolveQueued: (value: any) => void;
79+
80+
fetchMock.mockImplementation((url, options) => {
81+
const body = JSON.parse(options.body);
82+
if (body.path === "test:mutation" && body.args[0].value === "queued") {
83+
return new Promise((resolve) => {
84+
resolveQueued = resolve;
85+
});
86+
}
87+
if (body.path === "test:mutation" && body.args[0].value === "unqueued") {
88+
return Promise.resolve({
89+
ok: true,
90+
json: () =>
91+
Promise.resolve({ status: "success", value: "unqueued result" }),
92+
});
93+
}
94+
return Promise.reject(new Error("Unexpected mutation"));
95+
});
96+
97+
setFetch(fetchMock);
98+
99+
// Start a queued mutation
100+
const queuedPromise = client.mutation(apiMutationFunc, { value: "queued" });
101+
expect(fetchMock).toHaveBeenCalledTimes(1);
102+
103+
// Start an unqueued mutation while first is still running
104+
const unqueuedPromise = client.mutation(
105+
apiMutationFunc,
106+
{ value: "unqueued" },
107+
{ skipQueue: true },
108+
);
109+
await new Promise((resolve) => setTimeout(resolve, 0));
110+
111+
// Verify both mutations started immediately
112+
expect(fetchMock).toHaveBeenCalledTimes(2);
113+
114+
// Resolve the queued mutation
115+
resolveQueued!({
116+
ok: true,
117+
json: () => Promise.resolve({ status: "success", value: "queued result" }),
118+
});
119+
120+
// Verify both promises resolve
121+
await expect(queuedPromise).resolves.toBe("queued result");
122+
await expect(unqueuedPromise).resolves.toBe("unqueued result");
123+
});
124+
125+
test("failed mutations don't block the queue", async () => {
126+
const client = new ConvexHttpClient("http://test");
127+
128+
const fetchMock = vi.fn();
129+
let resolveSecond: (value: any) => void;
130+
131+
fetchMock.mockImplementation((url, options) => {
132+
const body = JSON.parse(options.body);
133+
if (body.path === "test:mutation" && body.args[0].value === "first") {
134+
return Promise.resolve({
135+
ok: true,
136+
json: () =>
137+
Promise.resolve({
138+
status: "error",
139+
errorMessage: "First mutation failed",
140+
}),
141+
});
142+
}
143+
if (body.path === "test:mutation" && body.args[0].value === "second") {
144+
return new Promise((resolve) => {
145+
resolveSecond = resolve;
146+
});
147+
}
148+
return Promise.reject(new Error("Unexpected mutation"));
149+
});
150+
151+
setFetch(fetchMock);
152+
153+
// Start two queued mutations
154+
const firstPromise = client.mutation(apiMutationFunc, { value: "first" });
155+
const secondPromise = client.mutation(apiMutationFunc, { value: "second" });
156+
157+
await expect(firstPromise).rejects.toThrow("First mutation failed");
158+
159+
// First mutation failed, second should start
160+
expect(fetchMock).toHaveBeenCalledTimes(2);
161+
expect(JSON.parse(fetchMock.mock.calls[1][1].body).args[0].value).toBe(
162+
"second",
163+
);
164+
165+
// Resolve second mutation
166+
resolveSecond!({
167+
ok: true,
168+
json: () => Promise.resolve({ status: "success", value: "second result" }),
169+
});
170+
171+
// Verify first promise rejects and second resolves
172+
await expect(firstPromise).rejects.toThrow("First mutation failed");
173+
await expect(secondPromise).resolves.toBe("second result");
174+
});

src/browser/http_client.ts

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
logForFunction,
1919
Logger,
2020
} from "./logging.js";
21-
import { FunctionArgs, UserIdentityAttributes } from "../server/index.js";
21+
import {
22+
ArgsAndOptions,
23+
FunctionArgs,
24+
UserIdentityAttributes,
25+
} from "../server/index.js";
2226

2327
export const STATUS_CODE_OK = 200;
2428
export const STATUS_CODE_BAD_REQUEST = 400;
@@ -33,15 +37,25 @@ export function setFetch(f: typeof globalThis.fetch) {
3337
specifiedFetch = f;
3438
}
3539

40+
export type HttpMutationOptions = {
41+
/**
42+
* Skip the default queue of mutations and run this immediately.
43+
*
44+
* This allows the same HttpConvexClient to be used to request multiple
45+
* mutations in parallel, something not possible with WebSocket-based clients.
46+
*/
47+
skipQueue: boolean;
48+
};
49+
3650
/**
3751
* A Convex client that runs queries and mutations over HTTP.
3852
*
53+
* This client is stateful (it has user credentials and queues mutations)
54+
* so take care to avoid sharing it between requests in a server.
55+
*
3956
* This is appropriate for server-side code (like Netlify Lambdas) or non-reactive
4057
* webapps.
4158
*
42-
* If you're building a React app, consider using
43-
* {@link react.ConvexReactClient} instead.
44-
*
4559
* @public
4660
*/
4761
export class ConvexHttpClient {
@@ -52,6 +66,14 @@ export class ConvexHttpClient {
5266
private debug: boolean;
5367
private fetchOptions?: FetchOptions;
5468
private logger: Logger;
69+
private mutationQueue: Array<{
70+
mutation: FunctionReference<"mutation">;
71+
args: FunctionArgs<any>;
72+
resolve: (value: any) => void;
73+
reject: (error: any) => void;
74+
}> = [];
75+
private isProcessingQueue: boolean = false;
76+
5577
/**
5678
* Create a new {@link ConvexHttpClient}.
5779
*
@@ -61,15 +83,19 @@ export class ConvexHttpClient {
6183
* - `skipConvexDeploymentUrlCheck` - Skip validating that the Convex deployment URL looks like
6284
* `https://happy-animal-123.convex.cloud` or localhost. This can be useful if running a self-hosted
6385
* Convex backend that uses a different URL.
64-
* - `logger` - A logger. If not provided, logs to the console.
86+
* - `logger` - A logger or a boolean. If not provided, logs to the console.
6587
* You can construct your own logger to customize logging to log elsewhere
66-
* or not log at all.
88+
* or not log at all, or use `false` as a shorthand for a no-op logger.
89+
* - `auth` - A JWT containing identity claims accessible in Convex functions.
90+
* This identity may expire so it may be necessary to call `setAuth()` later,
91+
* but for short-lived clients it's convenient to specify this value here.
6792
*/
6893
constructor(
6994
address: string,
7095
options?: {
7196
skipConvexDeploymentUrlCheck?: boolean;
7297
logger?: Logger | boolean;
98+
auth?: string;
7399
},
74100
) {
75101
if (typeof options === "boolean") {
@@ -124,6 +150,9 @@ export class ConvexHttpClient {
124150
}
125151

126152
/**
153+
* Set admin auth token to allow calling internal queries, mutations, and actions
154+
* and acting as an identity.
155+
*
127156
* @internal
128157
*/
129158
setAdminAuth(token: string, actingAsIdentity?: UserIdentityAttributes) {
@@ -299,19 +328,10 @@ export class ConvexHttpClient {
299328
}
300329
}
301330

302-
/**
303-
* Execute a Convex mutation function.
304-
*
305-
* @param name - The name of the mutation.
306-
* @param args - The arguments object for the mutation. If this is omitted,
307-
* the arguments will be `{}`.
308-
* @returns A promise of the mutation's result.
309-
*/
310-
async mutation<Mutation extends FunctionReference<"mutation">>(
331+
private async mutationInner<Mutation extends FunctionReference<"mutation">>(
311332
mutation: Mutation,
312-
...args: OptionalRestArgs<Mutation>
333+
mutationArgs: FunctionArgs<Mutation>,
313334
): Promise<FunctionReturnType<Mutation>> {
314-
const mutationArgs = parseArgs(args[0]);
315335
const name = getFunctionName(mutation);
316336
const body = JSON.stringify({
317337
path: name,
@@ -359,8 +379,60 @@ export class ConvexHttpClient {
359379
}
360380
}
361381

382+
private async processMutationQueue() {
383+
if (this.isProcessingQueue) {
384+
return;
385+
}
386+
387+
this.isProcessingQueue = true;
388+
while (this.mutationQueue.length > 0) {
389+
const { mutation, args, resolve, reject } = this.mutationQueue.shift()!;
390+
try {
391+
const result = await this.mutationInner(mutation, args);
392+
resolve(result);
393+
} catch (error) {
394+
reject(error);
395+
}
396+
}
397+
this.isProcessingQueue = false;
398+
}
399+
400+
private enqueueMutation<Mutation extends FunctionReference<"mutation">>(
401+
mutation: Mutation,
402+
args: FunctionArgs<Mutation>,
403+
): Promise<FunctionReturnType<Mutation>> {
404+
return new Promise((resolve, reject) => {
405+
this.mutationQueue.push({ mutation, args, resolve, reject });
406+
void this.processMutationQueue();
407+
});
408+
}
409+
410+
/**
411+
* Execute a Convex mutation function. Mutations are queued by default.
412+
*
413+
* @param name - The name of the mutation.
414+
* @param args - The arguments object for the mutation. If this is omitted,
415+
* the arguments will be `{}`.
416+
* @param options - An optional object containing
417+
* @returns A promise of the mutation's result.
418+
*/
419+
async mutation<Mutation extends FunctionReference<"mutation">>(
420+
mutation: Mutation,
421+
...args: ArgsAndOptions<Mutation, HttpMutationOptions>
422+
): Promise<FunctionReturnType<Mutation>> {
423+
const [fnArgs, options] = args;
424+
const mutationArgs = parseArgs(fnArgs);
425+
const queued = !options?.skipQueue;
426+
427+
if (queued) {
428+
return await this.enqueueMutation(mutation, mutationArgs);
429+
} else {
430+
return await this.mutationInner(mutation, mutationArgs);
431+
}
432+
}
433+
362434
/**
363-
* Execute a Convex action function.
435+
* Execute a Convex action function. Actions are not queued.
364436
*
365437
* @param name - The name of the action.
366438
* @param args - The arguments object for the action. If this is omitted,
@@ -420,7 +492,7 @@ export class ConvexHttpClient {
420492
}
421493

422494
/**
423-
* Execute a Convex function of an unknown type.
495+
* Execute a Convex function of an unknown type. These function calls are not queued.
424496
*
425497
* @param name - The name of the function.
426498
* @param args - The arguments object for the function. If this is omitted,

0 commit comments

Comments
 (0)