diff --git a/package-lock.json b/package-lock.json index 7b3de11572b393..10b0157d123182 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53204,6 +53204,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", "import-locals": "^2.0.0", "lib0": "^0.2.42", diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 2eaf044ff04938..9d642d26d49db6 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -19,7 +19,7 @@ import { receiveItems, removeItems, receiveQueriedItems } from './queried-data'; import { DEFAULT_ENTITY_KEY } from './entities'; import { createBatch } from './batch'; import { STORE_NAME } from './name'; -import { getSyncProvider } from './sync'; +import { LOCAL_EDITOR_ORIGIN, syncManager } from './sync'; import logEntityDeprecation from './utils/log-entity-deprecation'; /** @@ -413,7 +413,12 @@ export const editEntityRecord = const objectType = `${ kind }/${ name }`; const objectId = recordId; - getSyncProvider().update( objectType, objectId, edit.edits ); + syncManager.update( + objectType, + objectId, + edit.edits, + LOCAL_EDITOR_ORIGIN + ); } } if ( ! options.undoIgnore ) { diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 06d438248228a0..abedb5b8543f2f 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -15,6 +15,7 @@ import apiFetch from '@wordpress/api-fetch'; */ import { STORE_NAME } from './name'; import { additionalEntityConfigLoaders, DEFAULT_ENTITY_KEY } from './entities'; +import { syncManager } from './sync'; import { forwardResolver, getNormalizedCommaSeparable, @@ -23,7 +24,6 @@ import { ALLOWED_RESOURCE_ACTIONS, RECEIVE_INTERMEDIATE_RESULTS, } from './utils'; -import { getSyncProvider } from './sync'; import { fetchBlockPatterns } from './fetch'; /** @@ -165,7 +165,7 @@ export const getEntityRecord = const objectId = key; // Use the new transient "read/write" config to compute transients for - // the sync provider. Otherwise these transients are not available + // the sync manager. Otherwise these transients are not available // if / until the record is edited. Use a copy of the record so that // it does not change the behavior outside this experimental flag. const recordWithTransients = { ...record }; @@ -184,27 +184,30 @@ export const getEntityRecord = transientConfig.read( recordWithTransients ); } ); - getSyncProvider().register( - objectType, - entityConfig.syncConfig - ); - - // Bootstraps the edited document (and load from peers). - await getSyncProvider().bootstrap( + // Load the entity record for syncing. + await syncManager.load( + entityConfig.syncConfig, objectType, objectId, recordWithTransients, - ( edits ) => { - dispatch( { - type: 'EDIT_ENTITY_RECORD', - kind, - name, - recordId: key, - edits, - meta: { - undo: undefined, - }, - } ); + { + // Handle edits sourced from the sync manager. + editRecord: ( edits ) => { + if ( ! Object.keys( edits ).length ) { + return; + } + + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId: key, + edits, + meta: { + undo: undefined, + }, + } ); + }, } ); } diff --git a/packages/core-data/src/sync.js b/packages/core-data/src/sync.js deleted file mode 100644 index fdc421a6bd70e9..00000000000000 --- a/packages/core-data/src/sync.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createSyncProvider, - connectIndexDb, - createWebRTCConnection, -} from '@wordpress/sync'; - -let syncProvider; - -export function getSyncProvider() { - if ( ! syncProvider ) { - syncProvider = createSyncProvider( - connectIndexDb, - createWebRTCConnection( { - signaling: [ - //'ws://localhost:4444', - window?.wp?.ajax?.settings?.url, - ], - password: window?.__experimentalCollaborativeEditingSecret, - } ) - ); - } - - return syncProvider; -} diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts new file mode 100644 index 00000000000000..bf996b423b6a36 --- /dev/null +++ b/packages/core-data/src/sync.ts @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { + CRDT_RECORD_MAP_KEY, + LOCAL_EDITOR_ORIGIN, + createSyncManager, +} from '@wordpress/sync'; + +export { CRDT_RECORD_MAP_KEY, LOCAL_EDITOR_ORIGIN }; +export const syncManager = createSyncManager(); diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 58c4fad2967341..8128677a8ec1c6 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -3,11 +3,16 @@ */ import triggerFetch from '@wordpress/api-fetch'; -jest.mock( '@wordpress/api-fetch' ); +/** + * Internal dependencies + */ +import { syncManager } from '../sync'; -// Mock the sync provider +jest.mock( '@wordpress/api-fetch' ); jest.mock( '../sync', () => ( { - getSyncProvider: jest.fn(), + syncManager: { + load: jest.fn(), + }, } ) ); /** @@ -21,7 +26,6 @@ import { getAutosaves, getCurrentUser, } from '../resolvers'; -import { getSyncProvider } from '../sync'; describe( 'getEntityRecord', () => { const POST_TYPE = { slug: 'post' }; @@ -47,7 +51,7 @@ describe( 'getEntityRecord', () => { finishResolutions: jest.fn(), } ); triggerFetch.mockReset(); - getSyncProvider.mockClear(); + syncManager.load.mockClear(); } ); afterEach( () => { @@ -123,7 +127,7 @@ describe( 'getEntityRecord', () => { ); } ); - it( 'bootstraps entity with sync provider when __experimentalEnableSync is true', async () => { + it( 'loads entity with sync manager when __experimentalEnableSync is true', async () => { const POST_RECORD = { id: 1, title: 'Test Post' }; const POST_RESPONSE = { json: () => Promise.resolve( POST_RECORD ), @@ -140,12 +144,6 @@ describe( 'getEntityRecord', () => { window.__experimentalEnableSync = true; - const mockBootstrap = jest.fn(); - getSyncProvider.mockReturnValue( { - bootstrap: mockBootstrap, - register: jest.fn(), - } ); - const resolveSelectWithSync = { getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), getEditedEntityRecord: jest.fn(), @@ -163,14 +161,14 @@ describe( 'getEntityRecord', () => { resolveSelect: resolveSelectWithSync, } ); - // Verify bootstrap was called with correct arguments. - expect( getSyncProvider ).toHaveBeenCalled(); - expect( mockBootstrap ).toHaveBeenCalledTimes( 1 ); - expect( mockBootstrap ).toHaveBeenCalledWith( + // Verify load was called with correct arguments. + expect( syncManager.load ).toHaveBeenCalledTimes( 1 ); + expect( syncManager.load ).toHaveBeenCalledWith( + {}, 'postType/post', 1, POST_RECORD, - expect.any( Function ) + { editRecord: expect.any( Function ) } ); } ); @@ -196,12 +194,6 @@ describe( 'getEntityRecord', () => { window.__experimentalEnableSync = true; - const mockBootstrap = jest.fn(); - getSyncProvider.mockReturnValue( { - bootstrap: mockBootstrap, - register: jest.fn(), - } ); - const resolveSelectWithSync = { getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), getEditedEntityRecord: jest.fn(), @@ -219,18 +211,18 @@ describe( 'getEntityRecord', () => { resolveSelect: resolveSelectWithSync, } ); - // Verify bootstrap was called with correct arguments. - expect( getSyncProvider ).toHaveBeenCalled(); - expect( mockBootstrap ).toHaveBeenCalledTimes( 1 ); - expect( mockBootstrap ).toHaveBeenCalledWith( + // Verify load was called with correct arguments. + expect( syncManager.load ).toHaveBeenCalledTimes( 1 ); + expect( syncManager.load ).toHaveBeenCalledWith( + {}, 'postType/post', 1, { ...POST_RECORD, foo: 'bar' }, - expect.any( Function ) + { editRecord: expect.any( Function ) } ); } ); - it( 'does not bootstrap entity when query is present', async () => { + it( 'does not load entity when query is present', async () => { const POST_RECORD = { id: 1, title: 'Test Post' }; const POST_RESPONSE = { json: () => Promise.resolve( POST_RECORD ), @@ -260,10 +252,10 @@ describe( 'getEntityRecord', () => { resolveSelect: resolveSelectWithSync, } ); - expect( getSyncProvider ).not.toHaveBeenCalled(); + expect( syncManager.load ).not.toHaveBeenCalled(); } ); - it( 'does not bootstrap entity when __experimentalEnableSync is undefined', async () => { + it( 'does not load entity when __experimentalEnableSync is undefined', async () => { const POST_RECORD = { id: 1, title: 'Test Post' }; const POST_RESPONSE = { json: () => Promise.resolve( POST_RECORD ), @@ -294,7 +286,7 @@ describe( 'getEntityRecord', () => { resolveSelect: resolveSelectWithSync, } ); - expect( getSyncProvider ).not.toHaveBeenCalled(); + expect( syncManager.load ).not.toHaveBeenCalled(); } ); } ); diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 796d92c952ce71..09a08761cd56cf 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -1,11 +1,12 @@ /** * WordPress dependencies */ -import { - type CRDTDoc, - CRDT_RECORD_MAP_KEY, - type ObjectData, -} from '@wordpress/sync'; +import { type CRDTDoc, type ObjectData } from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import { CRDT_RECORD_MAP_KEY } from '../sync'; export function defaultApplyChangesToCRDTDoc( crdtDoc: CRDTDoc, diff --git a/packages/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts index 1ac08a71550ff1..b716c2ef5c577e 100644 --- a/packages/private-apis/src/implementation.ts +++ b/packages/private-apis/src/implementation.ts @@ -29,6 +29,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/preferences', '@wordpress/reusable-blocks', '@wordpress/router', + '@wordpress/sync', '@wordpress/dataviews', '@wordpress/fields', '@wordpress/media-utils', diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 40a4b76d2cfd42..f9b65199056482 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -1,54 +1,46 @@ # Status of the sync experiment in Gutenberg -The sync package is part of an ongoing research effort to lay the groundwork of Real-Time Collaboration in Gutenberg. +The sync package provides an implementation of real-time collaboration in Gutenberg. -Relevant docs: +Relevant docs and discussions: -- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/ -- https://github.com/WordPress/gutenberg/issues/52593 -- https://docs.yjs.dev/ +- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/ +- https://github.com/WordPress/gutenberg/issues/52593 +- https://github.com/WordPress/gutenberg/discussions/65012 +- https://docs.yjs.dev/ ## Enable the experiment -The experiment can be enabled in the "Guteberg > Experiments" page. When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data: +The real-time collaboration experiment must be enabled on the "Gutenberg > Experiments" page. A WebRTC provider with HTTP signaling is used to connect peers. -- `window.__experimentalEnableSync`: boolean. Used by the `core-data` package to determine whether to bootstrap and use the sync provider offered by the `sync` package. -- `window.__experimentalCollaborativeEditingSecret`: string. A secret used by the `sync` package to create a secure connection among peers. +When it is enabled, the following global variables are defined:: + +- `window.__experimentalEnableSync` (`boolean`): Used by the `core-data` package to determine whether entity syncing is available. +- `window.__experimentalCollaborativeEditingSecret` (`string`). A secret (stored in a WordPress option) used by the WebRTC provider to create a secure connection between peers. ## The data flow -The current experiment updates `core-data` to leverage the YJS library for synchronization and merging changes. Each core-data entity record represents a YJS document and updates to the `--edit` record are broadcasted among peers. +Each entity with sync enabled is represented by a CRDT (Yjs) document. Local edits (unsaved changes) to an entity record are applied to its CRDT document, which is synced with other peers via a provider. Those peers use the CRDT document to update their local state. These are the specific checkpoints: -1. REGISTER. - - See `getSyncProvider().register( ... )` in `registerSyncConfigs`. - - Not all entity types are sync-enabled at the moment, look at those that declare a `syncConfig` and `syncObjectType` in `rootEntitiesConfig`. -2. BOOTSTRAP. - - See `getSyncProvider().bootstrap( ... )` in `getEntityRecord`. - - The `bootstrap` function fetches the entity and sets up the callback that will dispatch the relevant Redux action when document changes are broadcasted from other peers. -3. UPDATE. - - See `getSyncProvider().update( ... )` in `editEntityRecord`. - - Each change done by a peer to the `--edit` entity record (local changes, not persisted ones) is broadcasted to the others. - - The data that is shared is the whole block list. - -This is the data flow when the peer A makes a local change: - -- Peer A makes a local change. -- Peer A triggers a `getSyncProvider().update( ... )` request (see `editEntityRecord`). -- All peers (including A) receive the broadcasted change and execute the callback (see `updateHandler` in `createSyncProvider.bootstrap`). -- All peers (including A) trigger a `EDIT_ENTITY_RECORD` redux action. - -## What works and what doesn't - -- Undo/redo does not work. -- Changes can be persisted and the publish/update button should react accordingly for all peers. -- Offline. - - Changes are stored in the browser's local storage (indexedDB) for each user/peer. Users can navigate away from the document and they'll see the changes when they come back. - - Offline changes can be deleted via visiting the browser's database in all peers, then reload the document. -- Documents can get out of sync. For example: - - Two peers open the same document. - - One of them (A) leaves the document. Then, the remaining user (B) makes changes. - - When A comes back to the document, the changes B made are not visible to A. -- Entities - - Not all entities are synced. For example, global styles are not. Look at the `base` entity config for an example (it declares `syncConfig` and `syncObjectType` properties). +1. **CONFIG**: The entity's config defines a `syncConfig` property to enable syncing for that entity type and define its behavior. + - See `packages/core-data/src/entities.js`. + - Not all entities are sync-enabled; look for those that define a `syncConfig` property. + - Not all properties are synced; look for the `syncedProperties` set that is passed as an argument to various functions. +2. **LOAD**: When an entity record is loaded for the first time and it supports syncing, it is loaded into the `syncManager` to provide handlers for various lifecycle events. + - See `getEntityRecord` in `packages/core-data/src/resolvers.js`. + - See `syncManager.load()` in this package. +3. **LOCAL CHANGES**: When local changes are made to an entity record, it is applied to the entity's CRDT document, which is synced with peers. + - See `editEntityRecord` in `packages/core-data/src/actions.js`. + - See `syncManager.update()` in this package. +4. **REMOTE CHANGES**: When an entity's CRDT document is updated by a remote peer, changes are extracted and the entity record is updated in the local store. + - See `updateEntityRecord` in this package. + +While the Redux actions in `core-data` and the `syncManager` orchestrate this data flow, the behavior of what gets synced is controlled by the entity's `syncConfig`: + +- `applyChangesToCRDTDoc` determines how (or if) local changes are applied to the CRDT document. +- `getChangesFromCRDTDoc` determines how (or if) changes from the CRDT document are extracted and applied to the entity record. +- `supports` is a hash that declares support for various sync features, present and future. + +An entity's `syncConfig` "owns" the sync behavior of the entity (especially via `applyChangesToCRDTDoc` and `getChangesFromCRDTDoc`) and it should not delegate or leak that responsibility to other parts of the codebase. diff --git a/packages/sync/README.md b/packages/sync/README.md index 1d59ad19824ff2..b6d480e8f95b6a 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -14,50 +14,21 @@ npm install @wordpress/sync --save -### connectIndexDb - -Connect function to the IndexedDB persistence provider. - -_Parameters_ - -- _objectId_ `ObjectID`: The object ID. -- _objectType_ `ObjectType`: The object type. -- _doc_ `CRDTDoc`: The CRDT document. - -_Returns_ - -- `Promise<() => void>`: Promise that resolves when the connection is established. - ### CRDT_RECORD_MAP_KEY Root-level key for the CRDT document that holds the entity record data. -### createSyncProvider - -Create a sync provider. - -_Parameters_ - -- _connectLocal_ `ConnectDoc`: Connect the document to a local database. -- _connectRemote_ `ConnectDoc`: Connect the document to a remote sync connection. - -_Returns_ - -- `SyncProvider`: Sync provider. - -### createWebRTCConnection +### createSyncManager -Function that creates a new WebRTC Connection. +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. -_Parameters_ +### LOCAL_EDITOR_ORIGIN -- _config_ `Object`: The object ID. -- _config.signaling_ `Array`: -- _config.password_ `string`: +Origin string for CRDT document changes originating from the local editor. -_Returns_ +### Y -- `Function`: Promise that resolves when the connection is established. +Exported copy of Yjs so that consumers of this package don't need to install it. diff --git a/packages/sync/package.json b/packages/sync/package.json index 8ff2386757aac5..fd2c2ac0e9ed5b 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -38,6 +38,7 @@ "sideEffects": false, "dependencies": { "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", "import-locals": "^2.0.0", "lib0": "^0.2.42", diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index 5aea2743e2ed75..b3fa0cf1fe1d02 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -1,4 +1,30 @@ +/** + * This version number should be incremented whenever there are breaking changes + * to Yjs doc schema or in how it is interpreted by code in the SyncConfig. This + * allows implementors to invalidate persisted CRDT docs. + */ +export const CRDT_DOC_VERSION = 1; + /** * Root-level key for the CRDT document that holds the entity record data. */ export const CRDT_RECORD_MAP_KEY = 'document'; + +/** + * Root-level key for the CRDT document that holds the state descriptors (see + * below). + */ +export const CRDT_STATE_MAP_KEY = 'state'; + +// Y.Map keys for the state map. +export const CRDT_STATE_VERSION_KEY = 'version'; + +/** + * Origin string for CRDT document changes originating from the local editor. + */ +export const LOCAL_EDITOR_ORIGIN = 'gutenberg'; + +/** + * Origin string for CRDT document changes originating from the sync manager. + */ +export const LOCAL_SYNC_MANAGER_ORIGIN = 'syncManager'; diff --git a/packages/sync/src/connect-indexdb.js b/packages/sync/src/connect-indexdb.js index a4fe2e86bcd528..fe8f28778e449e 100644 --- a/packages/sync/src/connect-indexdb.js +++ b/packages/sync/src/connect-indexdb.js @@ -7,24 +7,23 @@ import { IndexeddbPersistence } from 'y-indexeddb'; /** @typedef {import('./types').ObjectType} ObjectType */ /** @typedef {import('./types').ObjectID} ObjectID */ /** @typedef {import('./types').CRDTDoc} CRDTDoc */ -/** @typedef {import('./types').ConnectDoc} ConnectDoc */ +/** @typedef {import('./types').ProviderCreator} ProviderCreator */ +/** @typedef {import('./types').ProviderCreatorResult} ProviderCreatorResult */ /** * Connect function to the IndexedDB persistence provider. * - * @param {ObjectID} objectId The object ID. * @param {ObjectType} objectType The object type. + * @param {ObjectID} objectId The object ID. * @param {CRDTDoc} doc The CRDT document. * - * @return {Promise<() => void>} Promise that resolves when the connection is established. + * @return {Promise< ProviderCreatorResult >} Promise that resolves when the connection is established. */ -export function connectIndexDb( objectId, objectType, doc ) { +export function connectIndexDb( objectType, objectId, doc ) { const roomName = `${ objectType }-${ objectId }`; const provider = new IndexeddbPersistence( roomName, doc ); - return new Promise( ( resolve ) => { - provider.on( 'synced', () => { - resolve( () => provider.destroy() ); - } ); + return Promise.resolve( { + destroy: () => provider.destroy(), } ); } diff --git a/packages/sync/src/create-webrtc-connection.js b/packages/sync/src/create-webrtc-connection.js index 97fcddc727d024..bc93b3bfa92200 100644 --- a/packages/sync/src/create-webrtc-connection.js +++ b/packages/sync/src/create-webrtc-connection.js @@ -11,29 +11,30 @@ import { WebrtcProviderWithHttpSignaling } from './webrtc-http-stream-signaling' /** @typedef {import('./types').ObjectType} ObjectType */ /** @typedef {import('./types').ObjectID} ObjectID */ /** @typedef {import('./types').CRDTDoc} CRDTDoc */ +/** @typedef {import('./types').ProviderCreator} ProviderCreator */ /** * Function that creates a new WebRTC Connection. * - * @param {Object} config The object ID. - * - * @param {Array} config.signaling - * @param {string} config.password - * @return {Function} Promise that resolves when the connection is established. + * @param {Object} config + * @param {Array} config.signaling + * @param {string|undefined} config.password + * @return {ProviderCreator} Promise that resolves when the connection is established. */ export function createWebRTCConnection( { signaling, password } ) { return function ( - /** @type {string} */ objectId, - /** @type {string} */ objectType, - /** @type {import("yjs").Doc} */ doc + /** @type {ObjectType} */ objectType, + /** @type {ObjectID} */ objectId, + /** @type {CRDTDoc} */ doc ) { const roomName = `${ objectType }-${ objectId }`; - new WebrtcProviderWithHttpSignaling( roomName, doc, { + const provider = new WebrtcProviderWithHttpSignaling( roomName, doc, { signaling, - // @ts-ignore password, } ); - return Promise.resolve( () => true ); + return Promise.resolve( { + destroy: () => provider.destroy(), + } ); }; } diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 00ec5b694bb020..cbc138307e2d96 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -1,5 +1,16 @@ -export { CRDT_RECORD_MAP_KEY } from './config'; -export { connectIndexDb } from './connect-indexdb'; -export { createWebRTCConnection } from './create-webrtc-connection'; -export { createSyncProvider } from './provider'; +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ + +/** + * Exported copy of Yjs so that consumers of this package don't need to install it. + */ +export * as Y from 'yjs'; + +export { CRDT_RECORD_MAP_KEY, LOCAL_EDITOR_ORIGIN } from './config'; +export { createSyncManager } from './manager'; export type * from './types'; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts new file mode 100644 index 00000000000000..0bf81965af418a --- /dev/null +++ b/packages/sync/src/manager.ts @@ -0,0 +1,208 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import { + CRDT_RECORD_MAP_KEY as RECORD_KEY, + LOCAL_SYNC_MANAGER_ORIGIN, +} from './config'; +import { getProviderCreators } from './providers'; +import type { + CRDTDoc, + EntityID, + ObjectID, + ObjectData, + ObjectType, + ProviderCreator, + RecordHandlers, + SyncConfig, + SyncManager, +} from './types'; +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(); + + /** + * 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; + } + + updateEntityRecord( objectType, objectId ); + }; + + 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 ); + + ydoc.transact( () => { + syncConfig.applyChangesToCRDTDoc( ydoc, record ); + }, LOCAL_SYNC_MANAGER_ORIGIN ); + } + + /** + * 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 }`; + } + + /** + * 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 ); + const syncConfig = entityState?.syncConfig; + const ydoc = entityState?.ydoc; + + 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. + */ + function updateEntityRecord( + objectType: ObjectType, + objectId: ObjectID + ): 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 entity record. + const changes = syncConfig.getChangesFromCRDTDoc( ydoc ); + + // 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 ); + } + + return { + load: loadEntity, + unload: unloadEntity, + update: updateCRDTDoc, + }; +} diff --git a/packages/sync/src/provider.js b/packages/sync/src/provider.js deleted file mode 100644 index e390026251dd9c..00000000000000 --- a/packages/sync/src/provider.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * External dependencies - */ -// @ts-ignore -import * as Y from 'yjs'; - -/** @typedef {import('./types').ObjectType} ObjectType */ -/** @typedef {import('./types').ObjectID} ObjectID */ -/** @typedef {import('./types').ObjectData} ObjectData */ -/** @typedef {import('./types').CRDTDoc} CRDTDoc */ -/** @typedef {import('./types').ConnectDoc} ConnectDoc */ -/** @typedef {import('./types').SyncConfig} SyncConfig */ -/** @typedef {import('./types').SyncProvider} SyncProvider */ - -/** - * Create a sync provider. - * - * @param {ConnectDoc} connectLocal Connect the document to a local database. - * @param {ConnectDoc} connectRemote Connect the document to a remote sync connection. - * @return {SyncProvider} Sync provider. - */ -export const createSyncProvider = ( connectLocal, connectRemote ) => { - /** - * @type {Record} - */ - const config = {}; - - /** - * @type {Recordvoid>>} - */ - const listeners = {}; - - /** - * @type {Record>} - */ - const docs = {}; - - /** - * Registers an object type. - * - * @param {ObjectType} objectType Object type to register. - * @param {SyncConfig} objectConfig Object config. - */ - function register( objectType, objectConfig ) { - config[ objectType ] = objectConfig; - } - - /** - * Fetch data from local database or remote source. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID. - * @param {ObjectData} record Entity record. - * @param {Function} handleChanges Callback to call when data changes. - */ - async function bootstrap( objectType, objectId, record, handleChanges ) { - const doc = new Y.Doc(); - docs[ objectType ] = docs[ objectType ] || {}; - docs[ objectType ][ objectId ] = doc; - - const updateHandler = () => { - const data = config[ objectType ].getChangesFromCRDTDoc( doc ); - handleChanges( data ); - }; - doc.on( 'update', updateHandler ); - - // connect to locally saved database. - const destroyLocalConnection = await connectLocal( - objectId, - objectType, - doc - ); - - // Once the database syncing is done, start the remote syncing - if ( connectRemote ) { - await connectRemote( objectId, objectType, doc ); - } - - doc.transact( () => { - config[ objectType ].applyChangesToCRDTDoc( doc, record ); - } ); - - listeners[ objectType ] = listeners[ objectType ] || {}; - listeners[ objectType ][ objectId ] = () => { - destroyLocalConnection(); - doc.off( 'update', updateHandler ); - }; - } - - /** - * Fetch data from local database or remote source. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - * @param {any} data Updates to make. - */ - async function update( objectType, objectId, data ) { - const doc = docs[ objectType ][ objectId ]; - if ( ! doc ) { - throw 'Error doc ' + objectType + ' ' + objectId + ' not found'; - } - doc.transact( () => { - config[ objectType ].applyChangesToCRDTDoc( doc, data ); - } ); - } - - /** - * Stop updating a document and discard it. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - */ - async function discard( objectType, objectId ) { - if ( listeners?.[ objectType ]?.[ objectId ] ) { - listeners[ objectType ][ objectId ](); - } - } - - return { - register, - bootstrap, - update, - discard, - }; -}; diff --git a/packages/sync/src/providers.ts b/packages/sync/src/providers.ts new file mode 100644 index 00000000000000..c17a036a22918d --- /dev/null +++ b/packages/sync/src/providers.ts @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { connectIndexDb } from './connect-indexdb'; +import { createWebRTCConnection } from './create-webrtc-connection'; +import type { ProviderCreator } from './types'; + +let providerCreators: ProviderCreator[] | null = null; + +/** + * Returns provider creators for IndexedDB and WebRTC with HTTP signaling. These + * are the current default providers. + * + * @return {ProviderCreator[]} Creator functions for Yjs providers. + */ +function getDefaultProviderCreators(): ProviderCreator[] { + const signalingUrl = window?.wp?.ajax?.settings?.url; + + if ( ! signalingUrl ) { + return []; + } + + return [ + connectIndexDb, + createWebRTCConnection( { + password: window?.__experimentalCollaborativeEditingSecret, + signaling: [ signalingUrl ], + } ), + ]; +} + +/** + * Type guard to ensure filter return values are functions. + * + * @param {unknown} creator + * @return {boolean} Whether the argument is a function + */ +function isProviderCreator( creator: unknown ): creator is ProviderCreator { + return 'function' === typeof creator; +} + +/** + * Get the current Yjs provider creators, allowing plugins to filter the array. + * + * @return {ProviderCreator[]} Creator functions for Yjs providers. + */ +export function getProviderCreators(): ProviderCreator[] { + if ( providerCreators ) { + return providerCreators; + } + + /** + * Filter the + */ + const filteredProviderCreators: unknown = applyFilters( + 'sync.providers', + getDefaultProviderCreators() + ); + + // If the returned value is not an array, ignore and set to empty array. + if ( ! Array.isArray( filteredProviderCreators ) ) { + providerCreators = []; + return providerCreators; + } + + providerCreators = filteredProviderCreators.filter( isProviderCreator ); + + return providerCreators; +} diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 7bd89d8b2512ff..12d3a7cb5f7400 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -3,6 +3,20 @@ */ import type * as Y from 'yjs'; +/* globalThis */ +declare global { + interface Window { + __experimentalCollaborativeEditingSecret?: string; + wp?: { + ajax?: { + settings?: { + url?: string; + }; + }; + }; + } +} + export type CRDTDoc = Y.Doc; export type EntityID = string; export type ObjectID = string; @@ -12,11 +26,19 @@ export type ObjectType = string; // are not many expectations that can hold on its shape. export interface ObjectData extends Record< string, unknown > {} -export type ConnectDoc = ( - id: ObjectID, - type: ObjectType, - doc: CRDTDoc -) => Promise< () => void >; +export interface ProviderCreatorResult { + destroy: () => void; +} + +export type ProviderCreator = ( + objectType: ObjectType, + objectId: ObjectID, + ydoc: Y.Doc +) => Promise< ProviderCreatorResult >; + +export interface RecordHandlers { + editRecord: ( data: Partial< ObjectData > ) => void; +} export interface SyncConfig { applyChangesToCRDTDoc: ( @@ -24,17 +46,22 @@ export interface SyncConfig { changes: Partial< ObjectData > ) => void; getChangesFromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; - supports: Record< string, true >; + supports?: Record< string, true >; } -export type SyncProvider = { - register: ( type: ObjectType, config: SyncConfig ) => void; - bootstrap: ( - type: ObjectType, +export interface SyncManager { + load: ( + syncConfig: SyncConfig, + objectType: ObjectType, objectId: ObjectID, record: ObjectData, - handleChanges: ( data: any ) => void + handlers: RecordHandlers ) => Promise< void >; - update: ( type: ObjectType, id: ObjectID, data: any ) => void; - discard: ( type: ObjectType, id: ObjectID ) => Promise< void >; -}; + unload: ( objectType: ObjectType, objectId: ObjectID ) => void; + update: ( + objectType: ObjectType, + objectId: ObjectID, + changes: Partial< ObjectData >, + origin: string + ) => void; +} diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts new file mode 100644 index 00000000000000..a0ae5d3c5a26ad --- /dev/null +++ b/packages/sync/src/utils.ts @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import { + CRDT_DOC_VERSION, + CRDT_STATE_MAP_KEY, + CRDT_STATE_VERSION_KEY, +} from './config'; + +export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc { + // Meta is not synced and does not get persisted with the document. + const metaMap = new Map< string, unknown >( + Object.entries( documentMeta ) + ); + + const ydoc = new Y.Doc( { meta: metaMap } ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + stateMap.set( CRDT_STATE_VERSION_KEY, CRDT_DOC_VERSION ); + + return ydoc; +} diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index f0a5cb0530d297..53e6a2b663d310 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -4,5 +4,5 @@ "compilerOptions": { "types": [ "node" ] }, - "references": [ { "path": "../url" } ] + "references": [ { "path": "../hooks" }, { "path": "../url" } ] }