convex
Version:
Client for the Convex Cloud
223 lines (201 loc) • 7.12 kB
text/typescript
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);
}
}
}