convex
Version:
Client for the Convex Cloud
382 lines (346 loc) • 10.5 kB
text/typescript
import { convexToJson, Value } from "../../values/index.js";
import {
AddQuery,
RemoveQuery,
QueryId,
QuerySetModification,
QuerySetVersion,
IdentityVersion,
Authenticate,
QueryJournal,
Transition,
AdminAuthentication,
UserIdentityAttributes,
} from "./protocol.js";
import {
canonicalizeUdfPath,
QueryToken,
serializePathAndArgs,
} from "./udf_path_utils.js";
type LocalQuery = {
id: QueryId;
canonicalizedUdfPath: string;
args: Record<string, Value>;
numSubscribers: number;
journal?: QueryJournal;
};
export class LocalSyncState {
private nextQueryId: QueryId;
private querySetVersion: QuerySetVersion;
private readonly querySet: Map<QueryToken, LocalQuery>;
private readonly queryIdToToken: Map<QueryId, QueryToken>;
private identityVersion: IdentityVersion;
private auth?: {
tokenType: "Admin" | "User";
value: string;
impersonating?: UserIdentityAttributes;
};
private readonly outstandingQueriesOlderThanRestart: Set<QueryId>;
private outstandingAuthOlderThanRestart: boolean;
private paused: boolean;
private pendingQuerySetModifications: Map<QueryId, AddQuery | RemoveQuery>;
constructor() {
this.nextQueryId = 0;
this.querySetVersion = 0;
this.identityVersion = 0;
this.querySet = new Map();
this.queryIdToToken = new Map();
this.outstandingQueriesOlderThanRestart = new Set();
this.outstandingAuthOlderThanRestart = false;
this.paused = false;
this.pendingQuerySetModifications = new Map();
}
hasSyncedPastLastReconnect(): boolean {
return (
this.outstandingQueriesOlderThanRestart.size === 0 &&
!this.outstandingAuthOlderThanRestart
);
}
markAuthCompletion() {
this.outstandingAuthOlderThanRestart = false;
}
subscribe(
udfPath: string,
args: Record<string, Value>,
journal?: QueryJournal,
componentPath?: string,
): {
queryToken: QueryToken;
modification: QuerySetModification | null;
unsubscribe: () => QuerySetModification | null;
} {
const canonicalizedUdfPath = canonicalizeUdfPath(udfPath);
const queryToken = serializePathAndArgs(canonicalizedUdfPath, args);
const existingEntry = this.querySet.get(queryToken);
if (existingEntry !== undefined) {
existingEntry.numSubscribers += 1;
return {
queryToken,
modification: null,
unsubscribe: () => this.removeSubscriber(queryToken),
};
} else {
const queryId = this.nextQueryId++;
const query: LocalQuery = {
id: queryId,
canonicalizedUdfPath,
args,
numSubscribers: 1,
journal,
};
this.querySet.set(queryToken, query);
this.queryIdToToken.set(queryId, queryToken);
const baseVersion = this.querySetVersion;
const newVersion = this.querySetVersion + 1;
const add: AddQuery = {
type: "Add",
queryId,
udfPath: canonicalizedUdfPath,
args: [convexToJson(args)],
journal,
componentPath,
};
if (this.paused) {
this.pendingQuerySetModifications.set(queryId, add);
} else {
this.querySetVersion = newVersion;
}
const modification: QuerySetModification = {
type: "ModifyQuerySet",
baseVersion,
newVersion,
modifications: [add],
};
return {
queryToken,
modification,
unsubscribe: () => this.removeSubscriber(queryToken),
};
}
}
transition(transition: Transition) {
for (const modification of transition.modifications) {
switch (modification.type) {
case "QueryUpdated":
case "QueryFailed": {
this.outstandingQueriesOlderThanRestart.delete(modification.queryId);
const journal = modification.journal;
if (journal !== undefined) {
const queryToken = this.queryIdToToken.get(modification.queryId);
// We may have already unsubscribed to this query by the time the server
// sends us the journal. If so, just ignore it.
if (queryToken !== undefined) {
this.querySet.get(queryToken)!.journal = journal;
}
}
break;
}
case "QueryRemoved": {
this.outstandingQueriesOlderThanRestart.delete(modification.queryId);
break;
}
default: {
// Enforce that the switch-case is exhaustive.
const _: never = modification;
throw new Error(`Invalid modification ${(modification as any).type}`);
}
}
}
}
queryId(udfPath: string, args: Record<string, Value>): QueryId | null {
const canonicalizedUdfPath = canonicalizeUdfPath(udfPath);
const queryToken = serializePathAndArgs(canonicalizedUdfPath, args);
const existingEntry = this.querySet.get(queryToken);
if (existingEntry !== undefined) {
return existingEntry.id;
}
return null;
}
isCurrentOrNewerAuthVersion(version: IdentityVersion): boolean {
return version >= this.identityVersion;
}
setAuth(value: string): Authenticate {
this.auth = {
tokenType: "User",
value: value,
};
const baseVersion = this.identityVersion;
if (!this.paused) {
this.identityVersion = baseVersion + 1;
}
return {
type: "Authenticate",
baseVersion: baseVersion,
...this.auth,
};
}
setAdminAuth(
value: string,
actingAs?: UserIdentityAttributes,
): AdminAuthentication {
const auth: typeof this.auth & {
tokenType: "Admin";
} = {
tokenType: "Admin",
value,
impersonating: actingAs,
};
this.auth = auth;
const baseVersion = this.identityVersion;
if (!this.paused) {
this.identityVersion = baseVersion + 1;
}
return {
type: "Authenticate",
baseVersion: baseVersion,
...auth,
};
}
clearAuth(): Authenticate {
this.auth = undefined;
this.markAuthCompletion();
const baseVersion = this.identityVersion;
if (!this.paused) {
this.identityVersion = baseVersion + 1;
}
return {
type: "Authenticate",
tokenType: "None",
baseVersion: baseVersion,
};
}
hasAuth(): boolean {
return !!this.auth;
}
isNewAuth(value: string): boolean {
return this.auth?.value !== value;
}
queryPath(queryId: QueryId): string | null {
const pathAndArgs = this.queryIdToToken.get(queryId);
if (pathAndArgs) {
return this.querySet.get(pathAndArgs)!.canonicalizedUdfPath;
}
return null;
}
queryArgs(queryId: QueryId): Record<string, Value> | null {
const pathAndArgs = this.queryIdToToken.get(queryId);
if (pathAndArgs) {
return this.querySet.get(pathAndArgs)!.args;
}
return null;
}
queryToken(queryId: QueryId): string | null {
return this.queryIdToToken.get(queryId) ?? null;
}
queryJournal(queryToken: QueryToken): QueryJournal | undefined {
return this.querySet.get(queryToken)?.journal;
}
restart(
oldRemoteQueryResults: Set<QueryId>,
): [QuerySetModification, Authenticate?] {
// Restart works whether we are paused or unpaused.
// The `this.pendingQuerySetModifications` is not used
// when restarting as the AddQuery and RemoveQuery are computed
// from scratch, based on the old remote query results, here.
this.unpause();
this.outstandingQueriesOlderThanRestart.clear();
const modifications = [];
for (const localQuery of this.querySet.values()) {
const add: AddQuery = {
type: "Add",
queryId: localQuery.id,
udfPath: localQuery.canonicalizedUdfPath,
args: [convexToJson(localQuery.args)],
journal: localQuery.journal,
};
modifications.push(add);
if (!oldRemoteQueryResults.has(localQuery.id)) {
this.outstandingQueriesOlderThanRestart.add(localQuery.id);
}
}
this.querySetVersion = 1;
const querySet: QuerySetModification = {
type: "ModifyQuerySet",
baseVersion: 0,
newVersion: 1,
modifications,
};
// If there's no auth, no need to send an update as the server will also start with an unknown identity.
if (!this.auth) {
this.identityVersion = 0;
return [querySet, undefined];
}
this.outstandingAuthOlderThanRestart = true;
const authenticate: Authenticate = {
type: "Authenticate",
baseVersion: 0,
...this.auth,
};
this.identityVersion = 1;
return [querySet, authenticate];
}
pause() {
this.paused = true;
}
resume(): [QuerySetModification?, Authenticate?] {
const querySet: QuerySetModification | undefined =
this.pendingQuerySetModifications.size > 0
? {
type: "ModifyQuerySet",
baseVersion: this.querySetVersion,
newVersion: ++this.querySetVersion,
modifications: Array.from(
this.pendingQuerySetModifications.values(),
),
}
: undefined;
const authenticate: Authenticate | undefined =
this.auth !== undefined
? {
type: "Authenticate",
baseVersion: this.identityVersion++,
...this.auth,
}
: undefined;
this.unpause();
return [querySet, authenticate];
}
private unpause() {
this.paused = false;
this.pendingQuerySetModifications.clear();
}
private removeSubscriber(
queryToken: QueryToken,
): QuerySetModification | null {
const localQuery = this.querySet.get(queryToken)!;
if (localQuery.numSubscribers > 1) {
localQuery.numSubscribers -= 1;
return null;
} else {
this.querySet.delete(queryToken);
this.queryIdToToken.delete(localQuery.id);
this.outstandingQueriesOlderThanRestart.delete(localQuery.id);
const baseVersion = this.querySetVersion;
const newVersion = this.querySetVersion + 1;
const remove: RemoveQuery = {
type: "Remove",
queryId: localQuery.id,
};
if (this.paused) {
if (this.pendingQuerySetModifications.has(localQuery.id)) {
this.pendingQuerySetModifications.delete(localQuery.id);
} else {
this.pendingQuerySetModifications.set(localQuery.id, remove);
}
} else {
this.querySetVersion = newVersion;
}
return {
type: "ModifyQuerySet",
baseVersion,
newVersion,
modifications: [remove],
};
}
}
}