convex
Version:
Client for the Convex Cloud
782 lines (692 loc) • 23.8 kB
text/typescript
import child_process from "child_process";
import { test, expect } from "vitest";
import { Long } from "../long.js";
import { BaseConvexClient } from "./client.js";
import {
ActionRequest,
MutationRequest,
parseServerMessage,
RequestId,
ServerMessage,
} from "./protocol.js";
import {
encodeServerMessage,
nodeWebSocket,
UpdateQueue,
withInMemoryWebSocket,
} from "./client_node_test_helpers.js";
import { FunctionArgs, makeFunctionReference } from "../../server/index.js";
test("BaseConvexClient protocol in node", async () => {
await withInMemoryWebSocket(async ({ address, receive }) => {
const client = new BaseConvexClient(
address,
() => {
// ignore updates.
},
{ webSocketConstructor: nodeWebSocket, unsavedChangesWarning: false },
);
expect((await receive()).type).toEqual("Connect");
expect((await receive()).type).toEqual("ModifyQuerySet");
await client.close();
});
});
// Run the test above in its own Node.js subprocess to ensure that it exists
// cleanly. This is the only point of this test.
test("BaseConvexClient closes cleanly", () => {
const p = child_process.spawnSync(
"node_modules/.bin/vitest",
[
"-t",
"BaseConvexClient protocol in node",
"src/browser/sync/client_node.test.ts",
],
{
encoding: "utf-8",
timeout: 35000,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
FORCE_COLOR: "false",
},
},
);
// If this is a timeout, the test didn't exit cleanly! Check for timers.
expect(p.status).toBeFalsy();
});
test("Tests can encode longs in server messages", () => {
const orig: ServerMessage = {
type: "Transition",
startVersion: { querySet: 0, identity: 0, ts: Long.fromNumber(0) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(1) },
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: 0.0,
logLines: ["[LOG] 'Got stuff'"],
journal: null,
},
],
};
const encoded = encodeServerMessage(orig);
const decoded = parseServerMessage(JSON.parse(encoded));
expect(orig).toEqual(decoded);
});
// Detects an issue where actions sent before the WebSocket has connected
// are failed upon first connecting.
test("Actions can be called immediately", async () => {
await withInMemoryWebSocket(async ({ address, receive, send }) => {
const client = new BaseConvexClient(address, () => null, {
webSocketConstructor: nodeWebSocket,
unsavedChangesWarning: false,
});
const actionP = client.action("myAction", {});
expect((await receive()).type).toEqual("Connect");
expect((await receive()).type).toEqual("ModifyQuerySet");
const actionRequest = await receive();
expect(actionRequest.type).toEqual("Action");
const requestId = (actionRequest as ActionRequest).requestId;
send(actionSuccess(requestId));
expect(await actionP).toBe(42);
await client.close();
});
});
function actionSuccess(requestId: RequestId): ServerMessage {
return {
type: "ActionResponse",
requestId: requestId,
success: true,
result: 42,
logLines: [],
};
}
test("maxObservedTimestamp is updated on mutation and transition", async () => {
await withInMemoryWebSocket(async ({ address, receive, send }) => {
const client = new BaseConvexClient(address, () => null, {
webSocketConstructor: nodeWebSocket,
unsavedChangesWarning: false,
});
expect(client.getMaxObservedTimestamp()).toBeUndefined();
const mutationP = client.mutation("myMutation", {});
expect((await receive()).type).toEqual("Connect");
expect((await receive()).type).toEqual("ModifyQuerySet");
const mutationRequest = await receive();
expect(mutationRequest.type).toEqual("Mutation");
const requestId = (mutationRequest as MutationRequest).requestId;
// Send a mutation, should update the max observed timestamp.
send({
type: "MutationResponse",
requestId: requestId,
success: true,
result: 42,
ts: Long.fromNumber(1000),
logLines: [],
});
// Wait until getMaxObservedTimestamp() gets updated
for (let i = 0; i < 10; i++) {
if (client.getMaxObservedTimestamp()) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(client.getMaxObservedTimestamp()).toEqual(Long.fromNumber(1000));
// Send a transition from before the mutation. Should not update the max
// observed timestamp nor resolve the mutation.
send({
type: "Transition",
startVersion: {
querySet: 0,
ts: Long.fromNumber(0),
identity: 0,
},
endVersion: {
querySet: 0,
ts: Long.fromNumber(500),
identity: 0,
},
modifications: [],
});
// Wait a bit and confirm the max timestamp has not been updated.
await new Promise((resolve) => setTimeout(resolve, 200));
expect(client.getMaxObservedTimestamp()).toEqual(Long.fromNumber(1000));
// Send another transition with higher timestamp. This should resolve the
// transition and advanced the max observable timestamp.
send({
type: "Transition",
startVersion: {
querySet: 0,
ts: Long.fromNumber(500),
identity: 0,
},
endVersion: {
querySet: 0,
ts: Long.fromNumber(2000),
identity: 0,
},
modifications: [],
});
// Wait until the mutation is resolved.
expect(await mutationP).toBe(42);
expect(client.getMaxObservedTimestamp()).toEqual(Long.fromNumber(2000));
await client.close();
});
});
const apiQueriesA = makeFunctionReference<"query", {}, string>("queries:a");
const apiQueriesB = makeFunctionReference<"query", {}, string>("queries:b");
const _apiMutationsZ = makeFunctionReference<"mutation", {}>("mutations:z");
/**
* Regression test for
* - subscribing to query a
* - running a mutation that sets an optimistic update for queries a and b
* - receiving an update for a
*/
test("Setting optimistic updates for queries that have not yet been subscribed to", async () => {
await withInMemoryWebSocket(async ({ address, receive, send }) => {
const q = new UpdateQueue(10);
const client = new BaseConvexClient(
address,
(queryTokens) => {
q.onTransition(client)(queryTokens);
},
{
webSocketConstructor: nodeWebSocket,
unsavedChangesWarning: false,
verbose: true,
},
);
client.subscribe("queries:a", {});
expect((await receive()).type).toEqual("Connect");
const modify = await receive();
expect(modify.type).toEqual("ModifyQuerySet");
if (modify.type !== "ModifyQuerySet") {
return;
}
expect(modify.modifications.length).toBe(1);
expect(modify.modifications).toEqual([
{
type: "Add",
queryId: 0,
udfPath: "queries:a",
args: [{}],
},
]);
// Now that we're subscribed to queries:a,
// run a mutation that, optimistically and on the server,
// - modifies q1
// - modifies q2
const mutP = client.mutation(
"mutations:z",
{},
{
optimisticUpdate: (
localStore,
_args: FunctionArgs<typeof _apiMutationsZ>,
) => {
const curA = localStore.getQuery(apiQueriesA, {});
localStore.setQuery(
apiQueriesA,
{},
curA === undefined ? "a local" : `${curA} with a local applied`,
);
const curB = localStore.getQuery(apiQueriesB, {});
localStore.setQuery(
apiQueriesB,
{},
curB === undefined ? "b local" : `${curB} with b local applied`,
);
},
},
);
// Synchronously, the local store should update and the changes should be broadcast.
expect(client.localQueryResult("queries:a", {})).toEqual("a local");
// We haven't actually subscribed to this query but it had a value set in an optimistic update.
expect(client.localQueryResult("queries:b", {})).toEqual("b local");
const update1 = await q.updatePromises[0];
expect(q.updates).toHaveLength(1);
expect(update1).toEqual({
'{"udfPath":"queries:a","args":{}}': "a local",
'{"udfPath":"queries:b","args":{}}': "b local",
});
// Now a transition arrives containing only an update to query a.
// This previously crashed this execution context.
send({
type: "Transition",
startVersion: {
querySet: 0,
identity: 0,
ts: Long.fromNumber(0),
},
endVersion: {
querySet: 1,
identity: 0,
ts: Long.fromNumber(100),
},
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: "a server",
logLines: [],
journal: null,
},
],
});
const update2 = await q.updatePromises[1];
expect(update2).toEqual({
'{"udfPath":"queries:a","args":{}}': "a server with a local applied",
'{"udfPath":"queries:b","args":{}}': "b local",
});
expect(q.allResults).toEqual({
'{"udfPath":"queries:a","args":{}}': "a server with a local applied",
'{"udfPath":"queries:b","args":{}}': "b local",
});
expect(q.updates).toHaveLength(2);
const mutationRequest = await receive();
expect(mutationRequest.type).toEqual("Mutation");
expect(mutationRequest).toEqual({
type: "Mutation",
requestId: 0,
udfPath: "mutations:z",
args: [{}],
});
// Now the server sends:
// 1. MutationResponse saying the mutation has run
send({
type: "MutationResponse",
requestId: 0,
success: true,
result: null,
ts: Long.fromNumber(200), // "ZDhuVB3CRxg=", in example
logLines: [],
});
// 2. Transition bringing us up to date with the mutation
send({
type: "Transition",
startVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(200) },
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: "a server",
logLines: [],
journal: null,
},
],
});
expect(await q.updatePromises[2]).toEqual({
'{"udfPath":"queries:a","args":{}}': "a server",
// Now there's no more optimistic value for b!
'{"udfPath":"queries:b","args":{}}': undefined,
});
// After all that the mutation should resolve.
await mutP;
await client.close();
}, true);
});
/**
* Regression test for
* - subscribing to slow query a
* - subscribing to fast query b
* - receiving an update for b, without a
*/
test("Query results coming back out of order (fast query first, slower query later)", async () => {
await withInMemoryWebSocket(async ({ address, receive, send }) => {
const q = new UpdateQueue(10);
const client = new BaseConvexClient(
address,
(queryTokens) => {
q.onTransition(client)(queryTokens);
},
{
webSocketConstructor: nodeWebSocket,
unsavedChangesWarning: false,
verbose: true,
},
);
client.subscribe("queries:slow", {});
expect((await receive()).type).toEqual("Connect");
const modify = await receive();
expect(modify.type).toEqual("ModifyQuerySet");
if (modify.type !== "ModifyQuerySet") {
return;
}
expect(modify.modifications.length).toBe(1);
expect(modify.modifications).toEqual([
{
type: "Add",
queryId: 0,
udfPath: "queries:slow",
args: [{}],
},
]);
// Later we subscribe to a fast query
client.subscribe("queries:fast", {});
const modify2 = await receive();
expect(modify2.type).toEqual("ModifyQuerySet");
if (modify2.type !== "ModifyQuerySet") {
return;
}
expect(modify2.modifications.length).toBe(1);
expect(modify2.modifications).toEqual([
{
type: "Add",
queryId: 1,
udfPath: "queries:fast",
args: [{}],
},
]);
// Once the client has subscribed to queries:slow and queries:fast but has no results for either,
// the server is allowed to respond with a Transition that contains no mutations, "acknowledging"
// the slow query subscription but not responding with a value for it.
send({
type: "Transition",
startVersion: { querySet: 0, identity: 0, ts: Long.fromNumber(0) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
modifications: [
// This is unusual, but allowed.
],
});
// TODO test what happens when this first Transition contains an update for a query that does not exist in this query set.
// apparently this triggers an update???
const update1 = await q.awaitPromiseAtIndexWithTimeout(0);
expect(update1).toEqual({});
// Later the server sends a fast result
send({
type: "Transition",
startVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
endVersion: { querySet: 2, identity: 0, ts: Long.fromNumber(200) },
modifications: [
{
type: "QueryUpdated",
queryId: 1,
value: "fast result",
logLines: [],
journal: null,
},
],
});
const update2 = await q.awaitPromiseAtIndexWithTimeout(1);
expect(update2).toStrictEqual({
'{"udfPath":"queries:fast","args":{}}': "fast result",
});
expect(q.allResults).toStrictEqual({
'{"udfPath":"queries:fast","args":{}}': "fast result",
});
expect(q.updates).toHaveLength(2);
expect(client.localQueryResult("queries:fast", {})).toEqual("fast result");
expect(client.localQueryResult("queries:slow", {})).toEqual(undefined);
// Later the server sends a slow result
send({
type: "Transition",
startVersion: { querySet: 2, identity: 0, ts: Long.fromNumber(200) },
endVersion: { querySet: 2, identity: 0, ts: Long.fromNumber(300) },
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: "slow result",
logLines: [],
journal: null,
},
],
});
const update3 = await q.awaitPromiseAtIndexWithTimeout(2);
expect(update3).toStrictEqual({
'{"udfPath":"queries:slow","args":{}}': "slow result",
});
expect(q.allResults).toStrictEqual({
'{"udfPath":"queries:fast","args":{}}': "fast result",
'{"udfPath":"queries:slow","args":{}}': "slow result",
});
expect(q.updates).toHaveLength(3);
expect(client.localQueryResult("queries:fast", {})).toEqual("fast result");
expect(client.localQueryResult("queries:slow", {})).toEqual("slow result");
await client.close();
}, true);
});
/**
* Test to characterize behavior so we know if it changes. This behavior is not relied upon,
* we consider it a protocol error, but it does happen to work.
*
* - subscribe to slow query (query 0) (querySet 1)
* - subscribe to fast query (query 1) (querySet 2)
* - server sends message transitioning to querySet 1 that includes result for only the fast query (!)
* This works! The fast query result is not dropped on the floor.
* - server send message transitioning to querySet 2, plus it includes slow query.
*/
test("Transition contains result for query not in purported query set version", async () => {
await withInMemoryWebSocket(async ({ address, receive, send }) => {
const q = new UpdateQueue(10);
const client = new BaseConvexClient(
address,
(queryTokens) => {
q.onTransition(client)(queryTokens);
},
{
webSocketConstructor: nodeWebSocket,
unsavedChangesWarning: false,
verbose: true,
},
);
// Subscribe to slow query first
client.subscribe("queries:slow", {});
expect((await receive()).type).toEqual("Connect");
const modify1 = await receive();
expect(modify1.type).toEqual("ModifyQuerySet");
if (modify1.type !== "ModifyQuerySet") {
return;
}
expect(modify1.modifications).toEqual([
{
type: "Add",
queryId: 0,
udfPath: "queries:slow",
args: [{}],
},
]);
// Then subscribe to fast query
client.subscribe("queries:fast", {});
const modify2 = await receive();
expect(modify2.type).toEqual("ModifyQuerySet");
if (modify2.type !== "ModifyQuerySet") {
return;
}
expect(modify2.modifications).toEqual([
{
type: "Add",
queryId: 1,
udfPath: "queries:fast",
args: [{}],
},
]);
// Server sends malformed Transition: claims querySet version 1 (only slow query)
// but includes result for queryId 1 (fast query) which should be in querySet version 2
send({
type: "Transition",
startVersion: { querySet: 0, identity: 0, ts: Long.fromNumber(0) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
modifications: [
{
type: "QueryUpdated",
queryId: 1, // But provides result for fast query!
value: "fast result from malformed transition",
logLines: [],
journal: null,
},
],
});
// Client should still process the result (the client is forgiving)
const update1 = await q.awaitPromiseAtIndexWithTimeout(0);
expect(update1).toEqual({
'{"udfPath":"queries:fast","args":{}}':
"fast result from malformed transition",
});
// The fast query result should be available
expect(client.localQueryResult("queries:fast", {})).toEqual(
"fast result from malformed transition",
);
expect(client.localQueryResult("queries:slow", {})).toEqual(undefined);
// Next transition should work normally
send({
type: "Transition",
startVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
endVersion: { querySet: 2, identity: 0, ts: Long.fromNumber(200) },
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: "slow result",
logLines: [],
journal: null,
},
],
});
const update2 = await q.awaitPromiseAtIndexWithTimeout(1);
expect(update2).toEqual({
'{"udfPath":"queries:slow","args":{}}': "slow result",
});
// Both results should be available and the fast query result should persist
expect(client.localQueryResult("queries:fast", {})).toEqual(
"fast result from malformed transition",
);
expect(client.localQueryResult("queries:slow", {})).toEqual("slow result");
await client.close();
}, true);
});
/**
* Test to characterize existing behavior that we may want to change.
*
* - subscribe to a query
* - get a result
* - get a new "result" (Transition with QueryRemoved modification) that, if you didn't know better,
* you might send from the server to indicate this result is now loading
* - get a new real result (Transition with QueryUpdated modification)
*
* One might expect the client to publish updates "result, loading, result,"
* but the client only publishes "result."
*
* Currently the client stops tracking a query when it receives QueryRemoved
* from the server (it stops tracking it in the RemoteQuerySet).
*/
test("Query unsubscription triggers empty transition for listeners", async () => {
await withInMemoryWebSocket(async ({ address, receive, send }) => {
const q = new UpdateQueue(10);
const client = new BaseConvexClient(
address,
(queryTokens) => {
q.onTransition(client)(queryTokens);
},
{
webSocketConstructor: nodeWebSocket,
unsavedChangesWarning: false,
verbose: true,
},
);
client.subscribe("queries:test", {});
expect((await receive()).type).toEqual("Connect");
const modify1 = await receive();
expect(modify1.type).toEqual("ModifyQuerySet");
if (modify1.type !== "ModifyQuerySet") {
return;
}
expect(modify1.modifications).toEqual([
{
type: "Add",
queryId: 0,
udfPath: "queries:test",
args: [{}],
},
]);
// Server sends result for the query
send({
type: "Transition",
startVersion: { querySet: 0, identity: 0, ts: Long.fromNumber(0) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: "test result",
logLines: [],
journal: null,
},
],
});
const update1 = await q.awaitPromiseAtIndexWithTimeout(0);
expect(update1).toEqual({
'{"udfPath":"queries:test","args":{}}': "test result",
});
expect(q.allResults).toStrictEqual({
'{"udfPath":"queries:test","args":{}}': "test result",
});
expect(client.localQueryResult("queries:test", {})).toEqual("test result");
// "QueryRemoved" from the server communicates that this query is now in an loading state.
send({
type: "Transition",
startVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(100) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(200) },
modifications: [
{
type: "QueryRemoved",
queryId: 0,
},
],
});
// The update received contains nothing so there's no notification
// that this has gone back to undefined!
const update2 = await q.awaitPromiseAtIndexWithTimeout(1);
// What we wish happened, if removing a query is supposed to be
// the same as setting it to undefined.
/*
expect(update2).toStrictEqual({
'{"udfPath":"queries:test","args":{}}': undefined,
});
*/
// what actually happens
expect(update2).toStrictEqual({});
expect(q.updates).toHaveLength(2);
// The query result is no longer available locally after removal...
expect(client.localQueryResult("queries:test", {})).toEqual(undefined);
// ...but anyone listening won't have been updated.
expect(q.allResults).toEqual({
'{"udfPath":"queries:test","args":{}}': "test result",
});
// So e.g. a useQuery() React hook would return the correct value (undefined),
// but that component would not be triggered to rerender. The displayed result
// will be stale until the component is rerendered for some other reason.
// What if we set a result again?
send({
type: "Transition",
startVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(200) },
endVersion: { querySet: 1, identity: 0, ts: Long.fromNumber(300) },
modifications: [
{
type: "QueryUpdated",
queryId: 0,
value: "new test result",
logLines: [],
journal: null,
},
],
});
// What we might expect to happen if removing a query were equivalent to
// "updating the query to loading" and setting a value after worked:
/*
const update3 = await q.awaitPromiseAtIndexWithTimeout(2);
expect(update3).toEqual({
'{"udfPath":"queries:test","args":{}}': "new test result",
});
expect(q.allResults).toStrictEqual({
'{"udfPath":"queries:test","args":{}}': "new test result",
});
*/
// what actually happens
// wait a macrotask just in case
//await new Promise((r) => setTimeout(r, 0));
expect(q.updates).toHaveLength(2);
// This result is no longer tracked!
expect(client.localQueryResult("queries:test", {})).toEqual(undefined);
await client.close();
}, true);
});