UNPKG

convex

Version:

Client for the Convex Cloud

169 lines (149 loc) 5.38 kB
import { jsonToConvex } from "../../values/index.js"; import { createError, logToConsole } from "../logging.js"; import { Long } from "../long.js"; import { ClientMessage, MutationRequest, MutationResponse, RequestId, } from "./protocol.js"; type RequestStatus = | { status: "Requested"; onResult: (result: any) => void; onFailure: (reason: any) => void; } | { status: "Completed"; onResolve: () => void; ts: Long; }; // RequestManager tracks idempotent requests - Mutations. export class RequestManager { private inflightRequests: Map< RequestId, { message: MutationRequest; status: RequestStatus; } >; constructor() { this.inflightRequests = new Map(); } request(message: MutationRequest): Promise<any> { const result = new Promise((resolve, reject) => { this.inflightRequests.set(message.requestId, { message, status: { status: "Requested", onResult: resolve, onFailure: reject }, }); }); return result; } /** * Update the state after receiving a response. * * @returns A RequestId if the request is complete and its optimistic update * can be dropped, null otherwise. Only applies to mutations. */ onResponse(response: MutationResponse): RequestId | null { const requestInfo = this.inflightRequests.get(response.requestId); if (requestInfo === undefined) { // Annoyingly we can occasionally get responses to mutations that we're no // longer tracking. One flow where this happens is: // 1. Client sends mutation 1 // 2. Client gets response for mutation 1. The sever says that it was committed at ts=10. // 3. Client is disconnected // 4. Client reconnects and re-issues queries and this mutation. // 5. Server sends transition message to ts=20 // 6. Client drops mutation because it's already been observed. // 7. Client receives a second response for mutation 1 but doesn't know about it anymore. // The right fix for this is probably to add a reconciliation phase on // reconnection where we receive responses to all the mutations before // the transition message so this flow could never happen (CX-1513). // For now though, we can just ignore this message. return null; } // Because `.restart()` re-requests completed requests, we may get some // responses for requests that are already in the "Completed" state. // We can safely ignore those because we've already notified the UI about // their results. if (requestInfo.status.status !== "Requested") { return null; } const udfType = "mutation"; const udfPath = requestInfo.message.udfPath; for (const line of response.logLines) { logToConsole("info", udfType, udfPath, line); } const status = requestInfo.status; let onResolve; if (response.success) { onResolve = () => status.onResult(jsonToConvex(response.result)); } else { logToConsole("error", udfType, udfPath, response.result); onResolve = () => status.onFailure(createError(udfType, udfPath, response.result)); } if (!response.success) { // We can resolve Mutation failures immediately since they don't have any // side effects. onResolve(); this.inflightRequests.delete(response.requestId); return response.requestId; } // We have to wait to resolve the request promise until after we transition // past this timestamp so clients can read their own writes. requestInfo.status = { status: "Completed", ts: response.ts, onResolve, }; return null; } // Removed completed requests and returns the set of completed // mutations. removeCompleted(ts: Long): Set<RequestId> { const completeMutations: Set<RequestId> = new Set(); for (const [requestId, requestInfo] of this.inflightRequests.entries()) { const status = requestInfo.status; if (status.status === "Completed" && status.ts.lessThanOrEqual(ts)) { status.onResolve(); if (requestInfo.message.type === "Mutation") { completeMutations.add(requestId); } this.inflightRequests.delete(requestId); } } return completeMutations; } restart(): ClientMessage[] { // When we reconnect to the backend, re-request all the in-flight requests. // This includes ones that have already been completed because we still // want to tell the backend to transition the client past the completed // timestamp. This is safe because mutations are idempotent. const allMessages = []; for (const value of this.inflightRequests.values()) { allMessages.push(value.message); } return allMessages; } /** ** @returns true if there are any requests that have been requested but have ** not be completed yet. **/ hasIncompleteRequests(): boolean { for (const requestInfo of this.inflightRequests.values()) { if (requestInfo.status.status === "Requested") { return true; } } return false; } /** ** @returns true if there are any inflight requests, including ones that have ** completed on the server, but have not been applied. **/ hasInflightRequests(): boolean { return this.inflightRequests.size > 0; } }