UNPKG

convex

Version:

Client for the Convex Cloud

223 lines (201 loc) 7.12 kB
import { Value } from "@convex-dev/common"; import { GenericAPI, NamedQuery, QueryNames } from "../api.js"; import { createError } from "../logging.js"; import { MutationId } from "./protocol.js"; import { QueryResult } from "./remote_query_set.js"; import { QueryToken, serializePathAndArgs } from "./udf_path_utils.js"; /** * A view of the query results currently in the Convex client for use within * optimistic updates. * * @public */ export interface OptimisticLocalStore<API extends GenericAPI = GenericAPI> { /** * Retrieve the result of a query from the client. * * Important: Query results should be treated as immutable! * Always make new copies of structures within query results to avoid * corrupting data within the client. * * @param name - The name of the query. * @param args - An array of the arguments for this query. * @returns The query result or `undefined` if the query is not currently * in the client. */ getQuery<Name extends QueryNames<API>>( name: Name, args: Parameters<NamedQuery<API, Name>> ): undefined | ReturnType<NamedQuery<API, Name>>; /** * Optimistically update the result of a query. * * This can either be a new value (perhaps derived from the old value from * {@link OptimisticLocalStore.getQuery}) or `undefined` to remove the query. * Removing a query is useful to create loading states while Convex recomputes * the query results. * * @param name - The name of the query. * @param args - An array of the arguments for this query. * @param value - The new value to set the query to or `undefined` to remove * it from the client. */ setQuery<Name extends QueryNames<API>>( name: Name, args: Parameters<NamedQuery<API, Name>>, value: undefined | ReturnType<NamedQuery<API, Name>> ): void; } /** * A temporary, local update to query results within this client. * * This update will always be executed when a mutation is synced to the Convex * server and rolled back when the mutation completes. * * Note that optimistic updates can be called multiple times! If the client * loads new data while the mutation is in progress, the update will be replayed * again. * * @param localQueryStore - An interface to read and edit local query results. * @param args - The arguments to the mutation. * * @public */ export type OptimisticUpdate< API extends GenericAPI, Arguments extends any[] > = (localQueryStore: OptimisticLocalStore<API>, ...args: Arguments) => void; /** * An optimistic update function that has been curried over its arguments. */ type WrappedOptimisticUpdate = (locaQueryStore: OptimisticLocalStore) => void; /** * The implementation of `OptimisticLocalStore`. * * This class provides the interface for optimistic updates to modify query results. */ class OptimisticLocalStoreImpl implements OptimisticLocalStore { // A references of the query results in OptimisticQueryResults private readonly queryResults: QueryResultsMap; // All of the queries modified by this class readonly modifiedQueries: QueryToken[]; constructor(queryResults: QueryResultsMap) { this.queryResults = queryResults; this.modifiedQueries = []; } getQuery(name: string, args: Value[]): Value | undefined { const query = this.queryResults.get(serializePathAndArgs(name, args)); if (query === undefined) { return undefined; } const result = query.result; if (result === undefined) { return undefined; } else if (result.success) { return result.value; } else { // If the query is an error state, just return `undefined` as though // it's loading. Optimistic updates should already handle `undefined` well // and there isn't a need to break the whole update because it tried // to load a single query that errored. return undefined; } } setQuery(name: string, args: Value[], value: Value | undefined): void { const queryToken = serializePathAndArgs(name, args); let result: QueryResult | undefined; if (value === undefined) { result = undefined; } else { result = { success: true, value, }; } const query: Query = { udfPath: name, args, result, }; this.queryResults.set(queryToken, query); this.modifiedQueries.push(queryToken); } } type OptimisticUpdateAndId = { update: WrappedOptimisticUpdate; mutationId: MutationId; }; type Query = { // undefined means the query was set to be loading (undefined) in an optimistic update. // Note that we can also have queries not present in the QueryResultMap // at all because they are still loading from the server. result: QueryResult | undefined; udfPath: string; args: Value[]; }; export type QueryResultsMap = Map<QueryToken, Query>; type ChangedQueries = QueryToken[]; /** * A view of all of our query results with optimistic updates applied on top. */ export class OptimisticQueryResults { private queryResults: QueryResultsMap; private optimisticUpdates: OptimisticUpdateAndId[]; constructor() { this.queryResults = new Map(); this.optimisticUpdates = []; } ingestQueryResultsFromServer( serverQueryResults: QueryResultsMap, optimisticUpdatesToDrop: Set<MutationId> ): ChangedQueries { this.optimisticUpdates = this.optimisticUpdates.filter(updateAndId => { return !optimisticUpdatesToDrop.has(updateAndId.mutationId); }); const oldQueryResults = this.queryResults; this.queryResults = new Map(serverQueryResults); const localStore = new OptimisticLocalStoreImpl(this.queryResults); for (const updateAndId of this.optimisticUpdates) { updateAndId.update(localStore); } // To find the changed queries, just do a shallow comparison // TODO(CX-733): Change this so we avoid unnecessary rerenders const changedQueries: ChangedQueries = []; for (const [queryToken, query] of this.queryResults) { const oldQuery = oldQueryResults.get(queryToken); if (oldQuery === undefined || oldQuery.result !== query.result) { changedQueries.push(queryToken); } } return changedQueries; } applyOptimisticUpdate( update: WrappedOptimisticUpdate, mutationId: MutationId ): ChangedQueries { // Apply the update to our store this.optimisticUpdates.push({ update, mutationId, }); const localStore = new OptimisticLocalStoreImpl(this.queryResults); update(localStore); // Notify about any query results that changed // TODO(CX-733): Change this so we avoid unnecessary rerenders return localStore.modifiedQueries; } queryResult(queryToken: QueryToken): Value | undefined { const query = this.queryResults.get(queryToken); if (query === undefined) { return undefined; } const result = query.result; if (result === undefined) { return undefined; } else if (result.success) { return result.value; } else { throw createError("query", query.udfPath, result.errorMessage); } } }