@syncable/client
Version:
659 lines (525 loc) • 15.7 kB
text/typescript
import {
ChangePacket,
ChangePacketId,
ChangePlant,
ChangePlantBlueprint,
ClientRPCDefinition,
ConnectionRPCDefinition,
GeneralChange,
GeneralViewQuery,
IChangePlantBlueprintGenericParams,
IRPCDefinition,
ISyncable,
ISyncableAdapter,
ISyncableObject,
IViewQuery,
NumericTimestamp,
RPCMethod,
RPCPeer,
RPCPeerType,
ResolvedViewQuery,
ResolvedViewQueryType,
SyncData,
SyncUpdateSource,
SyncableContainer,
SyncableRef,
ViewQueryFilter,
ViewQueryUpdateObject,
generateUniqueId,
getSyncableKey,
getSyncableRef,
patch,
} from '@syncable/core';
import {Delta} from 'jsondiffpatch';
import _ from 'lodash';
import {action, observable, runInAction, toJS, when} from 'mobx';
import {Subject} from 'rxjs';
import {Dict} from 'tslang';
import {IClientAdapter} from './client-adapter';
const APPLYING_CHANGE_DEFAULT_SERVER_ONLY = false;
interface ViewQueryInfo {
filter: ViewQueryFilter;
query: IViewQuery;
}
interface PendingChangeInfo {
packet: ChangePacket;
refs: SyncableRef[];
confirmed: boolean;
}
export interface ClientApplyChangeResult {
id: ChangePacketId;
promise: Promise<void>;
}
export interface IClientGenericParams
extends IChangePlantBlueprintGenericParams {
syncableObject: ISyncableObject;
viewQueryDict: object;
customConnectionRPCDefinition: IRPCDefinition;
}
export class Client<TGenericParams extends IClientGenericParams>
extends RPCPeer<
ConnectionRPCDefinition | TGenericParams['customConnectionRPCDefinition']
>
implements RPCPeerType<ClientRPCDefinition> {
readonly container: SyncableContainer;
private _syncing = false;
private pendingQueryingNumber = 0;
private pendingChangeInfos: PendingChangeInfo[] = [];
private syncableSnapshotMap = new Map<string, ISyncable>();
private nameToViewQueryInfoMap = new Map<string, ViewQueryInfo>();
private changePlant: ChangePlant;
private initializeSubject$ = new Subject<void>();
private pendingRequestBatch:
| {
refs: SyncableRef[];
timer: number;
promise: Promise<void>;
resolve(): void;
}
| undefined;
readonly ready = this.initializeSubject$.toPromise();
constructor(
readonly context: TGenericParams['context'],
private clientAdapter: IClientAdapter<TGenericParams>,
private syncableAdapter: ISyncableAdapter<TGenericParams>,
blueprint: ChangePlantBlueprint<TGenericParams>,
) {
super(clientAdapter);
this.container = new SyncableContainer(syncableAdapter);
this.changePlant = new ChangePlant(blueprint as ChangePlantBlueprint);
clientAdapter.connect$.subscribe(this.onConnect);
}
get syncing(): boolean {
return this._syncing;
}
get querying(): boolean {
return this.pendingQueryingNumber !== 0;
}
getObjects(): TGenericParams['syncableObject'][];
getObjects<
TType extends TGenericParams['syncableObject']['syncable']['_type']
>(
type: TType,
): Extract<TGenericParams['syncableObject'], {syncable: {_type: TType}}>[];
getObjects(type?: string): TGenericParams['syncableObject'][] {
return this.container.getSyncableObjects(
type,
) as TGenericParams['syncableObject'][];
}
getObject<TRef extends TGenericParams['syncableObject']['ref']>(
ref: TRef,
): Extract<TGenericParams['syncableObject'], {ref: TRef}> | undefined {
return this.container.getSyncableObject(ref as SyncableRef) as
| Extract<TGenericParams['syncableObject'], {ref: TRef}>
| undefined;
}
requireObject<TRef extends TGenericParams['syncableObject']['ref']>(
ref: TRef,
): Extract<TGenericParams['syncableObject'], {ref: TRef}> {
return this.container.requireSyncableObject(ref as SyncableRef) as Extract<
TGenericParams['syncableObject'],
{ref: TRef}
>;
}
async requestObjects<TRef extends TGenericParams['syncableObject']['ref']>(
refs: TRef[],
): Promise<Extract<TGenericParams['syncableObject'], {ref: TRef}>[]> {
let container = this.container;
let pendingRequestBatch = this.pendingRequestBatch;
let batchedRefs: TRef[];
if (pendingRequestBatch) {
clearTimeout(pendingRequestBatch.timer);
this.pendingRequestBatch = undefined;
batchedRefs = [...(pendingRequestBatch.refs as TRef[]), ...refs];
} else {
batchedRefs = refs;
}
let missingSyncableRefs = batchedRefs.filter(
ref => !container.existsSyncable(ref),
);
if (missingSyncableRefs.length) {
await (this as RPCPeer<ConnectionRPCDefinition>).call(
'request-syncables',
missingSyncableRefs,
);
}
pendingRequestBatch?.resolve();
return refs
.map(ref => container.getSyncableObject(ref))
.filter(
(
object,
): object is Extract<TGenericParams['syncableObject'], {ref: TRef}> =>
!!object,
);
}
async requestObject<TRef extends TGenericParams['syncableObject']['ref']>(
ref: TRef,
): Promise<
Extract<TGenericParams['syncableObject'], {ref: TRef}> | undefined
> {
let pendingRequestBatch = this.pendingRequestBatch;
if (pendingRequestBatch) {
pendingRequestBatch.refs.push(ref);
} else {
let timer!: number;
let resolve!: () => void;
let promise = new Promise<void>(_resolve => {
timer = setTimeout(() => this.requestObjects([]));
resolve = _resolve;
});
pendingRequestBatch = {
refs: [ref],
timer,
promise,
resolve,
};
this.pendingRequestBatch = pendingRequestBatch;
}
await pendingRequestBatch.promise;
return this.container.getSyncableObject(ref);
}
getViewQueryFilter<
TName extends Extract<keyof TGenericParams['viewQueryDict'], string>
>(
name: TName,
resolvedViewQuery?: ResolvedViewQueryType<
TGenericParams['viewQueryDict'][TName]
>,
): ViewQueryFilter<TGenericParams['syncableObject']['syncable']> {
if (resolvedViewQuery) {
let context = this.context;
let syncableAdapter = this.syncableAdapter;
return syncableAdapter.getViewQueryFilter(
context,
name,
(resolvedViewQuery as unknown) as ResolvedViewQuery,
);
} else {
let info = this.nameToViewQueryInfoMap.get(name);
return info ? info.filter : () => false;
}
}
async query(
update: ViewQueryUpdateObject<TGenericParams['viewQueryDict']>,
forceUpdate?: boolean,
): Promise<void>;
async query(
update: ViewQueryUpdateObject,
forceUpdate?: boolean,
): Promise<void> {
runInAction(() => {
this.pendingQueryingNumber++;
});
try {
await this.ready;
await this._query(update, forceUpdate);
} finally {
runInAction(() => {
this.pendingQueryingNumber--;
});
}
}
applyChange(
change: TGenericParams['change'] | ChangePacket,
relatedGroups?: string[],
serverOnly = APPLYING_CHANGE_DEFAULT_SERVER_ONLY,
): ClientApplyChangeResult {
change = _.cloneDeep(change);
let id: ChangePacketId;
let packet: ChangePacket;
if ('id' in change) {
id = change.id;
packet = change;
} else {
id = generateUniqueId();
packet = {
id,
createdAt: Date.now() as NumericTimestamp,
relatedGroups,
...(change as GeneralChange),
};
}
if (!serverOnly) {
let info = this.applyChangePacket(packet);
this.pendingChangeInfos.push(info);
this._syncing = true;
}
let promise = (this as RPCPeer<ConnectionRPCDefinition>).call(
'apply-change',
packet,
);
promise.catch(() => {
this.sync(
{
syncables: [],
removals: [],
updates: [],
queryMetadata: {},
},
{
id,
clock: 0,
completed: true,
},
);
});
return {id, promise};
}
async applyChangeAndConfirm(
change: TGenericParams['change'],
relatedGroups?: string[],
serverOnly?: boolean,
): Promise<void> {
let {id, promise} = this.applyChange(change, relatedGroups, serverOnly);
await promise;
return when(
() => !this.pendingChangeInfos.some(info => info.packet.id === id),
);
}
initialize(
data: SyncData,
contextRef: SyncableRef,
viewQueryUpdateObject: ViewQueryUpdateObject,
): void {
this.container.clear();
let pendingChangeInfos = this.pendingChangeInfos;
let pendingPackets = pendingChangeInfos.map(info => info.packet);
pendingChangeInfos.length = 0;
this.sync(data);
this.context.setObject(this.requireObject(contextRef));
let viewQueryInfoMap = this.nameToViewQueryInfoMap;
let update: ViewQueryUpdateObject = {};
for (let [name, {query}] of viewQueryInfoMap) {
(update as Dict<IViewQuery>)[name] = query;
}
viewQueryInfoMap.clear();
for (let [name, query] of Object.entries(viewQueryUpdateObject)) {
if (query !== undefined) {
this.updateViewQueryInfo(name, query);
}
}
this._query(update)
.then(() => {
this.initializeSubject$.complete();
for (let packet of pendingPackets) {
try {
this.applyChange(packet);
} catch (error) {
console.error(error);
}
}
})
.catch(console.error);
}
sync(
{syncables, removals, updates, queryMetadata}: SyncData,
source?: SyncUpdateSource,
): void {
for (let [viewQueryName, metadata] of Object.entries(queryMetadata)) {
this.context.setQueryMetadata(viewQueryName, metadata);
}
let container = this.container;
let pendingChangeInfos = this.pendingChangeInfos;
let relevantRefs = _.flatMap(pendingChangeInfos, info => info.refs);
let clock: number | undefined;
let matchedPendingChangeInfo: PendingChangeInfo | undefined;
if (source) {
clock = source.clock;
matchedPendingChangeInfo = this.shiftPendingChangeInfo(source);
}
// Restore relevant syncables
let syncableSnapshotMap = this.syncableSnapshotMap;
for (let ref of relevantRefs) {
let key = getSyncableKey(ref);
let snapshot = syncableSnapshotMap.get(key);
if (snapshot) {
container.addSyncable(snapshot);
}
}
// Apply synced change
for (let syncable of syncables) {
this.onUpdateCreate(syncable, clock);
}
for (let {ref, delta} of updates) {
this.onUpdateChange(ref, delta, clock);
}
for (let ref of removals) {
this.onUpdateRemove(ref);
}
// Clean obsolete syncables.
// To avoid reference change, deletion of obsolete syncables need to be
// applied after updates and pending change packets.
if (matchedPendingChangeInfo) {
let obsoleteRefs = matchedPendingChangeInfo.refs.filter(
ref => !syncableSnapshotMap.has(getSyncableKey(ref)),
);
for (let ref of obsoleteRefs) {
container.removeSyncable(ref);
}
}
if (pendingChangeInfos.length) {
// Apply pending change.
for (let i = 0; i < pendingChangeInfos.length; i++) {
let pendingChangeInfo = pendingChangeInfos[i];
if (pendingChangeInfo.confirmed) {
continue;
}
pendingChangeInfos[i] = this.applyChangePacket(
pendingChangeInfo.packet,
);
}
}
if (!pendingChangeInfos.length) {
this._syncing = false;
}
}
private onUpdateCreate(syncable: ISyncable, clock: number | undefined): void {
this.container.addSyncable(syncable, clock);
let snapshot = _.cloneDeep(syncable);
this.syncableSnapshotMap.set(getSyncableKey(syncable), snapshot);
}
private onUpdateRemove(ref: SyncableRef): void {
this.container.removeSyncable(ref);
this.syncableSnapshotMap.delete(getSyncableKey(ref));
}
private onUpdateChange(
ref: SyncableRef,
delta: Delta,
clock: number | undefined,
): void {
let snapshot = this.syncableSnapshotMap.get(getSyncableKey(ref))!;
patch(snapshot, delta);
this.container.addSyncable(snapshot, clock);
}
private async _query(
update: ViewQueryUpdateObject,
forceUpdate = false,
): Promise<void> {
update = _.cloneDeep(update);
let viewQueryInfoMap = this.nameToViewQueryInfoMap;
let queryEntries = Object.entries(
update as Dict<GeneralViewQuery | false>,
).filter(([name, query]) => {
let info = viewQueryInfoMap.get(name);
if (info && _.isEqual(toJS(info.query), query) && !forceUpdate) {
delete (update as any)[name];
return false;
} else {
return true;
}
});
let refs = _.flatMapDeep<typeof queryEntries[number], SyncableRef>(
queryEntries,
([, query]) => {
return query ? Object.values(query.refs) : [];
},
);
await this.requestObjects(refs);
runInAction(() => {
for (let [name, query] of queryEntries) {
this.updateViewQueryInfo(name, query);
}
});
if (Object.keys(update).length === 0) {
return;
}
await (this as RPCPeer<ConnectionRPCDefinition>).call(
'update-view-query',
update,
);
}
private updateViewQueryInfo(name: string, query: IViewQuery | false): void {
let context = this.context;
let container = this.container;
let syncableAdapter = this.syncableAdapter;
let viewQueryInfoMap = this.nameToViewQueryInfoMap;
if (query) {
let {refs: refDict, options} = query;
let syncableDict = container.buildSyncableDict(refDict);
let resolvedViewQuery = {
syncables: syncableDict,
options,
};
let filter = syncableAdapter.getViewQueryFilter(
context,
name,
resolvedViewQuery,
);
viewQueryInfoMap.set(name, {
query,
filter,
});
} else {
viewQueryInfoMap.delete(name);
}
}
private onConnect = async (): Promise<void> => {
await (this as RPCPeer<ConnectionRPCDefinition>).call(
'initialize',
_.fromPairs(
Array.from(this.nameToViewQueryInfoMap).map(([key, info]) => [
key,
info.query,
]),
),
);
};
private shiftPendingChangeInfo(
source: SyncUpdateSource,
): PendingChangeInfo | undefined {
let infos = this.pendingChangeInfos;
let index = infos.findIndex(info => info.packet.id === source.id);
if (index < 0) {
return undefined;
}
let info = infos[index];
if (source.completed) {
infos.splice(0, index + 1);
return info;
} else {
info.confirmed = true;
return undefined;
}
}
private applyChangePacket(
packet: ChangePacket,
confirmed = false,
): PendingChangeInfo {
let container = this.container;
let {
updates,
creations,
removals,
notifications,
} = this.changePlant.process(packet, this.context, container);
let relevantRefs: SyncableRef[] = [];
for (let syncable of creations) {
container.addSyncable(syncable);
relevantRefs.push(getSyncableRef(syncable));
}
for (let {snapshot} of updates) {
container.addSyncable(snapshot);
relevantRefs.push(getSyncableRef(snapshot));
}
for (let ref of removals) {
container.removeSyncable(ref);
relevantRefs.push(ref);
}
this.clientAdapter.handleNotifications(notifications, packet.id);
return {
packet,
refs: relevantRefs,
confirmed,
};
}
}