@syncable/core
Version:
589 lines (487 loc) • 16.2 kB
text/typescript
import {Delta} from 'jsondiffpatch';
import _ from 'lodash';
import {Dict, KeyOfValueWithType} from 'tslang';
import {IContext} from '../context';
import {diff} from '../diff-patcher';
import {
AccessRight,
ISyncable,
ISyncableObject,
RefDictToSyncableObjectDict,
SyncableContainer,
SyncableRef,
getSyncableKey,
getSyncableRef,
} from '../syncable';
import {NumericTimestamp} from '../types';
import {
ChangePacket,
ChangePacketId,
GeneralChange,
IChange,
SyncableCreationRef,
} from './change';
export type RefDictToSyncableOrCreationRefDict<
T extends object
> = T extends object
? {
[TName in KeyOfValueWithType<Required<T>, SyncableRef>]: NonNullable<
T[TName]
> extends SyncableRef<infer TSyncableObject>
?
| TSyncableObject['syncable']
| (undefined extends T[TName] ? undefined : never)
: never;
} &
{
[TName in KeyOfValueWithType<Required<T>, SyncableRef[]>]: NonNullable<
T[TName]
> extends SyncableRef<infer TSyncableObject>[]
?
| TSyncableObject['syncable'][]
| (undefined extends T[TName] ? undefined : never)
: never;
} &
{
[TName in KeyOfValueWithType<
Required<T>,
SyncableCreationRef
>]: T[TName];
}
: never;
type ChangeToSyncableObjectRefDict<
T extends IChange
> = RefDictToSyncableObjectDict<T['refs']>;
type ChangeToSyncableOrCreationRefDict<
T extends IChange
> = RefDictToSyncableOrCreationRefDict<T['refs']>;
export interface ChangePlantProcessingResultUpdateItem {
delta: Delta;
snapshot: ISyncable;
}
export interface ChangePlantProcessingResult {
id: ChangePacketId;
creations: ISyncable[];
updates: ChangePlantProcessingResultUpdateItem[];
removals: SyncableRef[];
notifications: unknown[];
changes: GeneralChange[];
}
export interface ChangePlantProcessingResultWithClock
extends ChangePlantProcessingResult {
clock: number;
}
export type ChangePlantProcessorCreateOperation = (creation: ISyncable) => void;
export type ChangePlantProcessorRemoveOperation = (
object: ISyncableObject,
) => void;
export type ChangePlantProcessorIsBeingRemovedTest = (
object: ISyncableObject,
) => boolean;
export type ChangePlantProcessorGrantOperation = <
TSyncableObject extends ISyncableObject
>(
object: TSyncableObject,
fieldNames: Extract<keyof TSyncableObject['syncable'], string>[],
rights: AccessRight[],
) => void;
export type ChangePlantProcessorAbortOperation = () => void;
export type ChangePlantProcessorChangeOperation<TChange = GeneralChange> = (
change: TChange,
) => void;
export type ChangePlantProcessorPrepareOperation = <T extends ISyncableObject>(
object: T,
) => T['syncable'];
export type ChangePlantProcessorNotifyOperation<TNotification = unknown> = (
notification: TNotification,
) => void;
export interface ChangePlantProcessorExtra<
TGenericParams extends IChangePlantBlueprintGenericParams = GeneralChangePlantBlueprintGenericParams,
TSpecificChange extends IChange = TGenericParams['change']
> {
context: TGenericParams['context'];
container: SyncableContainer<TGenericParams['syncableObject']>;
type: TSpecificChange['type'];
refs: TSpecificChange['refs'];
options: TSpecificChange['options'];
create: ChangePlantProcessorCreateOperation;
remove: ChangePlantProcessorRemoveOperation;
isBeingRemoved: ChangePlantProcessorIsBeingRemovedTest;
grant: ChangePlantProcessorGrantOperation;
prepare: ChangePlantProcessorPrepareOperation;
abort: ChangePlantProcessorAbortOperation;
change: ChangePlantProcessorChangeOperation<TGenericParams['change']>;
notify: ChangePlantProcessorNotifyOperation<TGenericParams['notification']>;
createdAt: NumericTimestamp;
}
export type ChangePlantResolver<
TGenericParams extends IChangePlantBlueprintGenericParams = GeneralChangePlantBlueprintGenericParams
> = (
syncables: ChangeToSyncableOrCreationRefDict<TGenericParams['change']>,
options: TGenericParams['change']['options'],
) => SyncableRef<TGenericParams['syncableObject']>[];
type ChangePlantSpecificResolver<
TGenericParams extends IChangePlantBlueprintGenericParams,
TType extends string
> = ChangePlantResolver<{
context: TGenericParams['context'];
syncableObject: TGenericParams['syncableObject'];
change: Extract<TGenericParams['change'], {type: TType}>;
notification: TGenericParams['notification'];
}>;
export type ChangePlantProcessor<
TGenericParams extends IChangePlantBlueprintGenericParams = GeneralChangePlantBlueprintGenericParams,
TSpecificChange extends TGenericParams['change'] = TGenericParams['change']
> = (
syncables: ChangeToSyncableOrCreationRefDict<TSpecificChange>,
objects: ChangeToSyncableObjectRefDict<TSpecificChange>,
extra: ChangePlantProcessorExtra<TGenericParams, TSpecificChange>,
) => void;
type ChangePlantSpecificProcessor<
TGenericParams extends IChangePlantBlueprintGenericParams,
TType extends string
> = ChangePlantProcessor<
{
context: TGenericParams['context'];
syncableObject: TGenericParams['syncableObject'];
change: TGenericParams['change'];
notification: TGenericParams['notification'];
},
Extract<TGenericParams['change'], {type: TType}>
>;
export interface ChangePlantProcessorOptions<
TGenericParams extends IChangePlantBlueprintGenericParams = IChangePlantBlueprintGenericParams
> {
processor: ChangePlantProcessor<TGenericParams>;
resolver: ChangePlantResolver<TGenericParams>;
}
interface ChangePlantSpecificProcessorOptions<
TGenericParams extends IChangePlantBlueprintGenericParams,
TType extends string
> {
processor: ChangePlantSpecificProcessor<TGenericParams, TType>;
resolver: ChangePlantSpecificResolver<TGenericParams, TType>;
}
export type ChangePlantBlueprint<
TGenericParams extends IChangePlantBlueprintGenericParams = GeneralChangePlantBlueprintGenericParams
> = {
[TType in TGenericParams['change']['type']]:
| ChangePlantSpecificProcessor<TGenericParams, TType>
| ChangePlantSpecificProcessorOptions<TGenericParams, TType>;
};
export interface IChangePlantBlueprintGenericParams {
context: IContext;
syncableObject: ISyncableObject;
change: IChange;
notification: unknown;
}
export interface GeneralChangePlantBlueprintGenericParams
extends IChangePlantBlueprintGenericParams {
change: GeneralChange;
}
export type ChangePlantResolveSyncableLoader = (
refs: SyncableRef[],
) => Promise<ISyncable[]>;
export class ChangePlant {
constructor(private blueprint: ChangePlantBlueprint) {}
resolve(
{type, refs: refDict, options}: ChangePacket,
syncables: ISyncable[],
): SyncableRef[] {
let processorOptions = this.blueprint[type];
let resolver =
typeof processorOptions === 'object'
? processorOptions.resolver
: undefined;
if (!resolver) {
return [];
}
let syncableMap = new Map(
syncables.map((syncable): [string, ISyncable] => [
getSyncableKey(syncable),
syncable,
]),
);
let syncableDict: Dict<ISyncable | ISyncable[]> = {};
for (let [name, ref] of Object.entries(refDict)) {
if (!ref) {
continue;
}
if (Array.isArray(ref)) {
syncableDict[name] = ref.map(
ref => syncableMap.get(getSyncableKey(ref))!,
);
} else {
syncableDict[name] = syncableMap.get(getSyncableKey(ref))!;
}
}
return resolver(syncableDict, options);
}
process(
packet: ChangePacket,
context: IContext,
container: SyncableContainer,
): ChangePlantProcessingResult;
process(
packet: ChangePacket,
context: IContext,
container: SyncableContainer,
clock: number,
): ChangePlantProcessingResultWithClock;
process(
{id, type, refs: refDict, options, createdAt}: ChangePacket,
context: IContext,
container: SyncableContainer,
clock?: number,
): ChangePlantProcessingResult | ChangePlantProcessingResultWithClock {
options = _.cloneDeep(options);
let now =
context.environment === 'client'
? createdAt
: (Date.now() as NumericTimestamp);
let preparedSyncableObjectMap = new Map<string, ISyncableObject>();
let preparedSyncableObjectToSyncableMap = new Map<
ISyncableObject,
ISyncable
>();
let syncableObjectToFieldNameToGrantedRightsMapMap = new Map<
ISyncableObject,
Map<string, AccessRight[]>
>();
interface PreparedBundle {
latest: ISyncable;
clone: ISyncable;
object: ISyncableObject;
}
let preparedBundles: PreparedBundle[] = [];
let creations: ISyncable[] = [];
let removals: SyncableRef[] = [];
let removalObjectSet = new Set<ISyncableObject>();
let updates: ChangePlantProcessingResultUpdateItem[] = [];
let notifications: unknown[] = [];
let changes: GeneralChange[] = [];
let create: ChangePlantProcessorCreateOperation = creation => {
if (clock !== undefined) {
creation._clock = clock;
}
creation._createdAt = now;
creation._updatedAt = now;
creations.push(creation);
};
let remove: ChangePlantProcessorRemoveOperation = object => {
object.validateAccessRights(['full'], context);
removals.push(object.ref);
removalObjectSet.add(object);
};
let isBeingRemoved: ChangePlantProcessorIsBeingRemovedTest = object => {
return removalObjectSet.has(object);
};
let grant: ChangePlantProcessorGrantOperation = (
object,
fieldNames,
rights,
) => {
let fieldNameToGrantedRightsMap = syncableObjectToFieldNameToGrantedRightsMapMap.get(
object,
);
if (!fieldNameToGrantedRightsMap) {
fieldNameToGrantedRightsMap = new Map();
syncableObjectToFieldNameToGrantedRightsMapMap.set(
object,
fieldNameToGrantedRightsMap,
);
}
for (let fieldName of fieldNames) {
fieldNameToGrantedRightsMap.set(
fieldName,
_.union(fieldNameToGrantedRightsMap.get(fieldName), rights),
);
}
};
let prepare: ChangePlantProcessorPrepareOperation = object => {
let clone = preparedSyncableObjectToSyncableMap.get(object);
if (clone) {
return clone;
}
object.validateAccessRights(['read'], context);
let latest = object.syncable;
clone = _.cloneDeep(latest);
preparedBundles.push({
latest,
clone,
object,
});
let key = getSyncableKey(object.ref);
preparedSyncableObjectMap.set(key, object);
preparedSyncableObjectToSyncableMap.set(object, clone);
return clone;
};
let notify: ChangePlantProcessorNotifyOperation = notification => {
notifications.push(notification);
};
let clonedSyncableOrCreationRefDict: Dict<
ISyncable | ISyncable[] | SyncableCreationRef
> = {};
let syncableObjectDict: Dict<ISyncableObject | ISyncableObject[]> = {};
for (let [name, ref] of Object.entries(refDict)) {
if (!ref) {
continue;
}
if ('id' in ref) {
let object = container.requireSyncableObject(ref);
clonedSyncableOrCreationRefDict[name] = prepare(object);
syncableObjectDict[name] = object;
} else if (Array.isArray(ref)) {
let objects = ref
.filter(ref => 'id' in ref)
.map(ref => container.requireSyncableObject(ref));
clonedSyncableOrCreationRefDict[name] = objects.map(object =>
prepare(object),
);
syncableObjectDict[name] = objects;
} else {
clonedSyncableOrCreationRefDict[name] = ref;
}
}
let processor = this.blueprint[type];
if (typeof processor === 'object') {
processor = processor.processor;
}
let aborted = false;
let abort: ChangePlantProcessorAbortOperation = () => {
aborted = true;
};
let change: ChangePlantProcessorChangeOperation = subsequentChange => {
changes.push(subsequentChange);
};
processor(clonedSyncableOrCreationRefDict, syncableObjectDict, {
context,
container,
create,
remove,
isBeingRemoved,
grant,
prepare,
notify,
abort,
change,
type,
options,
refs: refDict,
createdAt: now,
});
if (aborted) {
return {
id,
updates: [],
creations: [],
removals: [],
notifications,
changes,
};
}
let updatedContainer = new SyncableContainer(container.adapter);
for (let object of container.getSyncableObjects()) {
if (removalObjectSet.has(object)) {
continue;
}
let syncable =
preparedSyncableObjectToSyncableMap.get(object) ?? object.syncable;
updatedContainer.addSyncable(syncable);
}
for (let createdSyncable of creations) {
updatedContainer.addSyncable(createdSyncable);
}
for (let {
latest: latestSyncable,
clone: updatedSyncableClone,
object: latestSyncableObject,
} of preparedBundles) {
if (removalObjectSet.has(latestSyncableObject)) {
continue;
}
if (clock !== undefined) {
updatedSyncableClone._clock = clock;
}
updatedSyncableClone._updatedAt = now;
let syncableOverrides = updatedContainer
.requireSyncableObject(latestSyncableObject.ref)
.getSyncableOverrides();
Object.assign(updatedSyncableClone, syncableOverrides);
let overriddenFieldNames = Object.keys(syncableOverrides);
let delta = diff(latestSyncable, updatedSyncableClone) || {};
let changedFieldNameSet = new Set(Object.keys(delta));
changedFieldNameSet.delete('_clock');
changedFieldNameSet.delete('_updatedAt');
if (!changedFieldNameSet.size) {
continue;
}
if (
changedFieldNameSet.has('_id') ||
changedFieldNameSet.has('_type') ||
changedFieldNameSet.has('_extends')
) {
throw new Error('Invalid operation');
}
let requiredRightSet = new Set<AccessRight>(['write']);
let securingFieldNameSet = new Set(
latestSyncableObject.getSecuringFieldNames(),
);
for (let fieldName of changedFieldNameSet) {
if (/^_/.test(fieldName) || securingFieldNameSet.has(fieldName)) {
requiredRightSet.add('full');
break;
}
}
let nonOverriddenChangedFieldNames = _.difference(
Array.from(changedFieldNameSet),
overriddenFieldNames,
);
let fieldNameToGrantedRightsMap = syncableObjectToFieldNameToGrantedRightsMapMap.get(
latestSyncableObject,
);
if (fieldNameToGrantedRightsMap) {
for (let requiredRight of requiredRightSet) {
let fieldNames = nonOverriddenChangedFieldNames.filter(fieldName => {
let grantedRights = fieldNameToGrantedRightsMap!.get(fieldName);
if (grantedRights) {
// If access right is granted to this field, return false to ignore this field.
return !grantedRights.includes(requiredRight);
} else {
return true;
}
});
if (fieldNames.length) {
latestSyncableObject.validateAccessRights(
[requiredRight],
context,
fieldNames,
);
}
}
} else {
latestSyncableObject.validateAccessRights(
Array.from(requiredRightSet),
context,
nonOverriddenChangedFieldNames,
);
}
updates.push({delta, snapshot: updatedSyncableClone});
}
for (let createdSyncable of creations) {
let syncableOverrides = updatedContainer
.requireSyncableObject(getSyncableRef(createdSyncable))
.getSyncableOverrides();
Object.assign(createdSyncable, syncableOverrides);
}
return {
id,
clock,
updates,
creations: creations || [],
removals: removals || [],
notifications,
changes,
};
}
}