@bangle.dev/collab-client
Advanced tools
Comparing version 0.30.0-alpha.7 to 0.30.0-alpha.8
@@ -12,2 +12,3 @@ /** | ||
CollabRequestType, | ||
CollabServerState, | ||
ManagerRequest, | ||
@@ -22,3 +23,4 @@ MAX_STEP_HISTORY, | ||
import { plugins } from '../src/collab-extension'; | ||
import { onUpstreamChanges } from '../src/commands'; | ||
import { hardResetClient, onUpstreamChanges } from '../src/commands'; | ||
import { collabMonitorKey } from '../src/common'; | ||
@@ -34,4 +36,10 @@ waitForExpect.defaults.timeout = 500; | ||
) => { | ||
const { manager, docChangeEmitter } = server; | ||
let ref: { manager?: CollabManager; docChangeEmitter?: Emitter } = { | ||
manager: undefined, | ||
docChangeEmitter: undefined, | ||
}; | ||
ref.manager = server.manager; | ||
ref.docChangeEmitter = server.docChangeEmitter; | ||
const { editor, debugString } = renderTestEditor({ | ||
@@ -45,3 +53,5 @@ specRegistry: specRegistry, | ||
retryWaitTime: 0, | ||
sendManagerRequest: manager.handleRequest.bind(manager), | ||
sendManagerRequest: (...args) => { | ||
return ref.manager!.handleRequest(...args); | ||
}, | ||
}), | ||
@@ -61,6 +71,8 @@ ], | ||
docChangeEmitter.on('doc_changed', ({ version }: { version: number }) => { | ||
const cb = ({ version }: { version: number }) => { | ||
onUpstreamChanges(version)(editor.view.state, editor.view.dispatch); | ||
}); | ||
}; | ||
ref.docChangeEmitter.on('doc_changed', cb); | ||
return { | ||
@@ -73,2 +85,8 @@ docName: _docName, | ||
typeText, | ||
changeServer(server: ReturnType<typeof setupServer>) { | ||
ref.docChangeEmitter?.off('doc_changed', cb); | ||
ref.docChangeEmitter = server.docChangeEmitter; | ||
ref.manager = server.manager; | ||
ref.docChangeEmitter.on('doc_changed', cb); | ||
}, | ||
}; | ||
@@ -78,2 +96,3 @@ }; | ||
const setupServer = ({ | ||
managerId = 'test-manager-id', | ||
rawDoc = { | ||
@@ -94,2 +113,3 @@ type: 'doc', | ||
}: { | ||
managerId?: string; | ||
rawDoc?: { | ||
@@ -100,2 +120,6 @@ type: 'doc'; | ||
} = {}) => { | ||
let ref: { rawDoc: any } = { | ||
rawDoc, | ||
}; | ||
let emitDocChangeEvent = true; | ||
@@ -106,11 +130,13 @@ | ||
const manager = new CollabManager({ | ||
managerId, | ||
schema: specRegistry.schema, | ||
getDoc: async (dName) => { | ||
getInitialState: async (dName) => { | ||
if (dName === docName) { | ||
return specRegistry.schema.nodeFromJSON(rawDoc); | ||
return new CollabServerState( | ||
specRegistry.schema.nodeFromJSON(ref.rawDoc), | ||
); | ||
} | ||
return undefined; | ||
}, | ||
applyCollabState: (docName, newCollabState) => { | ||
applyState: (docName, newCollabState) => { | ||
queueMicrotask(() => { | ||
@@ -221,2 +247,5 @@ if (emitDocChangeEvent) { | ||
}, | ||
changeRawDoc(rawDoc: any) { | ||
ref.rawDoc = rawDoc; | ||
}, | ||
}; | ||
@@ -466,2 +495,61 @@ }; | ||
test('hard reset', async () => { | ||
let server = setupServer({ managerId: 'manager-test-1' }); | ||
const client1 = setupClient(server, 'client1'); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
}); | ||
client1.typeText('one'); | ||
client1.typeText('two'); | ||
client1.typeText('three'); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.version).toEqual(3); | ||
}); | ||
server = setupServer({ | ||
managerId: 'manager-test-1', | ||
rawDoc: { | ||
type: 'doc', | ||
content: [ | ||
{ | ||
type: 'paragraph', | ||
content: [ | ||
{ | ||
type: 'text', | ||
text: 'reset bro!', | ||
}, | ||
], | ||
}, | ||
], | ||
}, | ||
}); | ||
client1.changeServer(server); | ||
hardResetClient()(client1.view.state, client1.view.dispatch); | ||
// should be reset | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("reset bro!"))`); | ||
}); | ||
expect(collabMonitorKey.getState(client1.view.state)?.serverVersion).toBe( | ||
undefined, | ||
); | ||
// should continue to sync | ||
client1.typeText('hi again'); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
`doc(paragraph("reset bro!hi again"))`, | ||
); | ||
}); | ||
expect(client1.debugString()).toEqual(`doc(paragraph("reset bro!hi again"))`); | ||
expect(collabMonitorKey.getState(client1.view.state)?.serverVersion).toBe(1); | ||
}); | ||
describe('failures', () => { | ||
@@ -804,89 +892,323 @@ test('handles ApplyFailed', async () => { | ||
}); | ||
}); | ||
test('local apply steps fails', async () => { | ||
console.error = jest.fn(); | ||
const server = setupServer(); | ||
test('handles ManagerDestroyed', async () => { | ||
let server = setupServer(); | ||
const client1 = setupClient(server, 'client1'); | ||
const client1 = setupClient(server, 'client1'); | ||
const client2 = setupClient(server, 'client2'); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
}); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
client1.typeText('wow '); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
'doc(paragraph("wow hello world!"))', | ||
); | ||
}); | ||
const { manager } = server; | ||
manager.destroy(); | ||
client1.typeText('bow '); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
'doc(paragraph("wow hello world!"))', | ||
); | ||
}); | ||
expect(await server.getNotOkayRequests()).toEqual([ | ||
{ | ||
userId: 'user-client1', | ||
result: { | ||
body: CollabFail.ManagerDestroyed, | ||
ok: false, | ||
}, | ||
}, | ||
]); | ||
expect( | ||
(await server.getCalls()).filter((r) => | ||
r.payload.userId.includes('client1'), | ||
), | ||
).toEqual([ | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.GetDocument', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PushEvents', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PullEvents', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PushEvents', | ||
}, | ||
]); | ||
server = setupServer(); | ||
client1.changeServer(server); | ||
client1.typeText('pow '); | ||
await sleep(10); | ||
// should get invalid version since server was changed | ||
expect(await server.getNotOkayRequests()).toEqual([ | ||
{ | ||
result: { | ||
body: 'CollabFail.InvalidVersion', | ||
ok: false, | ||
}, | ||
userId: 'user-client1', | ||
}, | ||
]); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
'doc(paragraph("hello world!"))', | ||
); | ||
}); | ||
expect( | ||
(await server.getCalls()).filter((r) => | ||
r.payload.userId.includes('client1'), | ||
), | ||
).toEqual([ | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PullEvents', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.GetDocument', | ||
}, | ||
]); | ||
}); | ||
test('server restarts with different managerId and version', async () => { | ||
console.error = jest.fn(); | ||
let server = setupServer(); | ||
let done = false; | ||
server.alterResponse((req, resp) => { | ||
if ( | ||
req.type === CollabRequestType.PullEvents && | ||
!done && | ||
resp.ok && | ||
req.payload.userId.includes('client1') | ||
) { | ||
done = true; | ||
let body = resp.body as PullEventResponse; | ||
return { | ||
...resp, | ||
body: { | ||
...body, | ||
steps: [ | ||
{ | ||
stepType: 'replace', | ||
from: 1, | ||
to: 10000, | ||
slice: { | ||
content: [ | ||
{ | ||
type: 'text', | ||
text: 'very ', | ||
}, | ||
], | ||
}, | ||
}, | ||
], | ||
const client1 = setupClient(server, 'client1'); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
}); | ||
client1.typeText('wow '); | ||
await sleep(10); | ||
expect(await server.getNotOkayRequests()).toEqual([]); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
'doc(paragraph("wow hello world!"))', | ||
); | ||
}); | ||
server = setupServer({ managerId: 'test-manager-new-id' }); | ||
client1.changeServer(server); | ||
await sleep(10); | ||
client1.typeText('bye '); | ||
await sleep(10); | ||
expect(await server.getNotOkayRequests()).toEqual([ | ||
{ | ||
userId: 'user-client1', | ||
result: { | ||
body: CollabFail.IncorrectManager, | ||
ok: false, | ||
}, | ||
}; | ||
} | ||
return resp; | ||
}, | ||
]); | ||
expect( | ||
(await server.getCalls()).filter((r) => | ||
r.payload.userId.includes('client1'), | ||
), | ||
).toEqual([ | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PushEvents', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.GetDocument', | ||
}, | ||
]); | ||
// TODO: Currently if it restarts, it ends up resetting everything | ||
// this is not desirable. | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
}); | ||
client1.typeText('new type '); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual( | ||
`doc(paragraph("hello wonew type rld!"))`, | ||
); | ||
}); | ||
}); | ||
client2.typeText('wow '); | ||
test('local apply steps fails', async () => { | ||
console.error = jest.fn(); | ||
const server = setupServer(); | ||
await sleep(10); | ||
const client1 = setupClient(server, 'client1'); | ||
const client2 = setupClient(server, 'client2'); | ||
expect(await server.getNotOkayRequests()).toEqual([]); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
}); | ||
await sleep(10); | ||
let done = false; | ||
server.alterResponse((req, resp) => { | ||
if ( | ||
req.type === CollabRequestType.PullEvents && | ||
!done && | ||
resp.ok && | ||
req.payload.userId.includes('client1') | ||
) { | ||
done = true; | ||
let body = resp.body as PullEventResponse; | ||
return { | ||
...resp, | ||
body: { | ||
...body, | ||
steps: [ | ||
{ | ||
stepType: 'replace', | ||
from: 1, | ||
to: 10000, | ||
slice: { | ||
content: [ | ||
{ | ||
type: 'text', | ||
text: 'very ', | ||
}, | ||
], | ||
}, | ||
}, | ||
], | ||
}, | ||
}; | ||
} | ||
return resp; | ||
}); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
'doc(paragraph("wow hello world!"))', | ||
client2.typeText('wow '); | ||
await sleep(10); | ||
expect(await server.getNotOkayRequests()).toEqual([]); | ||
await sleep(10); | ||
await waitForExpect(async () => { | ||
expect(server.manager.getCollabState(docName)?.doc.toString()).toEqual( | ||
'doc(paragraph("wow hello world!"))', | ||
); | ||
}); | ||
expect(console.error).toBeCalledTimes(1); | ||
expect(console.error).nthCalledWith( | ||
1, | ||
new RangeError('Position 10000 out of range'), | ||
); | ||
expect( | ||
(await server.getCalls()).filter((r) => | ||
r.payload.userId.includes('client1'), | ||
), | ||
).toEqual([ | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.GetDocument', | ||
}, | ||
// two pulls because of the apply error | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PullEvents', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PullEvents', | ||
}, | ||
]); | ||
// after recovery client should be syncing correctly | ||
client1.typeText('new type '); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual( | ||
'doc(paragraph("wow new type hello world!"))', | ||
); | ||
}); | ||
}); | ||
expect(console.error).toBeCalledTimes(1); | ||
expect(console.error).nthCalledWith( | ||
1, | ||
new RangeError('Position 10000 out of range'), | ||
); | ||
test('resets collab-monitor on restart', async () => { | ||
const server = setupServer(); | ||
expect( | ||
(await server.getCalls()).filter((r) => | ||
r.payload.userId.includes('client1'), | ||
), | ||
).toEqual([ | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.GetDocument', | ||
}, | ||
// two pulls because of the apply error | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PullEvents', | ||
}, | ||
{ | ||
payload: expect.anything(), | ||
type: 'CollabRequestType.PullEvents', | ||
}, | ||
]); | ||
const client1 = setupClient(server, 'client1'); | ||
await waitForExpect(async () => { | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
}); | ||
// send an invalid version to trigger client to restart | ||
server.alterRequest((req) => { | ||
if (req.type === CollabRequestType.PushEvents) { | ||
return { | ||
...req, | ||
payload: { | ||
...req.payload, | ||
version: 100, | ||
}, | ||
}; | ||
} | ||
return req; | ||
}); | ||
client1.typeText('wow '); | ||
expect(client1.debugString()).toEqual(`doc(paragraph("wow hello world!"))`); | ||
onUpstreamChanges(783)(client1.view.state, client1.view.dispatch); | ||
expect(collabMonitorKey.getState(client1.view.state)?.serverVersion).toBe( | ||
783, | ||
); | ||
await sleep(10); | ||
expect(await server.getNotOkayRequests()).toEqual([ | ||
{ | ||
userId: 'user-client1', | ||
result: { | ||
body: CollabFail.InvalidVersion, | ||
ok: false, | ||
}, | ||
}, | ||
]); | ||
// Editor should reset | ||
expect(client1.debugString()).toEqual(`doc(paragraph("hello world!"))`); | ||
// server version should be reset upon restart | ||
expect(collabMonitorKey.getState(client1.view.state)?.serverVersion).toBe( | ||
undefined, | ||
); | ||
}); | ||
}); |
import { CollabFail, CollabManager } from '@bangle.dev/collab-server'; | ||
import { EditorState, EditorView, Node, TextSelection, Command } from '@bangle.dev/pm'; | ||
import { Command, EditorState, EditorView, Node, TextSelection } from '@bangle.dev/pm'; | ||
import * as prosemirror_state from 'prosemirror-state'; | ||
@@ -15,3 +15,3 @@ | ||
debugInfo?: string; | ||
protected dispatchCollabPluginEvent(data: { | ||
dispatchCollabPluginEvent(data: { | ||
collabEvent?: ValidEvents; | ||
@@ -24,3 +24,3 @@ debugInfo?: string; | ||
abstract runAction(param: ActionParam): Promise<void>; | ||
abstract transition(event: ValidEvents, debugInfo?: string): ValidCollabStates; | ||
abstract transition(event: ValidEvents, debugInfo?: string): ValidCollabStates | undefined; | ||
} | ||
@@ -49,3 +49,3 @@ declare class FatalErrorState extends CollabBaseState { | ||
runAction({ clientInfo, signal, view }: ActionParam): Promise<void>; | ||
transition(event: InitDocEvent | InitErrorEvent, debugInfo?: string): InitDocState | InitErrorState; | ||
transition(event: InitDocEvent | InitErrorEvent, debugInfo?: string): InitDocState | InitErrorState | undefined; | ||
} | ||
@@ -70,3 +70,3 @@ declare class InitDocState extends CollabBaseState { | ||
runAction({ signal, view }: ActionParam): Promise<void>; | ||
transition(event: ReadyEvent | FatalErrorEvent, debugInfo?: string): FatalErrorState | ReadyState; | ||
transition(event: ReadyEvent | FatalErrorEvent, debugInfo?: string): FatalErrorState | ReadyState | undefined; | ||
} | ||
@@ -84,3 +84,3 @@ declare class InitErrorState extends CollabBaseState { | ||
runAction(param: ActionParam): Promise<void>; | ||
transition(event: RestartEvent | FatalErrorEvent, debugInfo?: string): FatalErrorState | InitState; | ||
transition(event: RestartEvent | FatalErrorEvent, debugInfo?: string): FatalErrorState | InitState | undefined; | ||
} | ||
@@ -94,3 +94,3 @@ declare class ReadyState extends CollabBaseState { | ||
runAction({ signal, view }: ActionParam): Promise<void>; | ||
transition(event: Parameters<ReadyState['dispatch']>[2], debugInfo?: string): PullState | PushState; | ||
transition(event: Parameters<ReadyState['dispatch']>[2], debugInfo?: string): PullState | PushState | undefined; | ||
} | ||
@@ -104,3 +104,3 @@ declare class PushState extends CollabBaseState { | ||
runAction({ clientInfo, signal, view }: ActionParam): Promise<void>; | ||
transition(event: ReadyEvent | PullEvent | PushPullErrorEvent, debugInfo?: string): PullState | PushPullErrorState | ReadyState; | ||
transition(event: ReadyEvent | PullEvent | PushPullErrorEvent, debugInfo?: string): PullState | PushPullErrorState | ReadyState | undefined; | ||
} | ||
@@ -114,3 +114,3 @@ declare class PullState extends CollabBaseState { | ||
runAction({ logger, clientInfo, signal, view }: ActionParam): Promise<void>; | ||
transition(event: ReadyEvent | PushPullErrorEvent, debugInfo?: string): PushPullErrorState | ReadyState; | ||
transition(event: ReadyEvent | PushPullErrorEvent, debugInfo?: string): ReadyState | PushPullErrorState | undefined; | ||
} | ||
@@ -130,3 +130,3 @@ declare class PushPullErrorState extends CollabBaseState { | ||
runAction(param: ActionParam): Promise<void>; | ||
transition(event: RestartEvent | PullEvent | FatalErrorEvent, debugInfo?: string): FatalErrorState | InitState | PullState; | ||
transition(event: RestartEvent | PullEvent | FatalErrorEvent, debugInfo?: string): FatalErrorState | InitState | PullState | undefined; | ||
} | ||
@@ -137,5 +137,6 @@ | ||
} | ||
declare type ValidEvents = FatalErrorEvent | InitDocEvent | InitErrorEvent | PullEvent | PushEvent | PushPullErrorEvent | ReadyEvent | RestartEvent; | ||
declare type ValidEvents = FatalErrorEvent | HardResetEvent | InitDocEvent | InitErrorEvent | PullEvent | PushEvent | PushPullErrorEvent | ReadyEvent | RestartEvent; | ||
declare enum EventType { | ||
FatalError = "FATAL_ERROR_EVENT", | ||
HardReset = "HARD_RESET_EVENT", | ||
InitDoc = "INIT_DOC_EVENT", | ||
@@ -188,2 +189,5 @@ InitError = "INIT_ERROR_EVENT", | ||
} | ||
interface HardResetEvent { | ||
type: EventType.HardReset; | ||
} | ||
declare enum CollabStateName { | ||
@@ -200,3 +204,3 @@ FatalError = "FATAL_ERROR_STATE", | ||
interface CollabPluginState { | ||
collabState: ValidCollabStates; | ||
collabState: CollabBaseState; | ||
} | ||
@@ -214,3 +218,4 @@ interface ClientInfo { | ||
} | undefined; | ||
declare function onUpstreamChanges(version: number): Command; | ||
declare function hardResetClient(): Command; | ||
declare function onUpstreamChanges(serverVersion: number | undefined): Command; | ||
@@ -221,2 +226,3 @@ declare const plugins: typeof pluginsFactory; | ||
queryFatalError: typeof queryFatalError; | ||
hardResetClient: typeof hardResetClient; | ||
}; | ||
@@ -223,0 +229,0 @@ declare const RECOVERY_BACK_OFF = 50; |
@@ -11,2 +11,3 @@ import { getVersion, receiveTransaction, sendableSteps, collab } from 'prosemirror-collab'; | ||
EventType["FatalError"] = "FATAL_ERROR_EVENT"; | ||
EventType["HardReset"] = "HARD_RESET_EVENT"; | ||
EventType["InitDoc"] = "INIT_DOC_EVENT"; | ||
@@ -117,2 +118,3 @@ EventType["InitError"] = "INIT_ERROR_EVENT"; | ||
// If in a fatal error (error which will not be recovered), it returns a fatal error message. | ||
function queryFatalError() { | ||
@@ -124,5 +126,19 @@ return (state) => { | ||
} | ||
// Saves the server version of the document. Rest of the code | ||
// Discards any editor changes that have not yet been sent to the server. | ||
// and sets the editor doc to the one provider by server. | ||
function hardResetClient() { | ||
return (state, dispatch) => { | ||
const collabState = getCollabState(state); | ||
collabState === null || collabState === void 0 ? void 0 : collabState.dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.HardReset, | ||
}, | ||
debugInfo: 'hard-reset', | ||
})(state, dispatch); | ||
return true; | ||
}; | ||
} | ||
// Saves the server version of the document. Rest of the extension | ||
// then uses this information to determine whether to pull from server or not. | ||
function onUpstreamChanges(version) { | ||
function onUpstreamChanges(serverVersion) { | ||
return (state, dispatch) => { | ||
@@ -133,4 +149,4 @@ const pluginState = collabMonitorKey.getState(state); | ||
} | ||
if (pluginState.serverVersion !== version) { | ||
const meta = { serverVersion: version }; | ||
if (pluginState.serverVersion !== serverVersion) { | ||
const meta = { serverVersion: serverVersion }; | ||
dispatch === null || dispatch === void 0 ? void 0 : dispatch(state.tr.setMeta(collabMonitorKey, meta)); | ||
@@ -164,2 +180,6 @@ return true; | ||
const collabState = getCollabState(state); | ||
// only dispatch if in ready state, so as to trigger a state transition | ||
// from ready-state -> whatever. | ||
// If state is not in ready state, whenever it eventually transitions to | ||
// ready state it's own action will dispatch these events automatically. | ||
if (collabState === null || collabState === void 0 ? void 0 : collabState.isReadyState()) { | ||
@@ -285,3 +305,4 @@ collabState.dispatch(state, dispatch, { | ||
else { | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -335,3 +356,4 @@ } | ||
else { | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -365,3 +387,4 @@ } | ||
else { | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -411,3 +434,4 @@ } | ||
else { | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -488,3 +512,4 @@ } | ||
else { | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -559,3 +584,3 @@ } | ||
else { | ||
throw new Error('Invalid event ' + type); | ||
return; | ||
} | ||
@@ -592,3 +617,4 @@ } | ||
else { | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -603,3 +629,3 @@ } | ||
logger('Handling failure=', failure, 'currentState=', collabState.name); | ||
const debugSource = `pushPullErrorStateAction:${failure}:`; | ||
const debugSource = `pushPullErrorStateAction(${failure}):`; | ||
switch (failure) { | ||
@@ -654,2 +680,20 @@ case CollabFail.InvalidVersion: | ||
// 500 | ||
case CollabFail.ManagerDestroyed: { | ||
abortableSetTimeout(() => { | ||
if (!signal.aborted) { | ||
if (collabState instanceof PushPullErrorState) { | ||
collabState.dispatch(view.state, view.dispatch, { | ||
type: EventType.Pull, | ||
}, debugSource); | ||
} | ||
else if (collabState instanceof InitErrorState) { | ||
collabState.dispatch(view.state, view.dispatch, { | ||
type: EventType.Restart, | ||
}, debugSource); | ||
} | ||
else ; | ||
} | ||
}, signal, clientInfo.retryWaitTime); | ||
return; | ||
} | ||
case CollabFail.ApplyFailed: { | ||
@@ -685,2 +729,5 @@ if (collabState instanceof PushPullErrorState) { | ||
: () => { }; | ||
const collabMonitorInitialState = { | ||
serverVersion: undefined, | ||
}; | ||
function collabClientPlugin(clientInfo) { | ||
@@ -702,5 +749,5 @@ const logger = (state) => (...args) => { | ||
} | ||
// prevent any other tr until state is in one of the no-edit state | ||
// prevent any other tr while state is in one of the no-edit state | ||
if (tr.docChanged && ((_a = getCollabState(state)) === null || _a === void 0 ? void 0 : _a.editingAllowed) === false) { | ||
logger(state)('skipping transaction'); | ||
console.debug('@bangle.dev/collab-client blocking transaction'); | ||
return false; | ||
@@ -718,19 +765,24 @@ } | ||
const meta = tr.getMeta(collabClientKey); | ||
if (meta === undefined) { | ||
if (meta === undefined || !meta.collabEvent) { | ||
return value; | ||
} | ||
let result = { | ||
...value, | ||
}; | ||
if (meta.collabEvent) { | ||
const newPluginState = value.collabState.transition(meta.collabEvent, meta.debugInfo); | ||
if (newPluginState.name !== value.collabState.name) { | ||
result.collabState = newPluginState; | ||
} | ||
else { | ||
logger(newState)(`applyState IGNORE EVENT ${meta.collabEvent.type}`, `debugInfo=${result.collabState.debugInfo}`); | ||
} | ||
if (meta.collabEvent.type === EventType.HardReset) { | ||
logger(newState)('apply state HARD RESET, newStateName=', CollabStateName.Init, 'oldStateName=', value.collabState.name); | ||
return { | ||
collabState: new InitState(undefined, '(HardReset)'), | ||
}; | ||
} | ||
logger(newState)('apply state, newStateName=', result.collabState.name, `debugInfo=${result.collabState.debugInfo}`, 'oldStateName=', value.collabState.name); | ||
return result; | ||
const newCollabState = value.collabState.transition(meta.collabEvent, meta.debugInfo); | ||
if (!newCollabState) { | ||
return value; | ||
} | ||
if (newCollabState.name !== value.collabState.name) { | ||
logger(newState)('apply state, newStateName=', newCollabState.name, `debugInfo=${newCollabState.debugInfo}`, 'oldStateName=', value.collabState.name); | ||
return { | ||
...value, | ||
collabState: newCollabState, | ||
}; | ||
} | ||
logger(newState)(`applyState IGNORE EVENT ${meta.collabEvent.type} due to self transition`, `debugInfo=${meta.debugInfo}`); | ||
return value; | ||
}, | ||
@@ -779,7 +831,6 @@ }, | ||
init: (_, _state) => { | ||
return { | ||
serverVersion: undefined, | ||
}; | ||
return collabMonitorInitialState; | ||
}, | ||
apply: (tr, value, oldState, newState) => { | ||
var _a; | ||
const meta = tr.getMeta(collabMonitorKey); | ||
@@ -793,2 +844,6 @@ if (meta) { | ||
} | ||
// Reset collab monitors state if collab state is restarted | ||
if (((_a = getCollabState(newState)) === null || _a === void 0 ? void 0 : _a.name) === CollabStateName.Init) { | ||
return collabMonitorInitialState; | ||
} | ||
return value; | ||
@@ -799,2 +854,9 @@ }, | ||
const check = (view) => { | ||
// There are two ways different ways this extension keeps a check on outdated version (needs to pull) | ||
// and sendable steps (needs to push): | ||
// 1. the ready state action which gets triggered when collabClientKey plugin transitions | ||
// to the ready state. | ||
// 2. this plugin (collabMonitorKey) which runs `check` every time the view is updated and | ||
// checks if we need a push or pull. | ||
// outdated version gets a priority over local changes | ||
if (isOutdatedVersion()(view.state)) { | ||
@@ -820,3 +882,3 @@ onOutdatedVersion()(view.state, view.dispatch); | ||
const plugins = pluginsFactory; | ||
const commands = { onUpstreamChanges, queryFatalError }; | ||
const commands = { onUpstreamChanges, queryFatalError, hardResetClient }; | ||
const RECOVERY_BACK_OFF = 50; | ||
@@ -823,0 +885,0 @@ function pluginsFactory({ clientID = 'client-' + uuid(), docName, sendManagerRequest, retryWaitTime = 100, }) { |
{ | ||
"name": "@bangle.dev/collab-client", | ||
"version": "0.30.0-alpha.7", | ||
"version": "0.30.0-alpha.8", | ||
"homepage": "https://bangle.dev", | ||
@@ -43,8 +43,8 @@ "authors": [ | ||
"devDependencies": { | ||
"@bangle.dev/all-base-components": "0.30.0-alpha.7", | ||
"@bangle.dev/collab-server": "0.30.0-alpha.7", | ||
"@bangle.dev/core": "0.30.0-alpha.7", | ||
"@bangle.dev/disk": "0.30.0-alpha.7", | ||
"@bangle.dev/pm": "0.30.0-alpha.7", | ||
"@bangle.dev/test-helpers": "0.30.0-alpha.7", | ||
"@bangle.dev/all-base-components": "0.30.0-alpha.8", | ||
"@bangle.dev/collab-server": "0.30.0-alpha.8", | ||
"@bangle.dev/core": "0.30.0-alpha.8", | ||
"@bangle.dev/disk": "0.30.0-alpha.8", | ||
"@bangle.dev/pm": "0.30.0-alpha.8", | ||
"@bangle.dev/test-helpers": "0.30.0-alpha.8", | ||
"@types/node": "^17.0.43", | ||
@@ -56,3 +56,3 @@ "localforage": "^1.9.0", | ||
"dependencies": { | ||
"@bangle.dev/utils": "0.30.0-alpha.7", | ||
"@bangle.dev/utils": "0.30.0-alpha.8", | ||
"@types/jest": "^27.5.2", | ||
@@ -59,0 +59,0 @@ "prosemirror-collab": "^1.3.0", |
@@ -18,2 +18,4 @@ import { getVersion, sendableSteps } from 'prosemirror-collab'; | ||
CollabPluginState, | ||
CollabStateName, | ||
EventType, | ||
} from './common'; | ||
@@ -28,2 +30,6 @@ import { getCollabState } from './helpers'; | ||
const collabMonitorInitialState = { | ||
serverVersion: undefined, | ||
}; | ||
export function collabClientPlugin(clientInfo: ClientInfo) { | ||
@@ -53,5 +59,5 @@ const logger = | ||
// prevent any other tr until state is in one of the no-edit state | ||
// prevent any other tr while state is in one of the no-edit state | ||
if (tr.docChanged && getCollabState(state)?.editingAllowed === false) { | ||
logger(state)('skipping transaction'); | ||
console.debug('@bangle.dev/collab-client blocking transaction'); | ||
return false; | ||
@@ -73,35 +79,48 @@ } | ||
if (meta === undefined) { | ||
if (meta === undefined || !meta.collabEvent) { | ||
return value; | ||
} | ||
let result: CollabPluginState = { | ||
...value, | ||
}; | ||
if (meta.collabEvent.type === EventType.HardReset) { | ||
logger(newState)( | ||
'apply state HARD RESET, newStateName=', | ||
CollabStateName.Init, | ||
'oldStateName=', | ||
value.collabState.name, | ||
); | ||
return { | ||
collabState: new InitState(undefined, '(HardReset)'), | ||
}; | ||
} | ||
if (meta.collabEvent) { | ||
const newPluginState = value.collabState.transition( | ||
meta.collabEvent, | ||
meta.debugInfo, | ||
const newCollabState = value.collabState.transition( | ||
meta.collabEvent, | ||
meta.debugInfo, | ||
); | ||
if (!newCollabState) { | ||
return value; | ||
} | ||
if (newCollabState.name !== value.collabState.name) { | ||
logger(newState)( | ||
'apply state, newStateName=', | ||
newCollabState.name, | ||
`debugInfo=${newCollabState.debugInfo}`, | ||
'oldStateName=', | ||
value.collabState.name, | ||
); | ||
if (newPluginState.name !== value.collabState.name) { | ||
result.collabState = newPluginState; | ||
} else { | ||
logger(newState)( | ||
`applyState IGNORE EVENT ${meta.collabEvent.type}`, | ||
`debugInfo=${result.collabState.debugInfo}`, | ||
); | ||
} | ||
return { | ||
...value, | ||
collabState: newCollabState, | ||
}; | ||
} | ||
logger(newState)( | ||
'apply state, newStateName=', | ||
result.collabState.name, | ||
`debugInfo=${result.collabState.debugInfo}`, | ||
'oldStateName=', | ||
value.collabState.name, | ||
`applyState IGNORE EVENT ${meta.collabEvent.type} due to self transition`, | ||
`debugInfo=${meta.debugInfo}`, | ||
); | ||
return result; | ||
return value; | ||
}, | ||
@@ -155,5 +174,3 @@ }, | ||
init: (_, _state): CollabMonitor => { | ||
return { | ||
serverVersion: undefined, | ||
}; | ||
return collabMonitorInitialState; | ||
}, | ||
@@ -170,2 +187,8 @@ apply: (tr, value, oldState, newState): CollabMonitor => { | ||
} | ||
// Reset collab monitors state if collab state is restarted | ||
if (getCollabState(newState)?.name === CollabStateName.Init) { | ||
return collabMonitorInitialState; | ||
} | ||
return value; | ||
@@ -176,2 +199,10 @@ }, | ||
const check = (view: EditorView) => { | ||
// There are two ways different ways this extension keeps a check on outdated version (needs to pull) | ||
// and sendable steps (needs to push): | ||
// 1. the ready state action which gets triggered when collabClientKey plugin transitions | ||
// to the ready state. | ||
// 2. this plugin (collabMonitorKey) which runs `check` every time the view is updated and | ||
// checks if we need a push or pull. | ||
// outdated version gets a priority over local changes | ||
if (isOutdatedVersion()(view.state)) { | ||
@@ -178,0 +209,0 @@ onOutdatedVersion()(view.state, view.dispatch); |
@@ -7,3 +7,7 @@ import { collab } from 'prosemirror-collab'; | ||
import { collabClientPlugin } from './collab-client'; | ||
import { onUpstreamChanges, queryFatalError } from './commands'; | ||
import { | ||
hardResetClient, | ||
onUpstreamChanges, | ||
queryFatalError, | ||
} from './commands'; | ||
@@ -19,3 +23,3 @@ const LOG = false; | ||
export const plugins = pluginsFactory; | ||
export const commands = { onUpstreamChanges, queryFatalError }; | ||
export const commands = { onUpstreamChanges, queryFatalError, hardResetClient }; | ||
@@ -22,0 +26,0 @@ export const RECOVERY_BACK_OFF = 50; |
@@ -8,2 +8,3 @@ import { getVersion } from 'prosemirror-collab'; | ||
// If in a fatal error (error which will not be recovered), it returns a fatal error message. | ||
export function queryFatalError() { | ||
@@ -15,5 +16,21 @@ return (state: EditorState) => { | ||
} | ||
// Saves the server version of the document. Rest of the code | ||
// Discards any editor changes that have not yet been sent to the server. | ||
// and sets the editor doc to the one provider by server. | ||
export function hardResetClient(): Command { | ||
return (state, dispatch) => { | ||
const collabState = getCollabState(state); | ||
collabState?.dispatchCollabPluginEvent({ | ||
collabEvent: { | ||
type: EventType.HardReset, | ||
}, | ||
debugInfo: 'hard-reset', | ||
})(state, dispatch); | ||
return true; | ||
}; | ||
} | ||
// Saves the server version of the document. Rest of the extension | ||
// then uses this information to determine whether to pull from server or not. | ||
export function onUpstreamChanges(version: number): Command { | ||
export function onUpstreamChanges(serverVersion: number | undefined): Command { | ||
return (state, dispatch) => { | ||
@@ -25,4 +42,4 @@ const pluginState = collabMonitorKey.getState(state); | ||
if (pluginState.serverVersion !== version) { | ||
const meta: CollabMonitorTrMeta = { serverVersion: version }; | ||
if (pluginState.serverVersion !== serverVersion) { | ||
const meta: CollabMonitorTrMeta = { serverVersion: serverVersion }; | ||
dispatch?.(state.tr.setMeta(collabMonitorKey, meta)); | ||
@@ -66,2 +83,6 @@ return true; | ||
const collabState = getCollabState(state); | ||
// only dispatch if in ready state, so as to trigger a state transition | ||
// from ready-state -> whatever. | ||
// If state is not in ready state, whenever it eventually transitions to | ||
// ready state it's own action will dispatch these events automatically. | ||
if (collabState?.isReadyState()) { | ||
@@ -68,0 +89,0 @@ collabState.dispatch( |
import { CollabFail, CollabManager } from '@bangle.dev/collab-server'; | ||
import { Node, PluginKey, TextSelection } from '@bangle.dev/pm'; | ||
import type { ValidCollabStates } from './state'; | ||
import type { CollabBaseState } from './state'; | ||
@@ -17,3 +17,3 @@ export const collabClientKey = new PluginKey<CollabPluginState>( | ||
export interface CollabMonitorTrMeta { | ||
serverVersion: number; | ||
serverVersion: number | undefined; | ||
} | ||
@@ -24,2 +24,3 @@ | ||
| FatalErrorEvent | ||
| HardResetEvent | ||
| InitDocEvent | ||
@@ -35,2 +36,3 @@ | InitErrorEvent | ||
FatalError = 'FATAL_ERROR_EVENT', | ||
HardReset = 'HARD_RESET_EVENT', | ||
InitDoc = 'INIT_DOC_EVENT', | ||
@@ -90,2 +92,6 @@ InitError = 'INIT_ERROR_EVENT', | ||
export interface HardResetEvent { | ||
type: EventType.HardReset; | ||
} | ||
export enum CollabStateName { | ||
@@ -103,3 +109,3 @@ FatalError = 'FATAL_ERROR_STATE', | ||
export interface CollabPluginState { | ||
collabState: ValidCollabStates; | ||
collabState: CollabBaseState; | ||
} | ||
@@ -106,0 +112,0 @@ |
@@ -54,3 +54,3 @@ import { getVersion, sendableSteps } from 'prosemirror-collab'; | ||
protected dispatchCollabPluginEvent(data: { | ||
public dispatchCollabPluginEvent(data: { | ||
collabEvent?: ValidEvents; | ||
@@ -80,3 +80,3 @@ debugInfo?: string; | ||
debugInfo?: string, | ||
): ValidCollabStates; | ||
): ValidCollabStates | undefined; | ||
} | ||
@@ -216,4 +216,7 @@ | ||
} else { | ||
// This should not get called by any statically findable .transition() . However | ||
// dynamic code can possibly call it and it should be safe to ignore. | ||
let val: never = type; | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -293,3 +296,4 @@ } | ||
let val: never = type; | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -335,3 +339,4 @@ } | ||
let val: never = type; | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -398,3 +403,4 @@ } | ||
let val: never = type; | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -508,3 +514,4 @@ } | ||
let val: never = type; | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -589,3 +596,6 @@ } | ||
transition(event: ReadyEvent | PushPullErrorEvent, debugInfo?: string) { | ||
transition( | ||
event: ReadyEvent | PushPullErrorEvent, | ||
debugInfo?: string, | ||
): ReadyState | PushPullErrorState | undefined { | ||
const type = event.type; | ||
@@ -604,3 +614,3 @@ if (type === EventType.Ready) { | ||
let val: never = type; | ||
throw new Error('Invalid event ' + type); | ||
return; | ||
} | ||
@@ -652,3 +662,4 @@ } | ||
let val: never = type; | ||
throw new Error('Invalid event'); | ||
console.debug('@bangle.dev/collab-client Ignoring event' + type); | ||
return; | ||
} | ||
@@ -672,3 +683,3 @@ } | ||
const debugSource = `pushPullErrorStateAction:${failure}:`; | ||
const debugSource = `pushPullErrorStateAction(${failure}):`; | ||
@@ -754,2 +765,34 @@ switch (failure) { | ||
// 500 | ||
case CollabFail.ManagerDestroyed: { | ||
abortableSetTimeout( | ||
() => { | ||
if (!signal.aborted) { | ||
if (collabState instanceof PushPullErrorState) { | ||
collabState.dispatch( | ||
view.state, | ||
view.dispatch, | ||
{ | ||
type: EventType.Pull, | ||
}, | ||
debugSource, | ||
); | ||
} else if (collabState instanceof InitErrorState) { | ||
collabState.dispatch( | ||
view.state, | ||
view.dispatch, | ||
{ | ||
type: EventType.Restart, | ||
}, | ||
debugSource, | ||
); | ||
} else { | ||
let val: never = collabState; | ||
} | ||
} | ||
}, | ||
signal, | ||
clientInfo.retryWaitTime, | ||
); | ||
return; | ||
} | ||
case CollabFail.ApplyFailed: { | ||
@@ -756,0 +799,0 @@ if (collabState instanceof PushPullErrorState) { |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
142301
4295
+ Added@bangle.dev/utils@0.30.0-alpha.8(transitive)
- Removed@bangle.dev/utils@0.30.0-alpha.7(transitive)