@wordpress/sync
Version:
309 lines (265 loc) • 8.97 kB
text/typescript
/**
* External dependencies
*/
import * as Y from 'yjs';
/**
* Internal dependencies
*/
import {
CRDT_RECORD_MAP_KEY as RECORD_KEY,
LOCAL_SYNC_MANAGER_ORIGIN,
} from './config';
import { createPersistedCRDTDoc, getPersistedCrdtDoc } from './persistence';
import { getProviderCreators } from './providers';
import type {
CRDTDoc,
EntityID,
ObjectID,
ObjectData,
ObjectType,
ProviderCreator,
RecordHandlers,
SyncConfig,
SyncManager,
} from './types';
import { createUndoManager } from './undo-manager';
import { createYjsDoc } from './utils';
interface EntityState {
handlers: RecordHandlers;
objectId: ObjectID;
objectType: ObjectType;
syncConfig: SyncConfig;
unload: () => void;
ydoc: CRDTDoc;
}
/**
* The sync manager orchestrates the lifecycle of syncing entity records. It
* creates Yjs documents, connects to providers, creates awareness instances,
* and coordinates with the `core-data` store.
*/
export function createSyncManager(): SyncManager {
const entityStates: Map< EntityID, EntityState > = new Map();
const undoManager = createUndoManager();
/**
* Load an entity for syncing and manage its lifecycle.
*
* @param {SyncConfig} syncConfig Sync configuration for the object type.
* @param {ObjectType} objectType Object type.
* @param {ObjectID} objectId Object ID.
* @param {ObjectData} record Entity record representing this object type.
* @param {RecordHandlers} handlers Handlers for updating and fetching the record.
*/
async function loadEntity(
syncConfig: SyncConfig,
objectType: ObjectType,
objectId: ObjectID,
record: ObjectData,
handlers: RecordHandlers
): Promise< void > {
const providerCreators: ProviderCreator[] = getProviderCreators();
if ( 0 === providerCreators.length ) {
return; // No provider creators, so syncing is effectively disabled.
}
const entityId = getEntityId( objectType, objectId );
if ( entityStates.has( entityId ) ) {
return; // Already bootstrapped.
}
const ydoc = createYjsDoc( { objectType } );
const recordMap = ydoc.getMap( RECORD_KEY );
// Clean up providers and in-memory state when the entity is unloaded.
const unload = (): void => {
providerResults.forEach( ( result ) => result.destroy() );
recordMap.unobserveDeep( onRecordUpdate );
ydoc.destroy();
entityStates.delete( entityId );
};
// When the CRDT document is updated by an UndoManager or a connection (not
// a local origin), update the local store.
const onRecordUpdate = (
_events: Y.YEvent< any >[],
transaction: Y.Transaction
): void => {
if (
transaction.local &&
! ( transaction.origin instanceof Y.UndoManager )
) {
return;
}
void updateEntityRecord( objectType, objectId );
};
undoManager.addToScope( recordMap );
const entityState: EntityState = {
handlers,
objectId,
objectType,
syncConfig,
unload,
ydoc,
};
entityStates.set( entityId, entityState );
// Create providers for the given entity and its Yjs document.
const providerResults = await Promise.all(
providerCreators.map( ( create ) =>
create( objectType, objectId, ydoc )
)
);
// Attach observers.
recordMap.observeDeep( onRecordUpdate );
// Get and apply the persisted CRDT document, if it exists.
const isInvalid = applyPersistedCrdtDoc( syncConfig, ydoc, record );
// If there is no persisted document or if it has been invalidated by out-of-
// band updates, apply changes from the current entity record to the CRDT
// document. This ensures that the CRDT document reflects the latest state of
// the entity record.
if ( isInvalid ) {
ydoc.transact( () => {
syncConfig.applyChangesToCRDTDoc( ydoc, record );
}, LOCAL_SYNC_MANAGER_ORIGIN );
const meta = createEntityMeta( objectType, objectId );
handlers.editRecord( { meta } );
handlers.saveRecord();
}
}
/**
* Unload an entity, stop syncing, and destroy its in-memory state.
*
* @param {ObjectType} objectType Object type to discard.
* @param {ObjectID} objectId Object ID to discard.
*/
function unloadEntity( objectType: ObjectType, objectId: ObjectID ): void {
entityStates.get( getEntityId( objectType, objectId ) )?.unload();
}
/**
* Get the entity ID for the given object type and object ID.
*
* @param {ObjectType} objectType Object type.
* @param {ObjectID} objectId Object ID.
*/
function getEntityId(
objectType: ObjectType,
objectId: ObjectID
): EntityID {
return `${ objectType }_${ objectId }`;
}
/**
* Apply a persisted CRDT document to the current document, if it exists.
* Return true if the document exists and is valid, otherwise false. Returning
* a boolean allows us to destroy the temporary document and prevent it from
* leaking out.
*
* @param {SyncConfig} syncConfig Sync configuration for the object type.
* @param {CRDTDoc} targetDoc Target CRDT doc.
* @param {ObjectData} record Entity record representing this object type.
* @return {boolean} Whether the persisted document is non-existent or invalid.
*/
function applyPersistedCrdtDoc(
syncConfig: SyncConfig,
targetDoc: CRDTDoc,
record: ObjectData
): boolean {
if ( ! syncConfig.supports?.crdtPersistence ) {
return true;
}
// Get the persisted CRDT document, if it exists.
const tempDoc = getPersistedCrdtDoc( record );
if ( ! tempDoc ) {
return true;
}
// Apply the persisted document to the current document as a singular update.
// This is done even if the persisted document has been invalidated. This
// prevents a newly joining peer (or refreshing user) from re-initializing
// the CRDT document (the "initialization problem").
const update = Y.encodeStateAsUpdateV2( tempDoc );
targetDoc.transact( () => {
Y.applyUpdateV2( targetDoc, update );
}, LOCAL_SYNC_MANAGER_ORIGIN );
// Check if the persisted doc has been invalidated by out-of-band updates
// (e.g., a WP CLI command or direct database update) by determining if the
// persisted document introduces any changes that are not present in the
// current record. If it has been invalidated, then we return true as a
// signal that we need to apply the entity record to the target document.
const changes = syncConfig.getChangesFromCRDTDoc( tempDoc, record );
// Destroy the temporary document to prevent leaks.
tempDoc.destroy();
return Object.keys( changes ).length > 0;
}
/**
* Update CRDT document with changes from the local store.
*
* @param {ObjectType} objectType Object type.
* @param {ObjectID} objectId Object ID.
* @param {Partial< ObjectData >} changes Updates to make.
* @param {string} origin The source of change.
*/
function updateCRDTDoc(
objectType: ObjectType,
objectId: ObjectID,
changes: Partial< ObjectData >,
origin: string
): void {
const entityId = getEntityId( objectType, objectId );
const entityState = entityStates.get( entityId );
if ( ! entityState ) {
return;
}
const { syncConfig, ydoc } = entityState;
ydoc.transact( () => {
syncConfig.applyChangesToCRDTDoc( ydoc, changes );
}, origin );
}
/**
* Update the entity record in the local store with changes from the CRDT
* document.
*
* @param {ObjectType} objectType Object type of record to update.
* @param {ObjectID} objectId Object ID of record to update.
*/
async function updateEntityRecord(
objectType: ObjectType,
objectId: ObjectID
): Promise< void > {
const entityId = getEntityId( objectType, objectId );
const entityState = entityStates.get( entityId );
if ( ! entityState ) {
return;
}
const { handlers, syncConfig, ydoc } = entityState;
// Determine which synced properties have actually changed by comparing
// them against the current edited entity record.
const changes = syncConfig.getChangesFromCRDTDoc(
ydoc,
await handlers.getEditedRecord()
);
if ( 0 === Object.keys( changes ).length ) {
return;
}
// This is a good spot to debug to see which changes are being synced. Note
// that `blocks` will always appear in the changes, but will only result
// in an update to the store if the blocks have changed.
handlers.editRecord( changes );
}
/**
* Create object meta to persist the CRDT document in the entity record.
*
* @param {ObjectType} objectType Object type.
* @param {ObjectID} objectId Object ID.
*/
function createEntityMeta(
objectType: ObjectType,
objectId: ObjectID
): Record< string, string > {
const entityId = getEntityId( objectType, objectId );
const entityState = entityStates.get( entityId );
if ( ! entityState?.syncConfig.supports?.crdtPersistence ) {
return {};
}
return createPersistedCRDTDoc( entityState.ydoc );
}
return {
createMeta: createEntityMeta,
load: loadEntity,
undoManager,
unload: unloadEntity,
update: updateCRDTDoc,
};
}