diff --git a/apps/io-playground/README.md b/apps/io-playground/README.md new file mode 100644 index 0000000000..c0650b7924 --- /dev/null +++ b/apps/io-playground/README.md @@ -0,0 +1,11 @@ +# I/O protocol playground + +An interactive view of `@portabletext/io`: Editor A, the link to the server, the server, the link to Editor B, and Editor B, side by side. Save requests, rejections and feed transactions sit as cards in the links until you deliver them, so a race is played by choosing the order. + +Each editor shows its message path: every `mutation`, `mutation sent`, `transaction`, `feed lost`, `load` and `resync` between its host and io, every `apply` io sends the editor, and the host's re-submits. Every value opens to its Portable Text blocks, every mutation, transaction and message to its patches. Each label has a one-sentence explanation behind its "i", and the Concepts button lists them all. After every step a narration says what happened in plain words. + +The Scenarios tab runs the Gherkin scenarios one step at a time. The Free play tab drives the same world by hand and writes down the steps as Gherkin, ready to copy out as a new scenario. It starts the editors in their first commit, and offers a plain, a Studio-shaped and a Horizon-shaped host, a feed that dies without telling anyone, a stored copy below the floor reached by a resync or by a transaction, and a listener that sends the document with each transaction. A `work dropped` shows as a notice with the dropped text. + +```sh +pnpm --filter io-playground dev +``` diff --git a/apps/io-playground/index.html b/apps/io-playground/index.html new file mode 100644 index 0000000000..05985d30ef --- /dev/null +++ b/apps/io-playground/index.html @@ -0,0 +1,12 @@ + + + + + + I/O protocol playground + + +
+ + + diff --git a/apps/io-playground/package.json b/apps/io-playground/package.json new file mode 100644 index 0000000000..2ef058db73 --- /dev/null +++ b/apps/io-playground/package.json @@ -0,0 +1,30 @@ +{ + "name": "io-playground", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "check:types": "tsc --noEmit --pretty --project tsconfig.app.json", + "check:types:watch": "tsc --watch --project tsconfig.app.json", + "clean": "del .turbo && del dist && del node_modules", + "dev": "vite", + "preview": "vite preview", + "test:unit": "vitest --run" + }, + "dependencies": { + "@portabletext/io": "workspace:^", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:tooling", + "@types/react": "catalog:tooling", + "@types/react-dom": "catalog:tooling", + "@vitejs/plugin-react": "catalog:tooling", + "tailwindcss": "catalog:tooling", + "typescript": "catalog:tooling", + "vite": "catalog:tooling", + "vitest": "catalog:tooling" + } +} diff --git a/apps/io-playground/src/App.tsx b/apps/io-playground/src/App.tsx new file mode 100644 index 0000000000..f0075c66ae --- /dev/null +++ b/apps/io-playground/src/App.tsx @@ -0,0 +1,234 @@ +import type {EditorName, World} from '@portabletext/io/testing' +import {useState} from 'react' +import { + applicableActions, + applicableNetworkActions, + editorPrompts, +} from './applicable' +import {DrawerView, OpenDetailsProvider, type Drawer} from './drawers' +import {EditorPanel} from './editor-panel' +import {FreePlayTab, useFreePlay} from './free-play-tab' +import {LinkPanel} from './link-panel' +import {ScenariosTab, useScenarioRunner} from './scenarios-tab' +import {ServerPanel} from './server-panel' +import {Button} from './ui' + +type Tab = 'scenarios' | 'free play' + +export function App() { + const [tab, setTab] = useState('scenarios') + const [drawerState, setDrawerState] = useState<{ + world: World + drawer: Drawer + } | null>(null) + const scenarioRunner = useScenarioRunner() + const freePlay = useFreePlay() + const world = tab === 'scenarios' ? scenarioRunner.world : freePlay.world + const snapshot = world.snapshot() + const drawer = drawerState?.world === world ? drawerState.drawer : null + const setDrawer = ( + next: Drawer | null | ((current: Drawer | null) => Drawer | null), + ) => { + const resolved = typeof next === 'function' ? next(drawer) : next + setDrawerState(resolved === null ? null : {world, drawer: resolved}) + } + const deadFeeds = tab === 'free play' ? freePlay.deadFeeds : [] + const network = applicableNetworkActions(snapshot, deadFeeds) + const promptsFor = (name: EditorName) => { + const held = network.links[name].held + + return [ + ...editorPrompts(applicableActions(snapshot, name)), + ...(held === undefined ? [] : [held]), + ] + } + const [selection, setSelection] = useState<{ + world: World + mutationIds: Array + } | null>(null) + const selectedMutationIds = + selection?.world === world ? selection.mutationIds : [] + const [dismissedWork, setDismissedWork] = useState<{ + world: World + byEditor: Record> + } | null>(null) + const dismissedFor = (name: EditorName) => + dismissedWork?.world === world ? dismissedWork.byEditor[name] : [] + const dismissWork = (name: EditorName, index: number) => + setDismissedWork({ + world, + byEditor: { + 'Editor A': dismissedFor('Editor A'), + 'Editor B': dismissedFor('Editor B'), + [name]: [...dismissedFor(name), index], + }, + }) + const onStep = + tab === 'free play' + ? (text: string) => freePlay.perform('When', text) + : undefined + const onDeliver = + tab === 'free play' + ? (text: string) => freePlay.perform('When', text) + : scenarioRunner.finished && !scenarioRunner.running + ? scenarioRunner.deliver + : undefined + const saveRequests = snapshot.network?.saveRequests ?? [] + const selectedRequests = saveRequests.filter((request) => + selectedMutationIds.includes(request.mutationId), + ) + + function toggleSelected(mutationId: string) { + setSelection({ + world, + mutationIds: selectedMutationIds.includes(mutationId) + ? selectedMutationIds.filter((candidate) => candidate !== mutationId) + : [ + ...selectedMutationIds.filter((candidate) => + saveRequests.some((request) => request.mutationId === candidate), + ), + mutationId, + ], + }) + } + + function receiveSelected() { + const [first, second] = selectedRequests + + if (!first || !second || !onStep) { + return + } + + setSelection(null) + onStep( + `the server receives ${first.editor}'s mutation ${first.mutationNumber} and ${second.editor}'s mutation ${second.mutationNumber} as one transaction`, + ) + } + + return ( + setDrawer({type: 'details', selection})} + > +
+
+

I/O protocol playground

+

+ Two editors save to one server through a link each. Click any value, + mutation or transaction to look inside it. +

+ +
+ +
+ dismissWork('Editor A', index)} + waitingCount={snapshot.network?.feeds['Editor A'].length ?? 0} + prompts={promptsFor('Editor A')} + /> + + + + dismissWork('Editor B', index)} + waitingCount={snapshot.network?.feeds['Editor B'].length ?? 0} + prompts={promptsFor('Editor B')} + /> +
+ +
+ +
+ {tab === 'scenarios' ? ( + + ) : ( + + )} +
+
+
+ + {drawer ? ( + setDrawer(null)} + /> + ) : null} +
+ ) +} diff --git a/apps/io-playground/src/applicable.test.ts b/apps/io-playground/src/applicable.test.ts new file mode 100644 index 0000000000..110819d236 --- /dev/null +++ b/apps/io-playground/src/applicable.test.ts @@ -0,0 +1,815 @@ +import type { + EditorSnapshot, + NetworkSnapshot, + WorldSnapshot, +} from '@portabletext/io/testing' +import {describe, expect, test} from 'vitest' +import { + applicableActions, + applicableNetworkActions, + editorPrompts, +} from './applicable' + +describe(applicableActions.name, () => { + test('an idle editor allows every edit and suggests nothing', () => { + const actions = applicableActions( + worldSnapshot({editorA: editorSnapshot({undoDepth: 1})}), + 'Editor A', + ) + + expect(actions).toEqual({ + 'type': {enabled: true}, + 'set style': {enabled: true}, + 'put caret after': {enabled: true}, + 'insert block': {enabled: true}, + 'delete block': {enabled: true}, + 'delete before caret': {enabled: true}, + 'undo': {enabled: true}, + 'read-only': {enabled: true}, + 'close': {enabled: true}, + 'resync': {enabled: true}, + 'resync discarding': {enabled: false, why: 'nothing unsent to discard'}, + 'load': { + enabled: false, + why: 'after ready a load throws: resync takes over', + }, + 'end first commit': {enabled: false, why: 'the first commit has ended'}, + }) + expect(editorPrompts(actions)).toEqual([]) + }) + + test('undo is disabled when there is nothing to undo', () => { + const actions = applicableActions( + worldSnapshot({editorA: editorSnapshot({undoDepth: 0})}), + 'Editor A', + ) + + expect(actions.undo).toEqual({ + enabled: false, + why: 'there is nothing to undo', + }) + }) + + test('a mutation in flight resyncs with its outcome, and discarding waits', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({ + inFlight: mutation(1), + io: ioContext({ + sync: 'saving', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + }), + sentMutations: [sentMutation(1)], + undoDepth: 1, + }), + }), + 'Editor A', + ) + + expect(actions).toEqual({ + 'type': {enabled: true}, + 'set style': {enabled: true}, + 'put caret after': {enabled: true}, + 'insert block': {enabled: true}, + 'delete block': {enabled: true}, + 'delete before caret': {enabled: true}, + 'undo': {enabled: true}, + 'read-only': {enabled: true}, + 'close': {enabled: true}, + 'resync': { + enabled: true, + why: 'mutation 1 is in flight: the host re-submits it under the same transaction ID to find out whether it landed, and the resync carries that outcome', + }, + 'resync discarding': { + enabled: false, + why: 'the steps discard only when no mutation is in flight', + }, + 'load': { + enabled: false, + why: 'after ready a load throws: resync takes over', + }, + 'end first commit': {enabled: false, why: 'the first commit has ended'}, + }) + expect(editorPrompts(actions)).toEqual([]) + }) + + test('a rejected mutation suggests a resync', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({ + rejected: mutation(1), + pending: [mutation(2)], + io: ioContext({sync: 'blocked', pending: 1}), + sentMutations: [sentMutation(1)], + undoDepth: 1, + }), + }), + 'Editor A', + ) + + expect(actions).toEqual({ + 'type': {enabled: true}, + 'set style': {enabled: true}, + 'put caret after': {enabled: true}, + 'insert block': {enabled: true}, + 'delete block': {enabled: true}, + 'delete before caret': {enabled: true}, + 'undo': {enabled: true}, + 'read-only': {enabled: true}, + 'close': {enabled: true}, + 'resync': { + enabled: true, + suggested: + "Editor A's mutation 1 was rejected: sending is blocked until a resync", + }, + 'resync discarding': { + enabled: true, + why: "the user's choice: load the saved version and throw away 1 unsent change(s)", + }, + 'load': { + enabled: false, + why: 'after ready a load throws: resync takes over', + }, + 'end first commit': {enabled: false, why: 'the first commit has ended'}, + }) + expect(editorPrompts(actions)).toEqual([ + "Editor A's mutation 1 was rejected: sending is blocked until a resync", + ]) + }) + + test('an editor out of step suggests a resync', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({ + io: ioContext({sync: 'out of step'}), + }), + }), + 'Editor A', + ) + + expect(actions.resync).toEqual({ + enabled: true, + suggested: 'Editor A is out of step: resync to recover', + }) + expect(editorPrompts(actions)).toEqual([ + 'Editor A is out of step: resync to recover', + ]) + }) + + test('an editor out of step with a mutation in flight suggests a resync with its outcome', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({ + inFlight: mutation(1), + io: ioContext({ + sync: 'out of step', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + }), + sentMutations: [sentMutation(1)], + }), + }), + 'Editor A', + ) + + expect(actions.resync).toEqual({ + enabled: true, + why: 'mutation 1 is in flight: the host re-submits it under the same transaction ID to find out whether it landed, and the resync carries that outcome', + suggested: + 'Editor A is out of step: resync with the outcome of mutation 1 to recover', + }) + expect(editorPrompts(actions)).toEqual([ + 'Editor A is out of step: resync with the outcome of mutation 1 to recover', + ]) + }) + + test('read-only blocks typing, not resync or closing', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({readOnly: true, undoDepth: 1}), + }), + 'Editor A', + ) + + expect(actions).toEqual({ + 'type': {enabled: false, why: 'typing is blocked while read-only'}, + 'set style': {enabled: false, why: 'typing is blocked while read-only'}, + 'put caret after': {enabled: true}, + 'insert block': { + enabled: false, + why: 'typing is blocked while read-only', + }, + 'delete block': { + enabled: false, + why: 'typing is blocked while read-only', + }, + 'delete before caret': { + enabled: false, + why: 'typing is blocked while read-only', + }, + 'undo': {enabled: false, why: 'typing is blocked while read-only'}, + 'read-only': { + enabled: false, + why: 'the steps have no way to end read-only', + }, + 'close': {enabled: true}, + 'resync': {enabled: true}, + 'resync discarding': {enabled: false, why: 'nothing unsent to discard'}, + 'load': { + enabled: false, + why: 'after ready a load throws: resync takes over', + }, + 'end first commit': {enabled: false, why: 'the first commit has ended'}, + }) + }) + + test('an editor in its first commit suggests loading or ending the commit', () => { + const snapshot = worldSnapshot({ + editorA: editorSnapshot({ + status: 'loading', + io: ioContext({status: 'loading'}), + }), + editorB: editorSnapshot({ + status: 'loading', + io: ioContext({status: 'loading'}), + }), + }) + const actions = applicableActions(snapshot, 'Editor A') + + expect(actions).toEqual({ + 'type': {enabled: false, why: "the editor isn't ready yet"}, + 'set style': {enabled: false, why: "the editor isn't ready yet"}, + 'put caret after': {enabled: true}, + 'insert block': {enabled: false, why: "the editor isn't ready yet"}, + 'delete block': {enabled: false, why: "the editor isn't ready yet"}, + 'delete before caret': { + enabled: false, + why: "the editor isn't ready yet", + }, + 'undo': {enabled: false, why: "the editor isn't ready yet"}, + 'read-only': {enabled: true}, + 'close': {enabled: true}, + 'resync': {enabled: false, why: 'resync is only accepted once ready'}, + 'resync discarding': { + enabled: false, + why: 'resync is only accepted once ready', + }, + 'load': { + enabled: true, + suggested: + 'Editor A is in its first commit: load the first content, or end the commit to start empty', + }, + 'end first commit': { + enabled: true, + suggested: + 'Editor A is in its first commit: load the first content, or end the commit to start empty', + }, + }) + expect(editorPrompts(actions)).toEqual([ + 'Editor A is in its first commit: load the first content, or end the commit to start empty', + ]) + }) + + test('an unmounted editor refuses everything', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({ + status: 'unmounted', + rejected: mutation(1), + io: ioContext({status: 'unmounted', sync: 'blocked'}), + sentMutations: [sentMutation(1)], + undoDepth: 1, + }), + }), + 'Editor A', + ) + + expect(actions).toEqual({ + 'type': {enabled: false, why: 'the editor is unmounted'}, + 'set style': {enabled: false, why: 'the editor is unmounted'}, + 'put caret after': {enabled: false, why: 'the editor is unmounted'}, + 'insert block': {enabled: false, why: 'the editor is unmounted'}, + 'delete block': {enabled: false, why: 'the editor is unmounted'}, + 'delete before caret': {enabled: false, why: 'the editor is unmounted'}, + 'undo': {enabled: false, why: 'the editor is unmounted'}, + 'read-only': {enabled: false, why: 'the editor is unmounted'}, + 'close': {enabled: false, why: 'the editor is unmounted'}, + 'resync': {enabled: false, why: 'the editor is unmounted'}, + 'resync discarding': {enabled: false, why: 'the editor is unmounted'}, + 'load': {enabled: false, why: 'the editor is unmounted'}, + 'end first commit': {enabled: false, why: 'the editor is unmounted'}, + }) + expect(editorPrompts(actions)).toEqual([]) + }) + + test('a held transaction suggests nothing on the editor', () => { + const actions = applicableActions( + worldSnapshot({ + editorA: editorSnapshot({ + held: [ + { + transactionId: 'B-2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }, + ], + }), + }), + 'Editor A', + ) + + expect(editorPrompts(actions)).toEqual([]) + }) +}) + +describe(applicableNetworkActions.name, () => { + test('nothing waiting suggests nothing', () => { + expect(applicableNetworkActions(worldSnapshot({}))).toEqual({ + links: { + 'Editor A': emptyLink(), + 'Editor B': emptyLink(), + }, + advanceClock: {enabled: true}, + }) + }) + + test('a waiting save request suggests the server receives it', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + inFlight: mutation(1), + io: ioContext({ + sync: 'saving', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + }), + sentMutations: [sentMutation(1)], + }), + network: networkSnapshot({ + saveRequests: [ + { + editor: 'Editor A', + mutationId: 'A-1', + mutationNumber: 1, + final: false, + patchCount: 1, + patches: [], + }, + ], + }), + }), + ) + + expect(network.links['Editor A']).toEqual({ + towardServer: { + prompts: [ + "Editor A's mutation 1 is waiting: the server receives it, or the request fails", + ], + saveRequests: { + 'A-1': { + enabled: true, + suggested: + "Editor A's mutation 1 is waiting: the server receives it, or the request fails", + }, + }, + loseReply: { + 'A-1': { + enabled: true, + why: 'the server saves it, but the host never hears back', + }, + }, + }, + towardEditor: {prompts: [], replies: {}, lostReplies: {}, feed: {}}, + feedLost: feedLostEnabled, + held: undefined, + }) + }) + + test('a waiting failure reply suggests delivering it', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + inFlight: mutation(1), + io: ioContext({ + sync: 'saving', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + }), + sentMutations: [sentMutation(1)], + }), + network: networkSnapshot({ + replies: [ + { + editor: 'Editor A', + mutationId: 'A-1', + mutationNumber: 1, + status: 400, + }, + ], + }), + }), + ) + + expect(network.links['Editor A']).toEqual({ + towardServer: {prompts: [], saveRequests: {}, loseReply: {}}, + towardEditor: { + prompts: ["Editor A's mutation 1 failed with 400: deliver the reply"], + replies: { + 'A-1': { + enabled: true, + suggested: + "Editor A's mutation 1 failed with 400: deliver the reply", + }, + }, + lostReplies: {}, + feed: {}, + }, + feedLost: feedLostEnabled, + held: undefined, + }) + }) + + test('a final save request cannot lose its reply', () => { + const network = applicableNetworkActions( + worldSnapshot({ + network: networkSnapshot({ + saveRequests: [ + { + editor: 'Editor A', + mutationId: 'A-2', + mutationNumber: 2, + final: true, + patchCount: 1, + patches: [], + }, + ], + }), + }), + ) + + expect(network.links['Editor A'].towardServer.loseReply).toEqual({ + 'A-2': {enabled: false, why: 'a final mutation gets no reply'}, + }) + }) + + test('a lost reply for the mutation in flight suggests retrying it, and one for a settled mutation cannot be retried', () => { + const lostReplies = [ + {editor: 'Editor A' as const, mutationId: 'A-1', mutationNumber: 1}, + ] + const inFlight = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + inFlight: mutation(1), + io: ioContext({ + sync: 'saving', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + }), + sentMutations: [sentMutation(1)], + }), + network: networkSnapshot({lostReplies}), + }), + ) + const settled = applicableNetworkActions( + worldSnapshot({network: networkSnapshot({lostReplies})}), + ) + + expect(inFlight.links['Editor A'].towardEditor).toEqual({ + prompts: [ + "the save reply for Editor A's mutation 1 was lost: retry it with the same transaction ID", + ], + replies: {}, + lostReplies: { + 'A-1': { + enabled: true, + suggested: + "the save reply for Editor A's mutation 1 was lost: retry it with the same transaction ID", + }, + }, + feed: {}, + }) + expect(settled.links['Editor A'].towardEditor).toEqual({ + prompts: [], + replies: {}, + lostReplies: { + 'A-1': { + enabled: false, + why: "mutation 1 isn't in flight anymore: the host knows what became of it", + }, + }, + feed: {}, + }) + }) + + test('a lost feed can be reported only while the editor is ready and in step', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + io: ioContext({sync: 'out of step'}), + }), + editorB: editorSnapshot({ + status: 'loading', + io: ioContext({status: 'loading'}), + }), + }), + ) + + expect([ + network.links['Editor A'].feedLost, + network.links['Editor B'].feedLost, + ]).toEqual([ + {enabled: false, why: 'the editor is out of step already: resync'}, + {enabled: false, why: 'the feed starts once the editor is ready'}, + ]) + }) + + test('a dead feed delivers nothing and suggests nothing', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + inFlight: mutation(1), + io: ioContext({ + sync: 'saving', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + }), + sentMutations: [sentMutation(1)], + }), + network: networkSnapshot({ + feeds: {'Editor A': [feedItem('A-1', 'r1', 'r2')], 'Editor B': []}, + }), + }), + ['Editor A'], + ) + + expect(network.links['Editor A'].towardEditor).toEqual({ + prompts: [], + replies: {}, + lostReplies: {}, + feed: { + 'A-1': { + enabled: false, + why: "the feed is dead: the network delivers nothing to Editor A's listener", + }, + }, + }) + }) + + test('a host with no listener has no feed to lose', () => { + const network = applicableNetworkActions( + worldSnapshot({editorA: editorSnapshot({host: 'self-confirming'})}), + ) + + expect(network.links['Editor A'].feedLost).toEqual({ + enabled: false, + why: 'the host has no listener, so there is no feed to lose', + }) + }) + + test('a waiting transaction suggests delivering it', () => { + const network = applicableNetworkActions( + worldSnapshot({ + network: networkSnapshot({ + feeds: {'Editor A': [], 'Editor B': [feedItem('A-1', 'r1', 'r2')]}, + }), + }), + ) + + expect(network.links['Editor B'].towardEditor).toEqual({ + prompts: ['A-1 is waiting for Editor B: deliver it'], + replies: {}, + lostReplies: {}, + feed: { + 'A-1': { + enabled: true, + suggested: 'A-1 is waiting for Editor B: deliver it', + }, + }, + }) + }) + + test('a held transaction names the missing revision and suggests delivering it or advancing the clock', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + held: [ + { + transactionId: 'B-2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }, + ], + }), + network: networkSnapshot({ + feeds: { + 'Editor A': [feedItem('B-1', 'r1', 'r2')], + 'Editor B': [], + }, + }), + }), + ) + + expect(network).toEqual({ + links: { + 'Editor A': { + towardServer: {prompts: [], saveRequests: {}, loseReply: {}}, + towardEditor: { + prompts: [ + "B-2 is held: it doesn't connect to r1, deliver the transaction that ends at r2, or advance 10 s", + ], + replies: {}, + lostReplies: {}, + feed: { + 'B-1': { + enabled: true, + suggested: 'deliver B-1: the held B-2 connects after it', + }, + }, + }, + feedLost: feedLostEnabled, + held: "B-2 is held: it doesn't connect to r1, deliver the transaction that ends at r2, or advance 10 s", + }, + 'Editor B': emptyLink(), + }, + advanceClock: {enabled: true, suggested: 'advance 10 s to end the wait'}, + }) + }) + + test('a held transaction whose missing one is not waiting suggests advancing the clock', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorA: editorSnapshot({ + held: [ + { + transactionId: 'B-2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }, + ], + }), + }), + ) + + expect(network.links['Editor A'].held).toEqual( + "B-2 is held: it doesn't connect to r1, and no transaction that ends at r2 is waiting: advance 10 s", + ) + }) + + test('a transaction for a loading editor cannot be delivered', () => { + const network = applicableNetworkActions( + worldSnapshot({ + editorB: editorSnapshot({ + status: 'loading', + io: ioContext({status: 'loading'}), + }), + network: networkSnapshot({ + feeds: {'Editor A': [], 'Editor B': [feedItem('A-1', 'r1', 'r2')]}, + }), + }), + ) + + expect(network.links['Editor B'].towardEditor).toEqual({ + prompts: [], + replies: {}, + lostReplies: {}, + feed: { + 'A-1': { + enabled: false, + why: 'transactions are only accepted once ready', + }, + }, + }) + }) +}) + +function worldSnapshot({ + editorA = editorSnapshot({}), + editorB = editorSnapshot({}), + network = networkSnapshot({}), +}: { + editorA?: EditorSnapshot + editorB?: EditorSnapshot + network?: NetworkSnapshot +}): WorldSnapshot { + return { + editors: { + 'Editor A': {...editorA, id: 'A'}, + 'Editor B': {...editorB, id: 'B'}, + }, + server: { + value: 'B: foo', + blocks: [], + rev: 'r1', + transactions: [], + duplicates: [], + nextFailure: null, + }, + network, + } +} + +function editorSnapshot(overrides: Partial): EditorSnapshot { + return { + id: 'A', + host: 'plain', + status: 'ready', + io: ioContext({}), + screen: 'B: foo|', + blocks: [], + base: {textspec: 'B: foo', blocks: [], rev: 'r1'}, + inFlight: null, + rejected: null, + echoed: [], + pending: [], + held: [], + readOnly: false, + undoDepth: 0, + sentMutations: [], + events: [], + messages: [], + ...overrides, + } +} + +function networkSnapshot(overrides: Partial): NetworkSnapshot { + return { + saveRequests: [], + replies: [], + lostReplies: [], + feeds: {'Editor A': [], 'Editor B': []}, + carriesServerCopy: false, + now: 0, + ...overrides, + } +} + +function ioContext( + overrides: Partial, +): EditorSnapshot['io'] { + return { + status: 'ready', + sync: 'synced', + rev: 'r1', + inFlight: undefined, + pending: 0, + ...overrides, + } +} + +function sentMutation( + mutationNumber: number, +): EditorSnapshot['sentMutations'][number] { + return { + number: mutationNumber, + id: `A-${mutationNumber}`, + transactionId: `A-tk${mutationNumber - 1}`, + patchCount: 1, + patches: [], + final: false, + } +} + +function mutation(mutationNumber: number) { + return { + mutationNumber, + transactionIds: [`A-${mutationNumber}`], + patchCount: 1, + patches: [], + } +} + +function feedItem( + transactionId: string, + previousRev: string, + resultRev: string, +): NetworkSnapshot['feeds']['Editor A'][number] { + return { + transactionId, + previousRev, + resultRev, + mutationIds: [transactionId], + patchCount: 1, + patches: [], + source: { + type: 'mutations', + mutations: [ + { + name: transactionId.startsWith('A') ? 'Editor A' : 'Editor B', + mutationNumber: Number(transactionId.split('-')[1]), + }, + ], + }, + } +} + +const feedLostEnabled = { + enabled: true, + why: "the host's listener reconnected or may have missed transactions", +} + +function emptyLink() { + return { + towardServer: {prompts: [], saveRequests: {}, loseReply: {}}, + towardEditor: {prompts: [], replies: {}, lostReplies: {}, feed: {}}, + feedLost: feedLostEnabled, + held: undefined, + } +} diff --git a/apps/io-playground/src/applicable.ts b/apps/io-playground/src/applicable.ts new file mode 100644 index 0000000000..2fd237fec8 --- /dev/null +++ b/apps/io-playground/src/applicable.ts @@ -0,0 +1,523 @@ +import type { + EditorName, + EditorSnapshot, + NetworkSnapshot, + WorldSnapshot, +} from '@portabletext/io/testing' + +/** + * Whether the protocol lets an action happen now, why not, and the prompt + * when the state calls for it. + */ +export type Applicability = {enabled: boolean; why?: string; suggested?: string} + +export type EditorAction = + | 'type' + | 'set style' + | 'put caret after' + | 'insert block' + | 'delete block' + | 'delete before caret' + | 'undo' + | 'read-only' + | 'close' + | 'resync' + | 'resync discarding' + | 'load' + | 'end first commit' + +export type EditorApplicability = Record + +export type LinkApplicability = { + towardServer: { + prompts: Array + /** By mutation ID. */ + saveRequests: Record + /** Receiving a save request and losing its reply, by mutation ID. */ + loseReply: Record + } + towardEditor: { + prompts: Array + /** By mutation ID. */ + replies: Record + /** Retrying the save whose reply was lost, by mutation ID. */ + lostReplies: Record + /** By transaction ID. */ + feed: Record + } + /** The host telling the editor its listener missed transactions. */ + feedLost: Applicability + /** The prompt when the editor holds a transaction that doesn't connect. */ + held: string | undefined +} + +export type NetworkApplicability = { + links: Record + advanceClock: Applicability +} + +type FeedItem = NetworkSnapshot['feeds'][EditorName][number] + +const enabled: Applicability = {enabled: true} + +const whyUnmounted = 'the editor is unmounted' +const whyLoading = "the editor isn't ready yet" +const whyReadOnly = 'typing is blocked while read-only' + +export function applicableActions( + snapshot: WorldSnapshot, + editorName: EditorName, +): EditorApplicability { + const editor = snapshot.editors?.[editorName] + + if (!editor) { + const noEditor = disabled('there are no editors yet') + + return { + 'type': noEditor, + 'set style': noEditor, + 'put caret after': noEditor, + 'insert block': noEditor, + 'delete block': noEditor, + 'delete before caret': noEditor, + 'undo': noEditor, + 'read-only': noEditor, + 'close': noEditor, + 'resync': noEditor, + 'resync discarding': noEditor, + 'load': noEditor, + 'end first commit': noEditor, + } + } + + const {status, pending} = editor.io + const edit = editApplicability(editor) + const resync = resyncApplicability(editor) + + return { + 'type': edit, + 'set style': edit, + 'put caret after': + status === 'unmounted' ? disabled(whyUnmounted) : enabled, + 'insert block': edit, + 'delete block': edit, + 'delete before caret': edit, + 'undo': + edit.enabled && editor.undoDepth === 0 + ? disabled('there is nothing to undo') + : edit, + 'read-only': + status === 'unmounted' + ? disabled(whyUnmounted) + : editor.readOnly + ? disabled('the steps have no way to end read-only') + : enabled, + 'close': status === 'unmounted' ? disabled(whyUnmounted) : enabled, + 'resync': {...resync, ...resyncSuggestion(editorName, editor, resync)}, + 'resync discarding': !resync.enabled + ? resync + : editor.io.inFlight + ? disabled('the steps discard only when no mutation is in flight') + : pending === 0 + ? disabled('nothing unsent to discard') + : { + enabled: true, + why: `the user's choice: load the saved version and throw away ${pending} unsent change(s)`, + }, + 'load': firstCommitApplicability( + editorName, + editor, + 'after ready a load throws: resync takes over', + ), + 'end first commit': firstCommitApplicability( + editorName, + editor, + 'the first commit has ended', + ), + } +} + +/** + * The distinct prompts of an editor's suggested actions, in action order. + */ +export function editorPrompts(actions: EditorApplicability): Array { + return unique( + Object.values(actions).flatMap((action) => + action.suggested === undefined ? [] : [action.suggested], + ), + ) +} + +/** + * `deadFeeds` names the editors whose listener the network has stopped + * delivering to. + */ +export function applicableNetworkActions( + snapshot: WorldSnapshot, + deadFeeds: ReadonlyArray = [], +): NetworkApplicability { + const link = (name: EditorName) => + linkApplicability( + name, + snapshot.editors?.[name], + snapshot.network, + deadFeeds.includes(name), + ) + const links = {'Editor A': link('Editor A'), 'Editor B': link('Editor B')} + const anyHeld = Object.values(links).some((link) => link.held !== undefined) + + return { + links, + advanceClock: anyHeld + ? {enabled: true, suggested: 'advance 10 s to end the wait'} + : enabled, + } +} + +function editApplicability(editor: EditorSnapshot): Applicability { + if (editor.io.status === 'unmounted') { + return disabled(whyUnmounted) + } + + if (editor.io.status === 'loading') { + return disabled(whyLoading) + } + + return editor.readOnly ? disabled(whyReadOnly) : enabled +} + +function resyncApplicability(editor: EditorSnapshot): Applicability { + if (editor.io.status === 'unmounted') { + return disabled(whyUnmounted) + } + + if (editor.io.status === 'loading') { + return disabled('resync is only accepted once ready') + } + + const inFlightNumber = inFlightMutationNumber(editor) + + return inFlightNumber === undefined + ? enabled + : { + enabled: true, + why: `mutation ${inFlightNumber} is in flight: the host re-submits it under the same transaction ID to find out whether it landed, and the resync carries that outcome`, + } +} + +function resyncSuggestion( + editorName: EditorName, + editor: EditorSnapshot, + resync: Applicability, +): Pick { + if (!resync.enabled) { + return {} + } + + const inFlightNumber = inFlightMutationNumber(editor) + + if (editor.io.sync === 'out of step') { + return { + suggested: + inFlightNumber === undefined + ? `${editorName} is out of step: resync to recover` + : `${editorName} is out of step: resync with the outcome of mutation ${inFlightNumber} to recover`, + } + } + + if (editor.io.sync === 'blocked' && editor.rejected) { + return { + suggested: `${editorName}'s mutation ${editor.rejected.mutationNumber} was rejected: sending is blocked until a resync`, + } + } + + return {} +} + +/** + * The world's number for the mutation io has in flight, which io names by + * its ID. + */ +function inFlightMutationNumber(editor: EditorSnapshot): number | undefined { + const {inFlight} = editor.io + + return inFlight === undefined + ? undefined + : editor.sentMutations.find((mutation) => mutation.id === inFlight.id) + ?.number +} + +function firstCommitApplicability( + editorName: EditorName, + editor: EditorSnapshot, + whyReady: string, +): Applicability { + if (editor.io.status === 'unmounted') { + return disabled(whyUnmounted) + } + + if (editor.io.status !== 'loading') { + return disabled(whyReady) + } + + return { + enabled: true, + suggested: `${editorName} is in its first commit: load the first content, or end the commit to start empty`, + } +} + +function linkApplicability( + name: EditorName, + editor: EditorSnapshot | undefined, + network: NetworkSnapshot | null, + deadFeed: boolean, +): LinkApplicability { + const requests = + network?.saveRequests.filter((request) => request.editor === name) ?? [] + const replies = + network?.replies.filter((reply) => reply.editor === name) ?? [] + const lostReplies = + network?.lostReplies.filter((reply) => reply.editor === name) ?? [] + const feed = network?.feeds[name] ?? [] + + const firstRequest = requests.at(0) + const requestPrompt = firstRequest + ? `${name}'s ${firstRequest.final ? 'final mutation' : `mutation ${firstRequest.mutationNumber}`} is waiting: the server receives it, or the request fails` + : undefined + + const firstReply = replies.at(0) + const replyPrompt = firstReply + ? `${name}'s mutation ${firstReply.mutationNumber} failed with ${firstReply.status}: deliver the reply` + : undefined + + const inFlightNumber = editor ? inFlightMutationNumber(editor) : undefined + const firstRetryable = lostReplies.find( + (reply) => inFlightNumber === reply.mutationNumber, + ) + const lostReplyPrompt = firstRetryable + ? `the save reply for ${name}'s mutation ${firstRetryable.mutationNumber} was lost: retry it with the same transaction ID` + : undefined + + const held = editor ? heldPrompt(editor, feed) : undefined + const feedEntries = deadFeed + ? Object.fromEntries( + feed.map((item) => [ + item.transactionId, + disabled( + `the feed is dead: the network delivers nothing to ${name}'s listener`, + ), + ]), + ) + : feedApplicability(name, editor, feed) + const suggestedFeed = Object.values(feedEntries).find( + (entry) => entry.suggested !== undefined, + ) + + return { + towardServer: { + prompts: requestPrompt === undefined ? [] : [requestPrompt], + saveRequests: Object.fromEntries( + requests.map((request) => [ + request.mutationId, + request === firstRequest && requestPrompt !== undefined + ? {enabled: true, suggested: requestPrompt} + : enabled, + ]), + ), + loseReply: Object.fromEntries( + requests.map((request) => [ + request.mutationId, + request.final + ? disabled('a final mutation gets no reply') + : { + enabled: true, + why: 'the server saves it, but the host never hears back', + }, + ]), + ), + }, + towardEditor: { + prompts: unique( + [replyPrompt, lostReplyPrompt, held ?? suggestedFeed?.suggested].filter( + (prompt) => prompt !== undefined, + ), + ), + replies: Object.fromEntries( + replies.map((reply) => [ + reply.mutationId, + reply === firstReply && + replyPrompt !== undefined && + editor?.io.status !== 'unmounted' + ? {enabled: true, suggested: replyPrompt} + : enabled, + ]), + ), + lostReplies: Object.fromEntries( + lostReplies.map((reply) => [ + reply.mutationId, + reply === firstRetryable && lostReplyPrompt !== undefined + ? {enabled: true, suggested: lostReplyPrompt} + : inFlightNumber === reply.mutationNumber + ? enabled + : disabled( + `mutation ${reply.mutationNumber} isn't in flight anymore: the host knows what became of it`, + ), + ]), + ), + feed: feedEntries, + }, + feedLost: feedLostApplicability(editor), + held, + } +} + +function feedLostApplicability( + editor: EditorSnapshot | undefined, +): Applicability { + if (!editor) { + return disabled('there are no editors yet') + } + + if (editor.io.status === 'unmounted') { + return disabled(whyUnmounted) + } + + if (editor.io.status === 'loading') { + return disabled('the feed starts once the editor is ready') + } + + if (editor.host === 'self-confirming') { + return disabled('the host has no listener, so there is no feed to lose') + } + + return editor.io.sync === 'out of step' + ? disabled('the editor is out of step already: resync') + : { + enabled: true, + why: "the host's listener reconnected or may have missed transactions", + } +} + +/** + * Suggests the transaction a held one waits for when it is in the feed, and + * otherwise the first one that can be delivered. + */ +function feedApplicability( + name: EditorName, + editor: EditorSnapshot | undefined, + feed: Array, +): Record { + const entries = feed.map((_item, index) => { + if (editor?.io.status === 'loading') { + return disabled('transactions are only accepted once ready') + } + + const blockedBy = earlierNamedItem(feed, index) + + return blockedBy === undefined + ? enabled + : disabled(`Deliver ${blockedBy} first: the steps name it the same way`) + }) + const missing = editor ? missingTransaction(editor) : undefined + const missingIndex = + missing === undefined + ? -1 + : feed.findIndex( + (item, index) => + item.resultRev === missing.previousRev && entries[index].enabled, + ) + const suggestedIndex = + missingIndex !== -1 + ? missingIndex + : entries.findIndex((entry) => entry.enabled) + + if ( + editor !== undefined && + editor.io.status !== 'unmounted' && + suggestedIndex !== -1 + ) { + const item = feed[suggestedIndex] + entries[suggestedIndex] = { + enabled: true, + suggested: + missingIndex === -1 + ? `${item.transactionId} is waiting for ${name}: deliver it` + : `deliver ${item.transactionId}: the held ${missing?.transactionId} connects after it`, + } + } + + return Object.fromEntries( + feed.map((item, index) => [item.transactionId, entries[index]]), + ) +} + +function heldPrompt( + editor: EditorSnapshot, + feed: Array, +): string | undefined { + const missing = missingTransaction(editor) + + if (!missing) { + return undefined + } + + const base = describeRev(editor.base.rev) + const end = describeRev(missing.previousRev) + + return feed.some((item) => item.resultRev === missing.previousRev) + ? `${missing.transactionId} is held: it doesn't connect to ${base}, deliver the transaction that ends at ${end}, or advance 10 s` + : `${missing.transactionId} is held: it doesn't connect to ${base}, and no transaction that ends at ${end} is waiting: advance 10 s` +} + +/** + * The held transaction at the start of the held chain: the one no other held + * transaction leads into. + */ +function missingTransaction( + editor: EditorSnapshot, +): EditorSnapshot['held'][number] | undefined { + return editor.held.find( + (transaction) => + !editor.held.some( + (other) => + other !== transaction && other.resultRev === transaction.previousRev, + ), + ) +} + +/** + * A server change is delivered by name, and the name picks the earliest + * waiting one, so a later one with the same name can't be delivered first. + */ +function earlierNamedItem( + feed: Array, + index: number, +): string | undefined { + const item = feed[index] + + if (item.source.type !== 'named') { + return undefined + } + + const {name} = item.source + const earlier = feed + .slice(0, index) + .find( + (candidate) => + candidate.source.type === 'named' && candidate.source.name === name, + ) + + return earlier?.transactionId +} + +function describeRev(rev: string | null): string { + return rev ?? 'no document' +} + +function disabled(why: string): Applicability { + return {enabled: false, why} +} + +function unique(values: Array): Array { + return [...new Set(values)] +} diff --git a/apps/io-playground/src/concepts.ts b/apps/io-playground/src/concepts.ts new file mode 100644 index 0000000000..1a638e016d --- /dev/null +++ b/apps/io-playground/src/concepts.ts @@ -0,0 +1,258 @@ +import type {HostShape} from '@portabletext/io/testing' + +export const concepts = [ + { + name: 'change', + definition: 'Something a user does, like typing or setting a style.', + }, + { + name: 'mutation', + definition: + 'A group of changes an editor sends off to be saved, numbered per editor.', + }, + { + name: 'save request', + definition: + "A mutation on its way from an editor's host to the server, waiting for the server to receive it.", + }, + { + name: 'rejection', + definition: + 'The save request failed for good (a 400, 403 or 404) and the host says so. A failure that may pass (a 500, 503 or a network error) is retried with the same request instead. A save that went well has no reply the editor needs: its transaction coming back is the confirmation.', + }, + { + name: 'transaction', + definition: + 'One entry on the feed: what the server saved in one go, carrying one or more mutations or a change made on the server.', + }, + { + name: 'feed', + definition: + "The server's list of transactions in order, which every editor receives one at a time.", + }, + { + name: 'revision', + definition: + "The server's version number for the document, which every transaction moves one step forward.", + }, + { + name: 'screen', + definition: + 'What the editor shows the user right now, its own unconfirmed changes included.', + }, + { + name: 'base', + definition: + "The editor's copy of what the server has, at the revision of the last transaction it applied.", + }, + { + name: 'in flight', + definition: + 'The one mutation the editor has sent and waits to see come back on the feed.', + }, + { + name: 'pending', + definition: + 'Changes made while a mutation is in flight, waiting to go out together as the next mutation.', + }, + { + name: 'confirmed', + definition: + "The editor's mutation came back on the feed, so the editor knows the mutation is on the server and where it sits among everyone's changes.", + }, + { + name: 'held', + definition: + "A transaction from the feed that doesn't start at the base's revision, kept aside until the missing one before it arrives.", + }, + { + name: 'held echo', + definition: + "The editor's own mutation that came back inside a held transaction, confirmed once that transaction applies.", + }, + { + name: 'rejected', + definition: + 'The server refused the mutation and the host said so, and the editor sends nothing more until a resync.', + }, + { + name: 'out of step', + definition: + "The editor reported that it no longer matches the server, and it stops applying the feed and sending mutations until a resync. It keeps taking the user's changes and recognizing its own mutations when they come back.", + }, + { + name: 'resync', + definition: + 'The host gives the editor a fresh copy of what the server has, and the editor puts its unsent changes back on top. Only when a person asks to load the saved version are the unsent changes thrown away. While a mutation is in flight, the host first finds out whether it landed and passes that outcome along: a mutation that landed is in the copy, and the changes of one that did not go back with the unsent ones. A rejected mutation is dropped, and the editor says so.', + }, + { + name: 'sync', + definition: + "Whether the user's work is saved: synced when everything came back, saving while a mutation is in flight or changes are pending, blocked after a rejection and out of step after an error or a lost feed, both until a resync.", + }, + { + name: 'feed lost', + definition: + "The host's listener reconnected or may have missed transactions, so it tells the editor, which goes out of step until a resync.", + }, + { + name: 'lost reply', + definition: + 'The server saved the mutation but the host never heard back. The host retries with the same transaction ID: the server refuses a transaction ID it already has (a 409) and changes nothing, so the mutation lands once.', + }, + { + name: 'read-only', + definition: 'The editor refuses user changes but keeps applying the feed.', + }, + { + name: 'error', + definition: + "An event the editor emits when it can't trust its copy of the server anymore, which puts it out of step: a transaction is missing, a key collides, a patch can't be applied, its own mutation came back rewritten (echo mismatch), or a transaction left content it can't show (invalid content).", + }, + { + name: 'work dropped', + definition: + "An event the editor emits when it gives up on the user's unsent changes: their target is gone, the editor closed while sending was blocked or while it was out of step, or a resync dropped the rejected mutation.", + }, + { + name: 'transaction.value', + definition: + "The field as the server holds it right after a transaction, from the listener's result. When the listener sends it, the host passes it on and io takes it as the new base instead of applying the patches to the old one. The patches still travel: io checks them, authors `apply` from them for the editor's tree, and matches them against its mutation to confirm it.", + }, + { + name: 'first commit', + definition: + 'The editor mounting, before it is ready. It takes its first content as a `load` now, and a second `load` replaces the first. Ending the first commit makes it ready, with the content loaded or empty. After ready a `load` throws, and a `resync` takes over.', + }, + { + name: 'message path', + definition: + "Every message between an editor's host and io, in the order it passed, and what io sent the editor: `mutation` goes out to the host, `mutation sent`, `mutation rejected`, `transaction`, `feed lost`, `load` and `resync` come in from it, and `load`, `resync` and `apply` reach the editor's tree.", + }, + { + name: 'host', + definition: + 'The application around the editor that talks to the server: it saves the mutations io emits, forwards the transactions the server records, and hands over the server copy. Free play offers three shapes.', + }, + { + name: 'warning', + definition: + "An event the editor emits when something looks wrong but it can carry on, like a mutation that hasn't come back in time.", + }, +] as const + +export type ConceptName = (typeof concepts)[number]['name'] + +/** + * The host shapes the world supports, under the names of the hosts they + * stand in for. + */ +export const hostPresets = [ + { + shape: 'plain', + label: 'plain host', + step: null, + description: + 'Saves each mutation as its own request, under the transaction ID io proposed with it, so it never sends `mutation sent`. Its listener forwards every transaction the server records, its own included, and its own transaction coming back confirms the mutation. The shape a simple app or the SDK plugin takes.', + }, + { + shape: 'folding', + label: 'Studio-shaped host', + step: 'hosts that fold mutations into shared requests', + description: + "Folds the mutations waiting at a flush into one request, the way Studio's committer sends the whole form in one commit. The request gets the host's own transaction ID, and the host tells io which with a `mutation sent` for each mutation in it, so the transaction coming back on the listener still confirms the right mutations. The request is frozen once formed: a retry or a re-submit sends it as it was, under the same ID.", + }, + { + shape: 'self-confirming', + label: 'Horizon-shaped host', + step: 'hosts that confirm each mutation themselves', + description: + "Has no listener and is the document's only writer. The answer to each save carries the transaction it became, and the host forwards that as `transaction` itself, which confirms the mutation. No other writer's changes reach it, and there is no feed to lose.", + }, +] as const satisfies ReadonlyArray<{ + shape: HostShape + label: string + step: string | null + description: string +}> + +export function hostPresetOf(shape: HostShape) { + return hostPresets.find((preset) => preset.shape === shape) ?? hostPresets[0] +} + +export function definitionOf(name: ConceptName): string { + return concepts.find((concept) => concept.name === name)?.definition ?? '' +} + +export function revisionTitle(rev: string | null): string { + return rev === null + ? "no revision: the document doesn't exist on the server" + : `revision ${rev}, the server's version number for the document` +} + +export const notationRules = [ + { + notation: 'B: foo', + meaning: 'A normal text block. `H1: foo` is a heading.', + }, + {notation: 'B: |', meaning: 'One empty block.'}, + { + notation: '|', + meaning: + 'The caret. A check that writes one compares the selection, and the server never has one.', + }, + { + notation: ';;', + meaning: 'Separates blocks in single-line form, as in `B: foo|;;B: bar`.', + }, + { + notation: '_key', + meaning: + 'Names a block\'s key, as in `B _key="k9": baz`. A check compares keys only when it names them.', + }, + { + notation: '\\|', + meaning: 'How the caret is written inside a Gherkin Examples table.', + }, +] + +export const floorRules = [ + { + phrase: 'has no key', + label: 'remove its key', + shape: 'A block without `_key`.', + onCopy: + 'io gives it a repair key, hashed from the revision and the index path, so two editors repairing the same copy agree, and the repair goes out in the next mutation.', + }, + { + phrase: 'has no type', + label: 'remove its type', + shape: 'A block without `_type`.', + onCopy: + "io makes it `'block'` (a text block's child becomes `'span'`), and the repair goes out in the next mutation.", + }, + { + phrase: 'has children "oops"', + label: 'set its children to "oops"', + shape: "A text block whose `children` isn't a non-empty array of objects.", + onCopy: + 'io gives it one empty span with a repair key, and the repair goes out in the next mutation.', + }, + { + phrase: 'has a span whose text is 42', + label: "set a span's text to 42", + shape: "A span whose `text` isn't a string.", + onCopy: + "io sets the text to `''`, and the repair goes out in the next mutation.", + }, + { + phrase: 'is the string "oops"', + label: 'replace it with "oops"', + shape: "A block that isn't an object.", + onCopy: + 'io leaves it out of what the editor gets and never writes to it, so the server keeps it. A repair of a later block addresses it by its index in the stored array.', + }, +] as const + +export const floorOnTransaction = + 'A transaction that leaves any of these in the blocks it changed makes io emit `error` with reason `invalid content`. The editor is out of step until a resync, which repairs the copy as above.' diff --git a/apps/io-playground/src/drawers.tsx b/apps/io-playground/src/drawers.tsx new file mode 100644 index 0000000000..4528b02fe6 --- /dev/null +++ b/apps/io-playground/src/drawers.tsx @@ -0,0 +1,675 @@ +import { + editorNames, + type EditorName, + type WorldSnapshot, +} from '@portabletext/io/testing' +import {createContext, useContext, type ReactNode} from 'react' +import { + concepts, + floorOnTransaction, + floorRules, + hostPresets, + notationRules, +} from './concepts' +import {describeSource} from './narration' +import { + Badge, + Button, + Empty, + InfoMark, + JsonView, + plural, + RevisionStep, + WithCode, +} from './ui' + +/** + * What the details drawer shows. The drawer looks it up in the current + * snapshot, so it follows the world as steps run. + */ +export type DetailsSelection = + | {type: 'mutation'; editor: EditorName; mutationNumber: number} + | {type: 'pending'; editor: EditorName; index: number} + | {type: 'event'; editor: EditorName; index: number} + | {type: 'transaction'; transactionId: string} + | {type: 'message'; editor: EditorName; index: number} + +export type Drawer = + | {type: 'concepts'} + | {type: 'details'; selection: DetailsSelection} + +const OpenDetailsContext = createContext<(selection: DetailsSelection) => void>( + () => {}, +) + +export const OpenDetailsProvider = OpenDetailsContext.Provider + +export function useOpenDetails(): (selection: DetailsSelection) => void { + return useContext(OpenDetailsContext) +} + +export function DrawerView({ + drawer, + snapshot, + onClose, +}: { + drawer: Drawer + snapshot: WorldSnapshot + onClose: () => void +}) { + return drawer.type === 'concepts' ? ( + + + + ) : ( + +
+ + ) +} + +function DrawerShell({ + title, + onClose, + children, +}: { + title: string + onClose: () => void + children: ReactNode +}) { + return ( + + ) +} + +function ConceptsList() { + return ( + <> +
+ {concepts.map((concept) => ( +
+
{concept.name}
+
+ +
+
+ ))} +
+
+

Host presets

+ {hostPresets.map((preset) => ( +

+ {preset.label}.{' '} + +

+ ))} +
+
+

The floor

+

+ The shapes the editor can't hold at all. On a load or a{' '} + resync, io repairs the copy before anything else and + sends the repairs as its own work: +

+
+ {floorRules.map((rule) => ( +
+
+ +
+
+ +
+
+ ))} +
+

+ +

+
+
+

State notation

+

+ Every state is textspec, the notation{' '} + @portabletext/test reads and + writes. +

+
+ {notationRules.map((rule) => ( +
+
{rule.notation}
+
+ +
+
+ ))} +
+
+ + ) +} + +function Details({ + selection, + snapshot, +}: { + selection: DetailsSelection + snapshot: WorldSnapshot +}) { + switch (selection.type) { + case 'mutation': + return + case 'pending': + return + case 'event': + return + case 'transaction': + return + case 'message': + return + } +} + +function MutationDetails({ + selection, + snapshot, +}: { + selection: Extract + snapshot: WorldSnapshot +}) { + const editor = snapshot.editors?.[selection.editor] + const mutation = editor?.sentMutations[selection.mutationNumber - 1] + + if (!editor || !mutation || !snapshot.network || !snapshot.server) { + return This mutation isn't in the current world. + } + + const savedAs = snapshot.server.transactions.find( + (transaction) => + transaction.source.type === 'mutations' && + transaction.source.mutations.some( + (candidate) => + candidate.name === selection.editor && + candidate.mutationNumber === selection.mutationNumber, + ), + ) + const whereItIs = snapshot.network.saveRequests.some( + (request) => + request.editor === selection.editor && + request.mutationNumber === selection.mutationNumber, + ) + ? 'a save request, waiting for the server to receive it' + : editor.inFlight?.mutationNumber === mutation.number + ? snapshot.network.lostReplies.some( + (reply) => + reply.editor === selection.editor && + reply.mutationNumber === mutation.number, + ) + ? 'in flight: saved, but the reply was lost, so the host may retry it' + : 'in flight: saved, waiting to come back on the feed' + : editor.rejected?.mutationNumber === mutation.number + ? 'rejected' + : editor.echoed.some( + (echoed) => echoed.mutationNumber === mutation.number, + ) + ? 'a held echo: it came back inside a held transaction' + : savedAs + ? `on the server as ${savedAs.id}` + : 'not on the server' + + return ( + <> +

+ {selection.editor}'s mutation {mutation.number}{' '} + +

+ + + + ) +} + +function PendingDetails({ + selection, + snapshot, +}: { + selection: Extract + snapshot: WorldSnapshot +}) { + const editor = snapshot.editors?.[selection.editor] + const change = editor?.pending[selection.index] + + if (!editor || !change) { + return This change isn't pending anymore. + } + + return ( + <> +

+ {selection.editor}'s pending change {selection.index + 1} of{' '} + {editor.pending.length} +

+ + + + ) +} + +function EventDetails({ + selection, + snapshot, +}: { + selection: Extract + snapshot: WorldSnapshot +}) { + const event = snapshot.editors?.[selection.editor]?.events[selection.index] + + if (event?.type === 'error') { + return ( + <> +

+ {selection.editor}'s error +

+ + {event.patch === undefined ? null : ( +
+

+ {event.reason === 'echo mismatch' + ? 'the patch the editor never sent, above a path its mutation touched' + : 'the patch'} +

+ +
+ )} + + ) + } + + if (event?.type === 'work dropped') { + return ( + <> +

+ {selection.editor}'s dropped work +

+ + + + ) + } + + return This event isn't in the current world. +} + +const errorMeanings = { + 'out of order': + "a transaction didn't connect to the base revision within 10 s, so one is missing", + 'duplicate key': + 'a remote insert brought a key the editor already has or has sent', + 'patch failed': "a patch from the host couldn't be evaluated at all", + 'echo mismatch': + "the editor's own transaction came back with a patch it never sent, above a path its mutation touched: the host widened its work", + 'invalid content': + "a transaction left content the editor can't show: a block without a key or type, children that aren't a list of spans, or text that isn't a string", +} + +function TransactionDetails({ + selection, + snapshot, +}: { + selection: Extract + snapshot: WorldSnapshot +}) { + const transaction = snapshot.server?.transactions.find( + (candidate) => candidate.id === selection.transactionId, + ) + + if (!transaction || !snapshot.network || !snapshot.editors) { + return This transaction isn't in the current world. + } + + const {network, editors} = snapshot + const waitingFor = editorNames.filter((name) => + network.feeds[name].some((item) => item.transactionId === transaction.id), + ) + const heldBy = editorNames.filter((name) => + editors[name].held.some((held) => held.transactionId === transaction.id), + ) + + return ( + <> +

+ Transaction {transaction.id} + {transaction.noop ? no change : null} +

+ , + ], + ['mutationIds', JSON.stringify(transaction.mutationIds)], + ['carries', describeSource(transaction.source)], + ['changed the field', transaction.noop ? 'no' : 'yes'], + [ + 'waiting on the feed for', + waitingFor.length === 0 ? 'nobody' : waitingFor.join(', '), + ], + ['held by', heldBy.length === 0 ? 'nobody' : heldBy.join(', ')], + ['patches', plural(transaction.patchCount, 'patch')], + ]} + /> + + + ) +} + +function MessageDetails({ + selection, + snapshot, +}: { + selection: Extract + snapshot: WorldSnapshot +}) { + const message = + snapshot.editors?.[selection.editor]?.messages[selection.index] + + if (!message) { + return This message isn't in the current world. + } + + const heading = ( +

+ {selection.editor}'s message {selection.index + 1}: {message.type}{' '} + +

+ ) + const route = ['route', routeDescriptions[message.route]] as const + + switch (message.type) { + case 'mutation': + return ( + <> + {heading} + + + + ) + case 'mutation sent': + return ( + <> + {heading} + + + ) + case 'mutation rejected': + return ( + <> + {heading} + + + ) + case 'feed lost': + return ( + <> + {heading} + + + ) + case 'transaction': + return ( + <> + {heading} + , + ], + ['patches', plural(message.patches.length, 'patch')], + [ + 'value', + 'value' in message + ? "the server's copy after the transaction: io takes it as the base" + : 'none: io applies the patches to its base', + ], + ]} + /> + + {'value' in message ? ( + + ) : null} + + ) + case 'load': + case 'resync': + return ( + <> + {heading} + + + + ) + case 'apply': + return ( + <> + {heading} + + + + + ) + case 're-submit': + return ( + <> + {heading} + + + ) + } +} + +const routeDescriptions = { + 'io to host': 'io → host', + 'host to io': 'host → io', + 'io to editor': 'io → the editor', + 'host to server': 'host → server, the frozen request sent again', +} + +function JsonSection({ + label, + title, + value, +}: { + label: string + title: string + value: unknown +}) { + return ( +
+

{title}

+ +
+ ) +} + +function Fields({ + fields, +}: { + fields: ReadonlyArray +}) { + return ( +
+ {fields.map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+ ) +} + +function PatchesView({patches}: {patches: Array}) { + return ( +
+

+ patches, as @portabletext/patches{' '} + shapes them +

+ +
+ ) +} diff --git a/apps/io-playground/src/editor-panel.tsx b/apps/io-playground/src/editor-panel.tsx new file mode 100644 index 0000000000..502799eeec --- /dev/null +++ b/apps/io-playground/src/editor-panel.tsx @@ -0,0 +1,366 @@ +import type { + MutationSnapshot, + EditorName, + EditorSnapshot, + HeardEvent, +} from '@portabletext/io/testing' +import type {ReactNode} from 'react' +import {hostPresetOf, type ConceptName} from './concepts' +import {useOpenDetails} from './drawers' +import {MessagePath} from './message-path' +import {describePatches} from './narration' +import { + Badge, + DetailsLink, + Empty, + ItemList, + Label, + plural, + Prompts, + Revision, + RevisionStep, + Section, + TextspecValue, + useFlash, +} from './ui' +import {WorkDroppedNotices} from './work-dropped' + +export function EditorPanel({ + name, + editor, + waitingCount, + prompts, + savingFor, + dismissedWork, + onDismissWork, +}: { + name: EditorName + editor: EditorSnapshot | undefined + /** How long the mutation in flight has been out, on the world's clock. */ + savingFor?: number | undefined + /** Transactions waiting in this editor's feed. */ + waitingCount: number + /** What the editor's state calls for. */ + prompts: Array + /** The `work dropped` events, by index, whose notice was dismissed. */ + dismissedWork: Array + onDismissWork: (index: number) => void +}) { + const flash = useFlash(JSON.stringify(editor ?? null)) + + return ( +
+
+

{name}

+ {editor ? ( + <> + + {editor.status} + + {editor.status === 'loading' ? ( + + ) : null} + + {editor.io.sync === 'saving' && savingFor !== undefined ? ( + = 10_000 ? 'font-semibold text-red-700' : 'text-gray-500'}`} + > + {savingFor / 1000} s + + ) : null} + {waitingCount > 0 ? ( + {waitingCount} waiting + ) : null} + {editor.readOnly ? read-only : null} + + + + + ) : null} +
+ + {editor ? ( + + ) : null} + + + + {editor ? ( + + ) : ( + No editors yet + )} +
+ ) +} + +function EditorDetails({ + name, + editor, +}: { + name: EditorName + editor: EditorSnapshot +}) { + const openDetails = useOpenDetails() + const mutationLink = (mutation: MutationSnapshot) => ( + + openDetails({ + type: 'mutation', + editor: name, + mutationNumber: mutation.mutationNumber, + }) + } + > + mutation {mutation.mutationNumber} → {describeTransactionIds(mutation)} ·{' '} + {plural(mutation.patchCount, 'patch')} + + ) + + return ( + <> +
+ +
+ +
· the server's copy, no document yet + ) : ( + <> + · the server's copy at {' '} + + + ) + } + > + {editor.base.textspec === null ? ( + no field + ) : ( + + )} +
+ +
+ + + {editor.inFlight ? mutationLink(editor.inFlight) : 'nothing'} + + + {editor.pending.length === 0 ? ( + 'nothing' + ) : ( + + {editor.pending.map((change, index) => ( + + openDetails({type: 'pending', editor: name, index}) + } + > + change {index + 1} · {describePatches(change.patches)} + + ))} + + )} + + + {editor.held.length === 0 ? ( + 'nothing' + ) : ( + + {editor.held.map((transaction) => ( + + + openDetails({ + type: 'transaction', + transactionId: transaction.transactionId, + }) + } + > + {transaction.transactionId} + + :{' '} + + + ))} + + )} + + {editor.echoed.length > 0 ? ( + + + {editor.echoed.map((mutation) => ( + + {mutationLink(mutation)} + + ))} + + + ) : null} + + {editor.rejected ? mutationLink(editor.rejected) : 'nothing'} + + + {editor.io.sync === 'out of step' ? 'yes' : 'no'} + + + {editor.readOnly ? 'yes' : 'no'} + + +
+ + + +
+ {editor.sentMutations.length === 0 ? ( + none + ) : ( + + {editor.sentMutations.map((mutation) => ( +
  • + + openDetails({ + type: 'mutation', + editor: name, + mutationNumber: mutation.number, + }) + } + > + mutation {mutation.number} → {mutation.transactionId} ·{' '} + {plural(mutation.patchCount, 'patch')} + {mutation.final ? ' · final' : null} + +
  • + ))} +
    + )} +
    + +
    + {editor.events.length === 0 ? ( + none + ) : ( + + {editor.events.map((event, index) => ( +
  • + {' '} + {event.type === 'error' || event.type === 'work dropped' ? ( + + openDetails({type: 'event', editor: name, index}) + } + > + {describeEvent(event)} + + ) : ( + describeEvent(event) + )} +
  • + ))} +
    + )} +
    + + ) +} + +function LedgerRow({ + concept, + label, + children, +}: { + concept: ConceptName + label: string + children: ReactNode +}) { + return ( +
  • + + + + {children} +
  • + ) +} + +const syncTones = { + 'synced': 'green', + 'saving': 'blue', + 'blocked': 'red', + 'out of step': 'amber', +} as const + +function describeTransactionIds(mutation: MutationSnapshot): string { + return mutation.transactionIds.length === 0 + ? '?' + : mutation.transactionIds.join(' / ') +} + +function describeEvent(event: HeardEvent): string { + switch (event.type) { + case 'change': + return `· ${event.origin} · ${plural(event.patchCount, 'patch')}` + case 'error': + return `· ${event.reason}${event.transactionId === undefined ? '' : ` · ${event.transactionId}`}` + case 'work dropped': + return `· ${event.reason} · ${plural(event.patchCount, 'patch')}` + case 'warning': + return `· ${event.message}` + } +} + +function eventTone(event: HeardEvent): string { + switch (event.type) { + case 'change': + return 'text-gray-700' + case 'error': + return 'text-red-700' + case 'work dropped': + return 'text-orange-700' + case 'warning': + return 'text-amber-700' + } +} diff --git a/apps/io-playground/src/features.ts b/apps/io-playground/src/features.ts new file mode 100644 index 0000000000..87ffb4d1bd --- /dev/null +++ b/apps/io-playground/src/features.ts @@ -0,0 +1,40 @@ +import {compileScenarios} from '@portabletext/io/testing' + +const featureFiles: Record = import.meta.glob( + '../../../packages/io/gherkin-spec/*.feature', + {query: '?raw', import: 'default', eager: true}, +) + +const featureOrder = [ + 'sending-and-confirming', + 'other-editors', + 'concurrent-edits', + 'out-of-step-and-resync', + 'keys', + 'loading-and-empty', + 'malformed-content', + 'listeners', + 'lifecycle', +] + +/** + * Every feature file in the package's `gherkin-spec`, the familiar ones in + * reading order and any other after them. + */ +export const features = Object.entries(featureFiles) + .map(([path, featureText]) => ({ + name: path.replace(/^.*\/(.+)\.feature$/, '$1'), + featureText, + })) + .sort( + (featureA, featureB) => + rank(featureA.name) - rank(featureB.name) || + featureA.name.localeCompare(featureB.name), + ) + .map(({featureText}) => compileScenarios(featureText)) + +function rank(name: string): number { + const index = featureOrder.indexOf(name) + + return index === -1 ? featureOrder.length : index +} diff --git a/apps/io-playground/src/free-play-tab.tsx b/apps/io-playground/src/free-play-tab.tsx new file mode 100644 index 0000000000..04d759d75f --- /dev/null +++ b/apps/io-playground/src/free-play-tab.tsx @@ -0,0 +1,706 @@ +import { + createWorld, + editorNames, + type EditorName, + type EditorSnapshot, + type HostShape, + type ServerCopyName, + type World, + type WorldSnapshot, +} from '@portabletext/io/testing' +import {useState} from 'react' +import { + applicableActions, + editorPrompts, + type Applicability, + type EditorApplicability, +} from './applicable' +import {hostPresetOf, hostPresets} from './concepts' +import { + formatScenario, + formatSteps, + inEditor, + quoted, + runStep, + serverChecks, + type LoggedStep, + type StepKeyword, +} from './gherkin' +import {narrateStep, type NarrationEntry} from './narration' +import {NarrationLog} from './narration-log' +import {ActionButton, Badge, Button, Prompts, Section, TextInput} from './ui' + +type Setup = { + mode: 'the document is' | 'editors in their first commit' + textspec: string + serverCopy: 'textspec' | ServerCopyName + hosts: HostShape + transactionsCarryCopy: boolean +} + +type FreePlay = { + world: World + log: Array + narration: Array + error: string | null + /** Editors whose listener the network has stopped delivering to. */ + deadFeeds: Array + /** When each editor sent each mutation, on the world's clock, by mutation number. */ + sentAt: Record> +} + +const setupModes: Array = [ + 'the document is', + 'editors in their first commit', +] + +const serverCopies: Array = [ + 'textspec', + 'no document', + 'no field', + 'an empty list', +] + +const styles = ['normal', 'h1', 'h2', 'h3'] + +export function useFreePlay() { + const [setup, setSetup] = useState({ + mode: 'editors in their first commit', + textspec: 'B: foo', + serverCopy: 'textspec', + hosts: 'plain', + transactionsCarryCopy: false, + }) + const [freePlay, setFreePlay] = useState(() => startFreePlay(setup)) + + function perform(keyword: StepKeyword, text: string): boolean { + const {world} = freePlay + const before = world.snapshot() + const narrateNow = () => + narrateStep({ + step: `${keyword} ${text}`, + isAction: keyword === 'When', + before, + after: world.snapshot(), + }) + + try { + runStep(world, {keyword, text}) + const entries = narrateNow() + const after = world.snapshot() + setFreePlay((current) => ({ + ...current, + log: [...current.log, {keyword, text}], + narration: [...current.narration, ...entries], + error: null, + sentAt: recordSends(current.sentAt, after), + })) + return true + } catch (error) { + const entries = narrateNow() + const after = world.snapshot() + setFreePlay((current) => ({ + ...current, + narration: [...current.narration, ...entries], + error: `${keyword} ${text}: ${error instanceof Error ? error.message : String(error)}`, + sentAt: recordSends(current.sentAt, after), + })) + return false + } + } + + function note(step: string, sentences: Array) { + setFreePlay((current) => ({ + ...current, + narration: [...current.narration, {step, sentences}], + })) + } + + function setFeedDead(name: EditorName, dead: boolean) { + setFreePlay((current) => ({ + ...current, + deadFeeds: dead + ? [...current.deadFeeds.filter((candidate) => candidate !== name), name] + : current.deadFeeds.filter((candidate) => candidate !== name), + })) + } + + function killFeed(name: EditorName) { + setFeedDead(name, true) + note(`(${name}'s feed dies)`, [ + `The network stops delivering to ${name}'s listener. Nothing tells the host or io: transactions pile up unheard, and a mutation in flight never comes back.`, + ]) + } + + function sayFeedLost(name: EditorName) { + const inFlight = freePlay.world.snapshot().editors?.[name].inFlight + + if (!perform('When', `${name}'s feed is lost`)) { + return + } + + perform( + 'When', + inFlight + ? `${name} is resynced with the outcome of mutation ${inFlight.mutationNumber}` + : `${name} is resynced`, + ) + setFeedDead(name, false) + } + + function doNothing(name: EditorName) { + if (!perform('When', '10 seconds pass')) { + return + } + + const snapshot = freePlay.world.snapshot() + const editor = snapshot.editors?.[name] + const elapsed = editor ? savingFor(name, snapshot) : undefined + + note(`(${name}'s host does nothing)`, [ + editor?.inFlight && elapsed !== undefined + ? `${name}'s host never notices. Mutation ${editor.inFlight.mutationNumber} has been in flight for ${elapsed / 1000} s and sync still says ${editor.io.sync}. The protocol has no stalled state to show: what a user sees when a save never comes back is still an open question.` + : `${name}'s host never notices, and sync says ${editor?.io.sync ?? 'nothing'}.`, + ]) + } + + function savingFor( + name: EditorName, + snapshot = freePlay.world.snapshot(), + ): number | undefined { + const inFlight = snapshot.editors?.[name].inFlight + const sentAt = + inFlight === null || inFlight === undefined + ? undefined + : recordSends(freePlay.sentAt, snapshot)[name][ + inFlight.mutationNumber - 1 + ] + + return sentAt === undefined || snapshot.network === null + ? undefined + : snapshot.network.now - sentAt + } + + function captureChecks() { + const snapshot = freePlay.world.snapshot() + const checks: Array = [] + + for (const name of editorNames) { + const editor = snapshot.editors?.[name] + + if (editor) { + checks.push(`${name} shows ${quoted(editor.screen)}`) + } + } + + const server = snapshot.server ? serverChecks(snapshot.server) : undefined + + for (const check of [...checks, ...(server?.checks ?? [])]) { + perform('Then', check) + } + + if (server?.omitted) { + note('(capture checks)', [server.omitted]) + } + } + + return { + world: freePlay.world, + log: freePlay.log, + narration: freePlay.narration, + error: freePlay.error, + deadFeeds: freePlay.deadFeeds, + killFeed, + sayFeedLost, + doNothing, + savingFor, + setup, + setSetup, + reset: () => setFreePlay(startFreePlay(setup)), + resetWith: (next: Setup) => setFreePlay(startFreePlay(next)), + setTransactionsCarryCopy: (transactionsCarryCopy: boolean) => { + const next = {...setup, transactionsCarryCopy} + setSetup(next) + setFreePlay(startFreePlay(next)) + }, + perform, + captureChecks, + } +} + +export function FreePlayTab({ + freePlay, +}: { + freePlay: ReturnType +}) { + const {setup, setSetup} = freePlay + const [scenarioName, setScenarioName] = useState('Free play') + const [copied, setCopied] = useState(false) + const scenarioText = formatScenario(scenarioName, freePlay.log) + const snapshot = freePlay.world.snapshot() + + return ( +
    + {editorNames.map((name) => ( + freePlay.perform('When', text)} + feed={{ + dead: freePlay.deadFeeds.includes(name), + dies: feedDiesApplicability(snapshot, name), + onDies: () => freePlay.killFeed(name), + onSayFeedLost: () => freePlay.sayFeedLost(name), + onDoNothing: () => freePlay.doNothing(name), + }} + /> + ))} + +
    +
    +
    + + + {setup.mode === 'the document is' ? null : ( + + )} + {setup.mode === 'the document is' || + setup.serverCopy === 'textspec' ? ( + setSetup({...setup, textspec})} + width="w-40" + /> + ) : null} + +
    +
    + +
    +
    + + + +
    + {freePlay.error ? ( +

    {freePlay.error}

    + ) : null} +
    +            {formatSteps(freePlay.log).join('\n')}
    +          
    +
    +
    + +
    + +
    +
    + ) +} + +function EditorControls({ + name, + actions, + readOnly, + inFlightMutationNumber, + onStep, + feed, +}: { + name: EditorName + actions: EditorApplicability + readOnly: boolean + /** The mutation whose outcome a resync carries. */ + inFlightMutationNumber: number | undefined + onStep: (text: string) => void + feed: { + dead: boolean + dies: Applicability + onDies: () => void + onSayFeedLost: () => void + onDoNothing: () => void + } +}) { + const [typed, setTyped] = useState('x') + const [style, setStyle] = useState('h1') + const [caretAfter, setCaretAfter] = useState('foo') + const [insertedBlock, setInsertedBlock] = useState('B: bar') + const [deletedBlock, setDeletedBlock] = useState('foo') + const [deletedText, setDeletedText] = useState('x') + const suffix = inEditor(name) + + return ( +
    +
    + +
    + onStep(`${quoted(typed)} is typed${suffix}`)} + > + type + + +
    +
    + + onStep(`the style is set to ${quoted(style)}${suffix}`) + } + > + set style + + +
    +
    + + onStep(`the caret is put after ${quoted(caretAfter)}${suffix}`) + } + > + put caret after + + +
    +
    + + onStep(`the block ${quoted(insertedBlock)} is inserted${suffix}`) + } + > + insert block + + +
    +
    + + onStep(`the block ${quoted(deletedBlock)} is deleted${suffix}`) + } + > + delete block + + +
    +
    + + onStep( + `${quoted(deletedText)} is deleted before the caret${suffix}`, + ) + } + > + delete before caret + + +
    +
    + onStep(`undo is performed${suffix}`)} + > + undo + + onStep(`${name} becomes read-only`)} + > + read-only: {readOnly ? 'on' : 'off'} + + onStep(`${name} is closed`)} + > + {actions.close.enabled ? 'close' : 'closed'} + +
    +
    + + onStep( + inFlightMutationNumber === undefined + ? `${name} is resynced` + : `${name} is resynced with the outcome of mutation ${inFlightMutationNumber}`, + ) + } + > + {inFlightMutationNumber === undefined + ? 'resync' + : `resync with outcome of mutation ${inFlightMutationNumber}`} + + + onStep(`${name} is resynced, discarding unsent changes`) + } + > + load saved version (discard unsent) + + onStep(`${name} is loaded`)} + > + load + + onStep(`${name}'s first commit ends`)} + > + end first commit + + {actions.load.enabled ? null : ( + + load: {actions.load.why} + + )} +
    +
    + {feed.dead ? ( + <> + feed dead + the host can: + + + + ) : ( + + the feed dies + + )} +
    +
    +
    + ) +} + +function startFreePlay(setup: Setup): FreePlay { + const world = createWorld() + const log: Array = [] + const narration: Array = [] + + try { + for (const text of setupSteps(setup)) { + const before = world.snapshot() + runStep(world, {keyword: 'Given', text}) + log.push({keyword: 'Given', text}) + narration.push( + ...narrateStep({ + step: `Given ${text}`, + isAction: false, + before, + after: world.snapshot(), + }), + ) + } + + return { + world, + log, + narration, + error: null, + deadFeeds: [], + sentAt: recordSends(noSends, world.snapshot()), + } + } catch (error) { + return { + world, + log, + narration, + error: `Setup: ${error instanceof Error ? error.message : String(error)}`, + deadFeeds: [], + sentAt: recordSends(noSends, world.snapshot()), + } + } +} + +function feedDiesApplicability( + snapshot: WorldSnapshot, + name: EditorName, +): Applicability { + const editor = snapshot.editors?.[name] + + if (!editor) { + return {enabled: false, why: 'there are no editors yet'} + } + + if (editor.io.status !== 'ready') { + return {enabled: false, why: 'the feed runs only while the editor is ready'} + } + + if (editor.host === 'self-confirming') { + return { + enabled: false, + why: 'the host has no listener, so there is no feed to die', + } + } + + return editor.io.sync === 'out of step' + ? {enabled: false, why: 'the editor is out of step already: resync'} + : { + enabled: true, + why: 'the network stops delivering to the listener, and nothing tells the host', + } +} + +const noSends: Record> = { + 'Editor A': [], + 'Editor B': [], +} + +function recordSends( + sentAt: Record>, + snapshot: WorldSnapshot, +): Record> { + const {editors, network} = snapshot + + if (!editors || !network) { + return sentAt + } + + return { + 'Editor A': stamp(sentAt['Editor A'], editors['Editor A'], network.now), + 'Editor B': stamp(sentAt['Editor B'], editors['Editor B'], network.now), + } +} + +function stamp( + times: Array, + editor: EditorSnapshot, + now: number, +): Array { + return editor.sentMutations.length > times.length + ? [...times, ...editor.sentMutations.slice(times.length).map(() => now)] + : times +} + +function setupSteps(setup: Setup): Array { + const hostStep = hostPresetOf(setup.hosts).step + + return [ + ...(hostStep === null ? [] : [hostStep]), + ...(setup.transactionsCarryCopy + ? ["transactions that carry the server's copy"] + : []), + ...editorSetupSteps(setup), + ] +} + +function editorSetupSteps(setup: Setup): Array { + if (setup.mode === 'the document is') { + return [`the document is ${quoted(setup.textspec)}`] + } + + return [ + setup.serverCopy === 'textspec' + ? `the server has ${quoted(setup.textspec)}` + : `the server has ${setup.serverCopy}`, + 'the editors are in their first commit', + ] +} diff --git a/apps/io-playground/src/gherkin.test.ts b/apps/io-playground/src/gherkin.test.ts new file mode 100644 index 0000000000..946dfa55c5 --- /dev/null +++ b/apps/io-playground/src/gherkin.test.ts @@ -0,0 +1,66 @@ +import {describe, expect, test} from 'vitest' +import {serverChecks} from './gherkin' + +describe(serverChecks.name, () => { + test('a block that is not an object gets the check of its own, and the other blocks are spelled as textspec', () => { + expect( + serverChecks({ + rev: 'r2', + blocks: [ + // @ts-expect-error: the stored field holds a string block + 'oops', + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + style: 'normal', + }, + ], + }), + ).toEqual({ + checks: [ + 'the server has "B: bar"', + 'the server has a block that is not an object', + ], + omitted: null, + }) + }) + + test("a block below the floor that textspec can't spell leaves the blocks unchecked, and says why", () => { + expect( + serverChecks({ + rev: 'r2', + blocks: [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + // @ts-expect-error: the stored block has no `_type` + { + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + style: 'normal', + }, + ], + }), + ).toEqual({ + checks: [], + omitted: + "The server holds a block below the floor that textspec can't spell, so no check pins the server's blocks.", + }) + }) + + test('no document, no field and an empty list each have their own check', () => { + expect([ + serverChecks({rev: null, blocks: null}), + serverChecks({rev: 'r1', blocks: null}), + serverChecks({rev: 'r1', blocks: []}), + ]).toEqual([ + {checks: ['the server has no document'], omitted: null}, + {checks: ['the server has no field'], omitted: null}, + {checks: ['the server has an empty list'], omitted: null}, + ]) + }) +}) diff --git a/apps/io-playground/src/gherkin.ts b/apps/io-playground/src/gherkin.ts new file mode 100644 index 0000000000..3fa7ec9d47 --- /dev/null +++ b/apps/io-playground/src/gherkin.ts @@ -0,0 +1,112 @@ +import { + compileScenarios, + formatTextspec, + type EditorName, + type ServerSnapshot, + type World, +} from '@portabletext/io/testing' + +export type StepKeyword = 'Given' | 'When' | 'Then' + +export type LoggedStep = {keyword: StepKeyword; text: string} + +/** + * Runs one step through the package's step definitions, so what the log + * records is exactly what ran. The model's steps are synchronous, which lets + * free play set up a world in one render. + */ +export function runStep(world: World, step: LoggedStep): void { + const [scenario] = compileScenarios( + `Feature: Free play\n Scenario: Free play\n ${step.keyword} ${step.text}\n`, + ).scenarios + + for (const compiledStep of scenario?.steps ?? []) { + if (compiledStep.run(world) instanceof Promise) { + throw new Error(`"${step.text}" is asynchronous`) + } + } +} + +/** A step keyword that repeats the one before it is written as `And`. */ +export function formatSteps(steps: Array): Array { + return steps.map((step, index) => + index > 0 && steps[index - 1].keyword === step.keyword + ? `And ${step.text}` + : `${step.keyword} ${step.text}`, + ) +} + +export function formatScenario(name: string, steps: Array): string { + return [ + ` Scenario: ${name}`, + ...formatSteps(steps).map((line) => ` ${line}`), + ].join('\n') +} + +/** + * The editor suffix for user actions: the vocabulary leaves it out for + * Editor A. + */ +export function inEditor(name: EditorName): string { + return name === 'Editor A' ? '' : ` in ${name}` +} + +export function quoted(text: string): string { + return `"${text}"` +} + +/** + * The checks that pin what the server has, in the step vocabulary: its + * blocks that are objects as textspec, and a check of its own for a block + * that isn't an object, which textspec can't spell. A block that is an + * object but below the floor textspec can't spell either, so the blocks go + * unchecked, and `omitted` says why. + */ +export function serverChecks(server: Pick): { + checks: Array + omitted: string | null +} { + if (server.blocks === null) { + return { + checks: [ + server.rev === null + ? 'the server has no document' + : 'the server has no field', + ], + omitted: null, + } + } + + if (server.blocks.length === 0) { + return {checks: ['the server has an empty list'], omitted: null} + } + + const objectBlocks = server.blocks.filter( + (block) => + typeof block === 'object' && block !== null && !Array.isArray(block), + ) + const nonObjectChecks = + objectBlocks.length < server.blocks.length + ? ['the server has a block that is not an object'] + : [] + + if (objectBlocks.length === 0) { + return {checks: nonObjectChecks, omitted: null} + } + + try { + return { + checks: [ + `the server has ${quoted(formatTextspec(objectBlocks))}`, + ...nonObjectChecks, + ], + omitted: null, + } + } catch { + return { + checks: nonObjectChecks, + omitted: + "The server holds a block below the floor that textspec can't spell, so no check pins the server's blocks.", + } + } +} diff --git a/apps/io-playground/src/index.css b/apps/io-playground/src/index.css new file mode 100644 index 0000000000..2748eb883e --- /dev/null +++ b/apps/io-playground/src/index.css @@ -0,0 +1,5 @@ +@import 'tailwindcss'; + +body { + @apply bg-gray-50 text-gray-900; +} diff --git a/apps/io-playground/src/link-panel.tsx b/apps/io-playground/src/link-panel.tsx new file mode 100644 index 0000000000..b0b1e381a9 --- /dev/null +++ b/apps/io-playground/src/link-panel.tsx @@ -0,0 +1,458 @@ +import type { + EditorName, + EditorSnapshot, + NetworkSnapshot, + TransactionSource, +} from '@portabletext/io/testing' +import type {ReactNode} from 'react' +import type {LinkApplicability} from './applicable' +import {useOpenDetails} from './drawers' +import {describeSource, isOwnFeedItem} from './narration' +import { + ActionButton, + Badge, + Button, + DetailsLink, + Empty, + InfoMark, + Label, + plural, + Prompts, + RevisionStep, + useFlash, +} from './ui' + +/** + * The link between one editor and the server: save requests travel up + * toward the server, rejections and the feed travel down toward the editor. + * Cards sit oldest nearest the party that takes them next. + */ +export function LinkPanel({ + name, + editorSide, + editor, + network, + applicability, + onStep, + onDeliver, + selectedMutationIds, + onToggleSelected, + deadFeed, +}: { + name: EditorName + /** Where the editor sits relative to this link. */ + editorSide: 'left' | 'right' + editor: EditorSnapshot | undefined + network: NetworkSnapshot | null + applicability: LinkApplicability + /** Present in free play: runs a `When` step and tells whether it succeeded. */ + onStep: ((text: string) => boolean) | undefined + /** + * Present in free play and after a scenario is done: runs a delivery + * `When` step and tells whether it succeeded. + */ + onDeliver: ((text: string) => boolean) | undefined + /** Save requests picked to be received as one transaction. */ + selectedMutationIds: Array + onToggleSelected: (mutationId: string) => void + /** Whether the network has stopped delivering to the editor's listener. */ + deadFeed: boolean +}) { + const requests = + network?.saveRequests.filter((request) => request.editor === name) ?? [] + const replies = + network?.replies.filter((reply) => reply.editor === name) ?? [] + const lostReplies = + network?.lostReplies.filter((reply) => reply.editor === name) ?? [] + const feed = network?.feeds[name] ?? [] + const flash = useFlash(JSON.stringify({requests, replies, lostReplies, feed})) + const openDetails = useOpenDetails() + const listening = editor?.host !== 'self-confirming' + const towardServer = editorSide === 'left' ? '→' : '←' + const towardEditor = editorSide === 'left' ? '←' : '→' + const upwardOrder = editorSide === 'left' ? 'flex-row-reverse' : 'flex-row' + const downwardOrder = editorSide === 'left' ? 'flex-row' : 'flex-row-reverse' + + return ( +
    +

    + link {name === 'Editor A' ? 'A' : 'B'} +

    + + {network ? ( + <> + + {' '} + + + } + > + {onStep ? ( + + ) : null} + {requests.length === 0 ? ( + none waiting + ) : ( +
    + {requests.map((request) => ( + +
    + {onStep && network.saveRequests.length > 1 ? ( + onToggleSelected(request.mutationId)} + className="mt-0.5" + /> + ) : null} + + openDetails({ + type: 'mutation', + editor: name, + mutationNumber: request.mutationNumber, + }) + } + > + mutation {request.mutationNumber} →{' '} + {editor?.sentMutations[request.mutationNumber - 1] + ?.transactionId ?? request.mutationId} + +
    + + {plural(request.patchCount, 'patch')} + {request.final ? ' · final' : null} + + {onStep ? ( +
    + + onStep( + request.final + ? `the server receives ${name}'s final mutation` + : `the server receives ${name}'s mutation ${request.mutationNumber}`, + ) + } + > + server receives + + {([400, 503] as const).map((failure) => ( + + ))} + { + if ( + onStep( + `the server receives ${name}'s mutation ${request.mutationNumber}`, + ) + ) { + onStep( + `the save reply for ${name}'s mutation ${request.mutationNumber} is lost`, + ) + } + }} + > + lose reply + +
    + ) : null} +
    + ))} +
    + )} +
    + + + {editorSide === 'left' ? ( + + ) : null} + + {listening ? ( + <> + {' '} + and the + + ) : null} + {deadFeed ? feed dead : null} + {editorSide === 'right' ? ( + + ) : null} + + } + > + {onDeliver ? ( + + ) : null} + {replies.length === 0 ? null : ( +
    + {replies.map((reply) => ( + + + failed: {reply.status} + + + mutation {reply.mutationNumber} + + {onDeliver ? ( +
    + + onDeliver( + `the save reply for ${name}'s mutation ${reply.mutationNumber} arrives`, + ) + } + > + deliver + +
    + ) : null} +
    + ))} +
    + )} + + {lostReplies.length === 0 ? null : ( +
    + {lostReplies.map((reply) => ( + + + reply lost + + + + mutation {reply.mutationNumber} →{' '} + {editor?.sentMutations[reply.mutationNumber - 1] + ?.transactionId ?? reply.mutationId} + + {onStep ? ( +
    + + onStep( + `${name}'s mutation ${reply.mutationNumber} is retried`, + ) + } + > + retry + +
    + ) : null} +
    + ))} +
    + )} + + {!listening ? ( + + no listener: the host confirms each mutation from the answer to + its own save + + ) : feed.length === 0 ? ( + no transactions waiting + ) : ( +
    + {feed.map((item) => { + const yours = isOwnFeedItem(item, name) + + return ( + + + + openDetails({ + type: 'transaction', + transactionId: item.transactionId, + }) + } + > + {item.transactionId} + + {yours ? yours : null} + + + + {describeSource(item.source)} + + {onDeliver ? ( +
    + + onDeliver(deliveryStep(name, item.source)) + } + > + deliver + +
    + ) : null} +
    + ) + })} +
    + )} + + {onDeliver ? ( +
    + {onStep ? ( + onStep(`${name}'s feed is lost`)} + > + feed lost + + ) : null} + +
    + ) : null} +
    + + ) : ( + No network yet + )} +
    + ) +} + +function Lane({ + label, + heading, + children, +}: { + label: string + heading: ReactNode + children: ReactNode +}) { + return ( +
    +

    + {heading} +

    + {children} +
    + ) +} + +function Card({label, children}: {label: string; children: ReactNode}) { + return ( +
    + {children} +
    + ) +} + +function deliveryStep(receiver: EditorName, source: TransactionSource): string { + if (source.type === 'named') { + return source.name === 'the other field' + ? `${receiver} receives the other field's change` + : `${receiver} receives ${source.name}` + } + + const mutation = + source.mutations.find((candidate) => candidate.name === receiver) ?? + source.mutations[0] + + return mutation.name === receiver + ? `${receiver}'s mutation ${mutation.mutationNumber} comes back` + : `${receiver} receives ${mutation.name}'s mutation ${mutation.mutationNumber}` +} diff --git a/apps/io-playground/src/main.tsx b/apps/io-playground/src/main.tsx new file mode 100644 index 0000000000..dad8424a4c --- /dev/null +++ b/apps/io-playground/src/main.tsx @@ -0,0 +1,10 @@ +import './index.css' +import {StrictMode} from 'react' +import {createRoot} from 'react-dom/client' +import {App} from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/io-playground/src/message-path.tsx b/apps/io-playground/src/message-path.tsx new file mode 100644 index 0000000000..14c06ef04b --- /dev/null +++ b/apps/io-playground/src/message-path.tsx @@ -0,0 +1,147 @@ +import type {EditorName, PathMessage} from '@portabletext/io/testing' +import {useEffect, useRef} from 'react' +import {useOpenDetails} from './drawers' +import {Badge, DetailsLink, Empty, plural, Section} from './ui' + +/** + * Every message on the editor's path, oldest first: what io and its host + * said to each other, what io sent the editor, and the host's re-submits. + */ +export function MessagePath({ + name, + messages, +}: { + name: EditorName + messages: Array +}) { + const openDetails = useOpenDetails() + const listRef = useRef(null) + + useEffect(() => { + const list = listRef.current + + if (list) { + list.scrollTop = list.scrollHeight + } + }, [messages.length]) + + return ( +
    + {messages.length === 0 ? ( + none + ) : ( +
      + {messages.map((message, index) => ( +
    1. + + {routeLabels[message.route]} + + + + openDetails({type: 'message', editor: name, index}) + } + > + {message.type} + {' '} + + {describeMessage(message)} + + {message.type === 'mutation sent' ? ( + <> + {' '} + host's own ID + + ) : null} + {message.type === 'transaction' && + message.via === 'save reply' ? ( + <> + {' '} + from its own save + + ) : null} + {message.type === 'transaction' && 'value' in message ? ( + <> + {' '} + value + + ) : null} + +
    2. + ))} +
    + )} +
    + ) +} + +const routeLabels: Record = { + 'io to host': 'io → host', + 'host to io': 'host → io', + 'io to editor': 'io → editor', + 'host to server': 'host → server', +} + +const routeTones: Record = { + 'io to host': 'text-violet-700', + 'host to io': 'text-sky-700', + 'io to editor': 'text-gray-500', + 'host to server': 'text-orange-700', +} + +function describeMessage(message: PathMessage): string { + switch (message.type) { + case 'mutation': + return `${message.id} · proposes ${message.transactionId} · ${plural(message.patches.length, 'patch')}${message.final ? ' · final' : ''}` + case 'mutation sent': + return `${message.id} went out as ${message.transactionId}` + case 'mutation rejected': + return message.id + case 'transaction': + return `${message.transactionId} · ${message.previousRev ?? '∅'} → ${message.resultRev ?? '∅'} · ${plural(message.patches.length, 'patch')}` + case 'feed lost': + return '' + case 'load': + return message.route === 'host to io' + ? `at ${message.rev ?? 'no document'}` + : describeValue(message.value) + case 'resync': + return message.route === 'host to io' + ? [ + `at ${message.rev ?? 'no document'}`, + ...Object.entries(message.outcomes ?? {}).map( + ([mutationId, outcome]) => `${mutationId} ${outcome}`, + ), + ...(message.discardUnsent ? ['discarding unsent'] : []), + ].join(' · ') + : describeValue(message.value) + case 'apply': + return `${plural(message.patches.length, 'instruction')} · ${plural(message.underneath.length, 'patch')} underneath` + case 're-submit': + return `${message.transactionId} · ${describeAnswer(message.answer)}` + } +} + +function describeValue(value: Array | undefined): string { + return value === undefined ? 'no field' : plural(value.length, 'block') +} + +function describeAnswer( + answer: Extract['answer'], +): string { + switch (answer.type) { + case 'saved': + return "saved: it hadn't landed, and now has" + case 'duplicate': + return '409: it had landed already' + case 'failed': + return `failed with ${answer.status}` + } +} diff --git a/apps/io-playground/src/narration-log.tsx b/apps/io-playground/src/narration-log.tsx new file mode 100644 index 0000000000..98f55256f7 --- /dev/null +++ b/apps/io-playground/src/narration-log.tsx @@ -0,0 +1,47 @@ +import {useEffect, useRef} from 'react' +import type {NarrationEntry} from './narration' +import {Empty, WithCode} from './ui' + +export function NarrationLog({entries}: {entries: Array}) { + const endRef = useRef(null) + const sentenceCount = entries.reduce( + (count, entry) => count + entry.sentences.length, + 0, + ) + + useEffect(() => { + endRef.current?.scrollIntoView({block: 'end'}) + }, [sentenceCount]) + + return ( +
    +

    + what happened, step by step +

    + {entries.length === 0 ? ( + Nothing yet. Run a step to see what it does. + ) : ( +
    + {entries.map((entry, index) => ( +
    +

    + {entry.step} +

    +
      + {entry.sentences.map((sentence, sentenceIndex) => ( +
    • + +
    • + ))} +
    +
    + ))} +
    +
    + )} +
    + ) +} diff --git a/apps/io-playground/src/narration.ts b/apps/io-playground/src/narration.ts new file mode 100644 index 0000000000..c0f43da6ea --- /dev/null +++ b/apps/io-playground/src/narration.ts @@ -0,0 +1,811 @@ +import { + editorNames, + type MutationSnapshot, + type EditorName, + type EditorSnapshot, + type NetworkSnapshot, + type ServerSnapshot, + type TransactionSource, + type WorldSnapshot, +} from '@portabletext/io/testing' +import {plural} from './ui' + +type Patch = MutationSnapshot['patches'][number] + +type FeedItem = NetworkSnapshot['feeds'][EditorName][number] + +/** + * A transaction that reached an editor's host in a step: from the feed and + * forwarded, from the feed and dropped as covered by the last copy, or from + * the answer to the host's own save. + */ +type ArrivedTransaction = Pick< + FeedItem, + 'transactionId' | 'previousRev' | 'resultRev' | 'source' +> & {via: 'feed' | 'save reply' | 'dropped'} + +export type NarrationEntry = { + /** The step as written in Gherkin. */ + step: string + sentences: Array +} + +/** + * The narration for one step. A check that changes nothing says nothing, and + * an action that changes nothing says so. + */ +export function narrateStep({ + step, + isAction, + before, + after, +}: { + step: string + isAction: boolean + before: WorldSnapshot + after: WorldSnapshot +}): Array { + const sentences = narrate(before, after) + + if (sentences.length > 0) { + return [{step, sentences}] + } + + return isAction ? [{step, sentences: ['Nothing changed.']}] : [] +} + +/** + * One plain sentence per thing that changed between two snapshots of the + * same world. + */ +function narrate(before: WorldSnapshot, after: WorldSnapshot): Array { + if (!after.editors || !after.server || !after.network) { + return [] + } + + if (!before.editors || !before.server || !before.network) { + return [ + describeServerStart(after.server), + ...editorNames.map((name) => describeEditorStart(name, after)), + ] + } + + const sentences: Array = [] + + for (const name of editorNames) { + sentences.push( + ...narrateEditor({ + name, + before: before.editors[name], + after: after.editors[name], + beforeNetwork: before.network, + afterNetwork: after.network, + beforeDuplicates: before.server.duplicates.filter((duplicate) => + duplicate.mutations.some((mutation) => mutation.name === name), + ), + afterDuplicates: after.server.duplicates.filter((duplicate) => + duplicate.mutations.some((mutation) => mutation.name === name), + ), + afterServer: after.server, + }), + ) + } + + sentences.push( + ...narrateServer({ + before: before.server, + after: after.server, + beforeNetwork: before.network, + afterNetwork: after.network, + }), + ) + + if (after.network.now !== before.network.now) { + sentences.push( + `The clock moved ${(after.network.now - before.network.now) / 1000} s ahead to t = ${after.network.now / 1000} s.`, + ) + } + + return sentences +} + +function narrateEditor({ + name, + before, + after, + beforeNetwork, + afterNetwork, + beforeDuplicates, + afterDuplicates, + afterServer, +}: { + name: EditorName + before: EditorSnapshot + after: EditorSnapshot + beforeNetwork: NetworkSnapshot + afterNetwork: NetworkSnapshot + beforeDuplicates: ServerSnapshot['duplicates'] + afterDuplicates: ServerSnapshot['duplicates'] + afterServer: ServerSnapshot +}): Array { + const sentences: Array = [] + const newMessages = after.messages.slice(before.messages.length) + const newMutations = after.sentMutations.slice(before.sentMutations.length) + const explainedMutationNumbers = new Set() + const newEvents = after.events.slice(before.events.length) + const newErrors = newEvents.flatMap((event) => + event.type === 'error' ? [event] : [], + ) + const forwarded = new Set( + newMessages.flatMap((message) => + message.type === 'transaction' ? [message.transactionId] : [], + ), + ) + const deliveredItems: Array = [ + ...beforeNetwork.feeds[name] + .filter( + (item) => + !afterNetwork.feeds[name].some( + (candidate) => candidate.transactionId === item.transactionId, + ), + ) + .map((item) => ({ + ...item, + via: forwarded.has(item.transactionId) + ? ('feed' as const) + : ('dropped' as const), + })), + ...newMessages.flatMap((message) => { + const transaction = + message.type === 'transaction' && message.via === 'save reply' + ? afterServer.transactions.find( + (candidate) => candidate.id === message.transactionId, + ) + : undefined + + return transaction + ? [ + { + transactionId: transaction.id, + previousRev: transaction.previousRev, + resultRev: transaction.resultRev, + source: transaction.source, + via: 'save reply' as const, + }, + ] + : [] + }), + ] + const resyncMessage = newMessages.find( + (message) => message.route === 'host to io' && message.type === 'resync', + ) + const resyncTook = newMessages.some( + (message) => message.route === 'io to editor' && message.type === 'resync', + ) + const screenChanged = before.screen !== after.screen + const blocksChanged = + JSON.stringify(before.blocks) !== JSON.stringify(after.blocks) + let screenExplained = false + + if (before.status !== after.status) { + if (before.status === 'loading' && after.status === 'ready') { + screenExplained = true + sentences.push( + after.base.rev === null && after.base.textspec === null + ? `${name}'s first commit ended and it is ready, empty.` + : `${name}'s first commit ended and it is ready, showing \`${after.screen}\` from the server's copy at ${describeRev(after.base.rev)}.`, + ) + } else if (after.status === 'unmounted') { + sentences.push(`${name} closed and unmounted.`) + } else { + sentences.push(`${name} is now ${after.status}.`) + } + } else if ( + after.status === 'loading' && + newMessages.some(isLoadOfTheEditor) + ) { + screenExplained = true + sentences.push( + before.messages.some(isLoadOfTheEditor) + ? `${name} was loaded again before ready: the second load replaces the first, so it holds the server's copy at ${describeRev(after.base.rev)} (\`${after.screen}\`) and becomes ready with it when its first commit ends.` + : `${name} took the server's copy at ${describeRev(after.base.rev)} as its first content, and becomes ready with it when its first commit ends.`, + ) + } + + for (const reply of beforeNetwork.replies) { + if ( + reply.editor !== name || + afterNetwork.replies.some( + (candidate) => candidate.mutationId === reply.mutationId, + ) + ) { + continue + } + + if ( + after.rejected?.mutationNumber === reply.mutationNumber && + before.rejected?.mutationNumber !== reply.mutationNumber + ) { + sentences.push( + `${name}'s host got the ${reply.status} for mutation ${reply.mutationNumber} and reported the rejection, so ${name} sends nothing more until a resync.`, + ) + } else { + sentences.push( + `${name}'s host got the ${reply.status} for mutation ${reply.mutationNumber}.`, + ) + } + } + + for (const reply of afterNetwork.lostReplies) { + if ( + reply.editor === name && + !beforeNetwork.lostReplies.some( + (candidate) => candidate.mutationId === reply.mutationId, + ) + ) { + sentences.push( + `The save reply for ${name}'s mutation ${reply.mutationNumber} was lost: the server saved it, but ${name}'s host never heard back.`, + ) + } + } + + for (const reply of beforeNetwork.lostReplies) { + if ( + reply.editor !== name || + afterNetwork.lostReplies.some( + (candidate) => candidate.mutationId === reply.mutationId, + ) + ) { + continue + } + + const transactionId = + after.sentMutations[reply.mutationNumber - 1]?.transactionId ?? + reply.mutationId + + sentences.push( + afterDuplicates.length > beforeDuplicates.length + ? `${name}'s host retried mutation ${reply.mutationNumber} with the same transaction ID, ${transactionId}: the server already has it and refused the retry with a 409, so the mutation landed once.` + : `${name}'s host retried mutation ${reply.mutationNumber} with the same transaction ID, ${transactionId}.`, + ) + } + + for (const item of deliveredItems) { + if (item.via === 'dropped') { + sentences.push( + `${name}'s host dropped ${item.transactionId} on arrival: the copy it fetched for the last load or resync covers it already.`, + ) + continue + } + + screenExplained = true + const ownMutationNumbers = ownMutations(item.source, name) + const failed = newErrors.some( + (error) => error.transactionId === item.transactionId, + ) + const arrived = + item.via === 'save reply' + ? `${name}'s host forwarded ${item.transactionId} from the answer to its own save` + : `${item.transactionId} came back to ${name}` + + if (isOutOfStep(before)) { + sentences.push( + ownMutationNumbers.length > 0 + ? `${item.transactionId} came back to ${name} while out of step: it notes that ${describeMutationNumbers(ownMutationNumbers)} came back and applies nothing.` + : `${name} is out of step and ignored ${item.transactionId}.`, + ) + continue + } + + if (after.held.some((held) => held.transactionId === item.transactionId)) { + sentences.push( + `${name} held ${item.transactionId}: it doesn't connect to ${describeRev(before.base.rev)} (it starts at ${describeRev(item.previousRev)})${ + ownMutationNumbers.length > 0 + ? `, so ${describeMutationNumbers(ownMutationNumbers)} waits as a held echo` + : '' + }.`, + ) + continue + } + + if (failed) { + continue + } + + if (ownMutationNumbers.length > 0) { + const confirmed = ownMutationNumbers.filter( + (mutationNumber) => + after.inFlight?.mutationNumber !== mutationNumber && + !after.echoed.some( + (mutation) => mutation.mutationNumber === mutationNumber, + ), + ) + const followUp = newMutations.at(0) + const parts = [ + confirmed.length > 0 + ? `${describeMutationNumbers(confirmed)} confirmed` + : `${describeMutationNumbers(ownMutationNumbers)} not confirmed yet`, + ] + + if (followUp) { + explainedMutationNumbers.add(followUp.number) + parts.push( + `mutation ${followUp.number} went out with ${ + before.pending.length === 1 + ? 'the pending change' + : `the ${before.pending.length} pending changes` + }`, + ) + } + + if (blocksChanged) { + parts.push(`it shows \`${after.screen}\``) + } + + sentences.push(`${arrived}: ${parts.join(', ')}.`) + continue + } + + sentences.push( + blocksChanged + ? `${name} received ${item.transactionId} and shows \`${after.screen}\`.` + : `${name} received ${item.transactionId}: its base moved to ${describeRev(after.base.rev)} and the screen didn't change.`, + ) + } + + const released = before.held.filter( + (held) => + !after.held.some( + (candidate) => candidate.transactionId === held.transactionId, + ), + ) + if (deliveredItems.length > 0 && !isOutOfStep(after) && released.length > 0) { + const confirmedEchoes = before.echoed + .filter( + (mutation) => + !after.echoed.some( + (candidate) => candidate.mutationNumber === mutation.mutationNumber, + ), + ) + .map((mutation) => mutation.mutationNumber) + + sentences.push( + `${name} applied the held ${joinPhrases(released.map((held) => held.transactionId))} now that it connects${ + confirmedEchoes.length > 0 + ? `: ${describeMutationNumbers(confirmedEchoes)} confirmed` + : '' + }.`, + ) + } + + for (const message of newMessages) { + if (message.type === 'mutation sent') { + sentences.push( + `${name}'s host formed the request for mutation ${mutationNumberOf(after, message.id)} under its own transaction ID, ${message.transactionId}, and told io with \`mutation sent\`.`, + ) + } + + if (message.type === 're-submit' && resyncMessage) { + sentences.push( + `${name}'s host re-submitted the frozen request ${message.transactionId} to find out what became of the mutation in flight: ${ + message.answer.type === 'duplicate' + ? 'the server answered 409, the transaction ID exists, so the mutation had landed' + : message.answer.type === 'saved' + ? "the server saved it, so it hadn't landed before and has now" + : `it failed with ${message.answer.status}, so the mutation never landed` + }.`, + ) + } + } + + if (resyncMessage && !resyncTook) { + sentences.push(`${name} refused the resync.`) + } + + if (resyncTook) { + screenExplained = true + const followUp = newMutations.at(0) + const outcome = + resyncMessage?.type === 'resync' && before.inFlight + ? resyncMessage.outcomes?.[ + mutationIdOf(after, before.inFlight.mutationNumber) ?? '' + ] + : undefined + const parts = [ + `${name} took a fresh copy at ${describeRev(after.base.rev)}`, + ] + + if (before.inFlight && outcome !== undefined) { + parts.push( + outcome === 'applied' + ? `let go of mutation ${before.inFlight.mutationNumber}: it landed, so it is in the copy` + : `put mutation ${before.inFlight.mutationNumber} back with the unsent changes: it never landed`, + ) + } + + if (followUp) { + explainedMutationNumbers.add(followUp.number) + parts.push( + before.pending.length > 0 || outcome === 'not applied' + ? `re-applied the unsent changes, which went out as mutation ${followUp.number}` + : `repaired the copy to the floor and sent the repair as mutation ${followUp.number}`, + ) + } else if (before.pending.length > 0) { + parts.push(`dropped ${plural(before.pending.length, 'unsent change')}`) + } + + if (before.rejected) { + parts.push( + `let go of the rejected mutation ${before.rejected.mutationNumber}`, + ) + } + + if (isOutOfStep(before) && !isOutOfStep(after)) { + parts.push('is back in step') + } + + if (screenChanged) { + parts.push(`shows \`${after.screen}\``) + } + + sentences.push(`${joinPhrases(parts)}.`) + } + + if (screenChanged && !screenExplained) { + sentences.push( + blocksChanged + ? `${name} shows \`${after.screen}\`.` + : `${name} moved the caret: \`${after.screen}\`.`, + ) + } + + const newPending = after.pending.slice(before.pending.length) + + if (newPending.length > 0) { + sentences.push( + `${name} kept the change (${describePatches(newPending.flatMap((change) => change.patches))}) as pending, because ${ + after.inFlight + ? `mutation ${after.inFlight.mutationNumber} is still in flight` + : after.rejected + ? `mutation ${after.rejected.mutationNumber} was rejected` + : `it isn't ready` + }.`, + ) + } + + for (const mutation of newMutations) { + if (explainedMutationNumbers.has(mutation.number)) { + continue + } + + sentences.push( + `${name} sent mutation ${mutation.number}, proposing transaction ID ${mutation.transactionId}${ + mutation.final ? ', its final mutation' : '' + }: ${describePatches(mutation.patches)}.`, + ) + } + + if (!isOutOfStep(before) && isOutOfStep(after)) { + sentences.push( + `${name} is out of step: it stopped applying the feed until a resync.`, + ) + } + + for (const event of newEvents) { + if (event.type === 'error') { + sentences.push( + `${name} reported ${event.reason}${ + event.transactionId === undefined ? '' : ` on ${event.transactionId}` + }.`, + ) + } + + if (event.type === 'work dropped') { + sentences.push( + `${name} dropped ${plural(event.patchCount, 'patch')}: ${ + event.reason === 'no target' + ? event.patchCount === 1 + ? 'its target is gone' + : 'their targets are gone' + : event.reason === 'rejected' + ? 'the resync dropped the rejected mutation' + : event.reason === 'closed out of step' + ? 'it closed while out of step' + : 'it closed while sending was blocked' + }.`, + ) + } + + if (event.type === 'warning') { + sentences.push(`${name} warned: ${event.message}.`) + } + } + + if (!before.readOnly && after.readOnly) { + sentences.push( + `${name} became read-only: it refuses user changes and keeps applying the feed.`, + ) + } + + return sentences +} + +function narrateServer({ + before, + after, + beforeNetwork, + afterNetwork, +}: { + before: ServerSnapshot + after: ServerSnapshot + beforeNetwork: NetworkSnapshot + afterNetwork: NetworkSnapshot +}): Array { + const sentences: Array = [] + + for (const transaction of after.transactions.slice( + before.transactions.length, + )) { + const revisions = `${describeRev(transaction.previousRev)} → ${describeRev(transaction.resultRev)}` + + if (transaction.source.type === 'named') { + switch (transaction.source.name) { + case 'the other field': + sentences.push( + `Another field changed on the server as ${transaction.id}: ${revisions}, and this field stayed the same.`, + ) + break + case "the script's change": + sentences.push( + `A script set the whole field on the server as ${transaction.id}: ${revisions}, to ${after.value === null ? 'no field' : `\`${after.value}\``}.`, + ) + break + case "the script's corruption": + sentences.push( + `A script changed the field on the server as ${transaction.id}: ${revisions}, leaving a block the editor cannot hold.`, + ) + break + case 'the deletion': + sentences.push( + `The document was deleted on the server as ${transaction.id}: ${revisions}.`, + ) + break + case 'the recreation': + sentences.push( + `The document was recreated on the server as ${transaction.id}: ${revisions}, with ${after.value === null ? 'no field' : `\`${after.value}\``}.`, + ) + break + } + continue + } + + sentences.push( + `The server saved ${transaction.id} (${describeSource(transaction.source)}): ${revisions}${ + transaction.noop ? ' and nothing changed' : '' + }.`, + ) + } + + if ( + after.transactions.length === before.transactions.length && + JSON.stringify(after.blocks) !== JSON.stringify(before.blocks) + ) { + sentences.push( + `The server's copy changed without a transaction, still at ${describeRev(after.rev)}, so no feed hears of it: only a load or a resync fetches it.`, + ) + } + + for (const request of beforeNetwork.saveRequests) { + const failure = afterNetwork.replies.find( + (reply) => + reply.mutationId === request.mutationId && + !beforeNetwork.replies.some( + (candidate) => candidate.mutationId === request.mutationId, + ), + ) + + if (failure) { + sentences.push( + `The request for ${request.editor}'s mutation ${request.mutationNumber} failed with ${failure.status}, and the reply is on its way back.`, + ) + } + } + + return sentences +} + +function describeServerStart(server: ServerSnapshot): string { + if (server.rev === null) { + return 'The server has no document yet.' + } + + return server.value === null + ? `The server starts at ${server.rev} with no field.` + : `The server starts at ${server.rev} with ${server.value === '' ? 'an empty list' : `\`${server.value}\``}.` +} + +function describeEditorStart(name: EditorName, world: WorldSnapshot): string { + const editor = world.editors?.[name] + + if (!editor) { + return `${name} doesn't exist.` + } + + return editor.status === 'ready' + ? `${name} is ready and shows \`${editor.screen}\`.` + : `${name} is ${editor.status} and waits for its first load.` +} + +function isLoadOfTheEditor(message: EditorSnapshot['messages'][number]) { + return message.route === 'io to editor' && message.type === 'load' +} + +function isOutOfStep(editor: EditorSnapshot): boolean { + return editor.io.sync === 'out of step' +} + +function mutationNumberOf(editor: EditorSnapshot, mutationId: string): number { + return ( + editor.messages + .flatMap((message) => (message.type === 'mutation' ? [message.id] : [])) + .indexOf(mutationId) + 1 + ) +} + +function mutationIdOf( + editor: EditorSnapshot, + mutationNumber: number, +): string | undefined { + return editor.messages.flatMap((message) => + message.type === 'mutation' ? [message.id] : [], + )[mutationNumber - 1] +} + +function ownMutations( + source: TransactionSource, + name: EditorName, +): Array { + return source.type === 'mutations' + ? source.mutations + .filter((mutation) => mutation.name === name) + .map((mutation) => mutation.mutationNumber) + : [] +} + +function describeMutationNumbers(mutationNumbers: Array): string { + return mutationNumbers.length === 1 + ? `mutation ${mutationNumbers[0]}` + : `mutations ${joinPhrases(mutationNumbers.map(String))}` +} + +function describeRev(rev: string | null): string { + return rev ?? 'no document' +} + +export function describeSource(source: TransactionSource): string { + return source.type === 'named' + ? source.name + : source.mutations + .map( + (mutation) => + `${mutation.name}'s mutation ${mutation.mutationNumber}`, + ) + .join(' + ') +} + +export function isOwnFeedItem(item: FeedItem, name: EditorName): boolean { + return ownMutations(item.source, name).length > 0 +} + +/** + * The patches in words, where that's cheap, or else a count. + */ +export function describePatches(patches: Array): string { + if (patches.length === 0) { + return 'no patches' + } + + const phrases: Array = [] + + for (let index = 0; index < patches.length; index++) { + const patch = patches[index] + const nextPatch = patches[index + 1] + + if ( + patch.type === 'setIfMissing' && + patch.path.length === 0 && + nextPatch?.type === 'insert' + ) { + phrases.push('created the block') + index++ + continue + } + + const phrase = describePatch(patch) + + if (phrase === undefined) { + return plural(patches.length, 'patch') + } + + phrases.push(phrase) + } + + return phrases.length > 3 + ? plural(patches.length, 'patch') + : joinPhrases(phrases) +} + +function describePatch(patch: Patch): string | undefined { + const lastSegment = patch.path.at(-1) + + switch (patch.type) { + case 'set': + if (patch.path.length === 0) { + return 'replaced the whole field' + } + + return lastSegment === 'style' + ? `set the style to ${String(patch.value)}` + : undefined + case 'unset': + if (patch.path.length === 0) { + return 'emptied the field (a whole-field unset)' + } + + if (patch.path.length === 1) { + return 'deleted a block' + } + + return lastSegment === 'style' ? 'removed the style' : undefined + case 'insert': + return patch.items.length === 1 + ? 'inserted a block' + : `inserted ${patch.items.length} blocks` + case 'diffMatchPatch': + return describeTextDiff(patch.value) + default: + return undefined + } +} + +function describeTextDiff(diff: string): string | undefined { + const lines = diff.split('\n') + const added = decodeDiffText(lines, '+') + const removed = decodeDiffText(lines, '-') + + if (added === undefined || removed === undefined) { + return undefined + } + + if (added !== '' && removed === '') { + return `typed "${added}"` + } + + if (removed !== '' && added === '') { + return `deleted "${removed}"` + } + + return added === '' ? undefined : `replaced "${removed}" with "${added}"` +} + +export function decodeDiffText( + lines: Array, + sign: '+' | '-', +): string | undefined { + try { + return lines + .filter((line) => line.startsWith(sign)) + .map((line) => decodeURI(line.slice(1))) + .join('') + } catch { + return undefined + } +} + +function joinPhrases(phrases: Array): string { + if (phrases.length <= 1) { + return phrases.join('') + } + + return `${phrases.slice(0, -1).join(', ')} and ${phrases.at(-1)}` +} diff --git a/apps/io-playground/src/scenarios-tab.tsx b/apps/io-playground/src/scenarios-tab.tsx new file mode 100644 index 0000000000..5e034854eb --- /dev/null +++ b/apps/io-playground/src/scenarios-tab.tsx @@ -0,0 +1,320 @@ +import {createWorld, type World} from '@portabletext/io/testing' +import {useEffect, useRef, useState, type ReactNode} from 'react' +import {features} from './features' +import {runStep} from './gherkin' +import {narrateStep, type NarrationEntry} from './narration' +import {NarrationLog} from './narration-log' +import {Button} from './ui' + +type StepResult = {status: 'passed'} | {status: 'failed'; message: string} + +type ScenarioRun = { + featureIndex: number + scenarioIndex: number + world: World + results: Array + narration: Array + running: boolean + deliveryError: string | null +} + +export function useScenarioRunner() { + const [run, setRun] = useState(() => + freshRun({featureIndex: 0, scenarioIndex: 0}), + ) + const scenario = features[run.featureIndex].scenarios[run.scenarioIndex] + const failed = run.results.at(-1)?.status === 'failed' + const finished = failed || run.results.length === scenario.steps.length + + async function runSteps({all}: {all: boolean}) { + if (run.running || finished) { + return + } + + setRun((current) => ({...current, running: true})) + + const {world} = run + const results = [...run.results] + const narration = [...run.narration] + const actions = actionSteps(scenario.steps) + const publish = (running: boolean) => + setRun((current) => + current.world === world + ? { + ...current, + results: [...results], + narration: [...narration], + running, + } + : current, + ) + + for (const step of scenario.steps.slice(results.length)) { + const before = world.snapshot() + const narrateNow = () => + narration.push( + ...narrateStep({ + step: `${step.keyword} ${step.text}`, + isAction: actions[results.length - 1] ?? false, + before, + after: world.snapshot(), + }), + ) + + try { + await step.run(world) + results.push({status: 'passed'}) + narrateNow() + } catch (error) { + results.push({ + status: 'failed', + message: error instanceof Error ? error.message : String(error), + }) + narrateNow() + break + } + + if (!all) { + break + } + + publish(true) + await new Promise((resolve) => requestAnimationFrame(resolve)) + } + + publish(false) + } + + function deliver(text: string): boolean { + const {world} = run + const before = world.snapshot() + let deliveryError: string | null = null + + try { + runStep(world, {keyword: 'When', text}) + } catch (error) { + deliveryError = `When ${text}: ${error instanceof Error ? error.message : String(error)}` + } + + const entries = narrateStep({ + step: `When ${text}`, + isAction: true, + before, + after: world.snapshot(), + }) + + setRun((current) => + current.world === world + ? { + ...current, + deliveryError, + narration: [...current.narration, ...entries], + } + : current, + ) + + return deliveryError === null + } + + return { + world: run.world, + featureIndex: run.featureIndex, + scenarioIndex: run.scenarioIndex, + scenario, + results: run.results, + narration: run.narration, + running: run.running, + finished, + deliveryError: run.deliveryError, + deliver, + select: (selection: {featureIndex: number; scenarioIndex: number}) => + setRun(freshRun(selection)), + reset: () => + setRun((current) => + freshRun({ + featureIndex: current.featureIndex, + scenarioIndex: current.scenarioIndex, + }), + ), + nextStep: () => runSteps({all: false}), + runAll: () => runSteps({all: true}), + } +} + +export function ScenariosTab({ + runner, +}: { + runner: ReturnType +}) { + const {scenario, results, running, finished} = runner + + return ( +
    +
    + + + + + {finished ? ( + results.at(-1)?.status === 'failed' ? ( + failed + ) : ( + passed + ) + ) : null} +
    + + {scenario.knownRed ? ( +

    + Expected to fail: {scenario.knownRed} +

    + ) : null} + + {runner.deliveryError ? ( +

    {runner.deliveryError}

    + ) : null} + +
    +
      + {scenario.steps.map((step, index) => { + const result = results[index] + const current = index === results.length && !finished + + return ( + + {step.keyword}{' '} + {step.text} + {result?.status === 'failed' ? ( +
      + {result.message} +
      + ) : null} +
      + ) + })} +
    + +
    + +
    +
    +
    + ) +} + +const knownRedScenarios = features.flatMap((feature, featureIndex) => + feature.scenarios.flatMap((candidate, scenarioIndex) => + candidate.knownRed === undefined + ? [] + : [{featureIndex, scenarioIndex, name: candidate.name}], + ), +) + +function StepItem({ + current, + className, + children, +}: { + current: boolean + className: string + children: ReactNode +}) { + const element = useRef(null) + + useEffect(() => { + if (current) { + element.current?.scrollIntoView({block: 'nearest'}) + } + }, [current]) + + return ( +
  • + {children} +
  • + ) +} + +function freshRun(selection: { + featureIndex: number + scenarioIndex: number +}): ScenarioRun { + return { + ...selection, + world: createWorld(), + results: [], + narration: [], + running: false, + deliveryError: null, + } +} + +/** + * Whether each step is an action: `When`, or an `And` or `But` that + * continues one. + */ +function actionSteps(steps: Array<{keyword: string}>): Array { + let lastPrimaryKeyword = '' + + return steps.map((step) => { + if (step.keyword !== 'And' && step.keyword !== 'But') { + lastPrimaryKeyword = step.keyword + } + + return lastPrimaryKeyword === 'When' + }) +} diff --git a/apps/io-playground/src/server-panel.tsx b/apps/io-playground/src/server-panel.tsx new file mode 100644 index 0000000000..20765a2672 --- /dev/null +++ b/apps/io-playground/src/server-panel.tsx @@ -0,0 +1,363 @@ +import type { + EditorSnapshot, + NetworkSnapshot, + ServerSnapshot, +} from '@portabletext/io/testing' +import {useState} from 'react' +import type {Applicability} from './applicable' +import {floorRules} from './concepts' +import {useOpenDetails} from './drawers' +import {quoted} from './gherkin' +import {describeSource} from './narration' +import { + ActionButton, + Badge, + Button, + DetailsLink, + Empty, + InfoMark, + ItemList, + Label, + plural, + Revision, + RevisionStep, + Section, + TextInput, + TextspecValue, + useFlash, +} from './ui' + +type SaveRequest = NetworkSnapshot['saveRequests'][number] + +export function ServerPanel({ + server, + now, + advanceClock, + onStep, + selectedRequests, + onReceiveSelected, + editorA, + editorADeadFeed, + carriesServerCopy, + onToggleServerCopy, +}: { + server: ServerSnapshot | null + /** The virtual clock, in milliseconds. */ + now: number | undefined + advanceClock: Applicability + /** Present in free play: runs a `When` step and tells whether it succeeded. */ + onStep: ((text: string) => boolean) | undefined + /** Save requests picked to be received as one transaction. */ + selectedRequests: Array + onReceiveSelected: () => void + /** The editor the malformed-content doors lead to. */ + editorA: EditorSnapshot | undefined + editorADeadFeed: boolean + /** Whether each transaction reaches the hosts with the server's copy. */ + carriesServerCopy: boolean + /** Present in free play: restarts it with the listener sending the copy, or not. */ + onToggleServerCopy: ((on: boolean) => void) | undefined +}) { + const [recreatedAs, setRecreatedAs] = useState('B: bar') + const [scriptValue, setScriptValue] = useState('B: baz') + const keys = (server?.blocks ?? []).flatMap((block) => + typeof block === 'object' && + block !== null && + typeof block._key === 'string' + ? [block._key] + : [], + ) + const [chosenKey, setChosenKey] = useState(undefined) + const corruptedKey = + chosenKey !== undefined && keys.includes(chosenKey) + ? chosenKey + : keys.at(-1) + const [corruption, setCorruption] = useState(floorRules[0].phrase) + const doors = malformedDoors(editorA, editorADeadFeed, corruptedKey) + const flash = useFlash(JSON.stringify(server)) + const openDetails = useOpenDetails() + + return ( +
    +
    +

    Server

    + {server ? ( + + {' '} + + + ) : null} + {server && server.nextFailure !== null ? ( + + next request fails with {server.nextFailure} + + ) : null} +
    + + + + {server ? ( + <> +
    + {server.value === null ? ( + {server.rev === null ? 'no document' : 'no field'} + ) : ( + + )} +
    + +
    + {server.transactions.length === 0 ? ( + none + ) : ( + + {server.transactions.map((transaction) => ( +
  • + + openDetails({ + type: 'transaction', + transactionId: transaction.id, + }) + } + > + {transaction.id} + + + + · {describeSource(transaction.source)} ·{' '} + {plural(transaction.patchCount, 'patch')} + + {transaction.noop ? ( + no change + ) : null} +
  • + ))} +
    + )} +
    + + {onStep ? ( +
    +
    + + + + +
    +
    + + + t = {(now ?? 0) / 1000} s + +
    +
    + + +
    +
    + +
    +
    + ) : null} + + {onStep ? ( +
    · corrupt the stored copy, then pick a door} + > +
    + block + + +
    +
    + { + if ( + corruptedKey !== undefined && + onStep( + `the server's copy changes without a transaction so its block ${quoted(corruptedKey)} ${corruption}`, + ) + ) { + onStep( + editorA?.inFlight + ? `Editor A is resynced with the outcome of mutation ${editorA.inFlight.mutationNumber}` + : 'Editor A is resynced', + ) + } + }} + > + reload Editor A from the server + + { + if ( + corruptedKey !== undefined && + onStep( + `a script changes the server's block ${quoted(corruptedKey)} so it ${corruption}`, + ) + ) { + onStep("Editor A receives the script's corruption") + } + }} + > + deliver it as a transaction + +
    +
    + ) : null} + + ) : ( + No server yet + )} +
    + ) +} + +function malformedDoors( + editor: EditorSnapshot | undefined, + deadFeed: boolean, + key: string | undefined, +): {reload: Applicability; transaction: Applicability} { + if (key === undefined) { + const noBlock = { + enabled: false, + why: 'the server has no block with a key to corrupt', + } + + return {reload: noBlock, transaction: noBlock} + } + + if (editor?.status !== 'ready') { + const notReady = {enabled: false, why: "Editor A isn't ready"} + + return {reload: notReady, transaction: notReady} + } + + if (editor.io.sync === 'out of step') { + return { + reload: { + enabled: true, + why: "the server's copy changes without a transaction, and Editor A resyncs from it", + }, + transaction: { + enabled: false, + why: 'Editor A is out of step and ignores transactions: resync first', + }, + } + } + + return { + reload: { + enabled: true, + why: "the server's copy changes without a transaction, and Editor A resyncs from it: io repairs the copy and sends the repair as its next mutation", + }, + transaction: + editor.host === 'self-confirming' + ? { + enabled: false, + why: "Editor A's host has no listener, so a script's transaction never reaches it", + } + : deadFeed + ? { + enabled: false, + why: "Editor A's feed is dead: the transaction would never arrive", + } + : { + enabled: true, + why: 'a script corrupts the block as a transaction, and Editor A receives it: `error: invalid content`, out of step until a resync', + }, + } +} diff --git a/apps/io-playground/src/ui.tsx b/apps/io-playground/src/ui.tsx new file mode 100644 index 0000000000..e3861eed1f --- /dev/null +++ b/apps/io-playground/src/ui.tsx @@ -0,0 +1,344 @@ +import {useEffect, useRef, useState, type ReactNode} from 'react' +import type {Applicability} from './applicable' +import {definitionOf, revisionTitle, type ConceptName} from './concepts' + +const badgeTones = { + gray: 'bg-gray-200 text-gray-700', + green: 'bg-green-100 text-green-800', + amber: 'bg-amber-100 text-amber-800', + red: 'bg-red-100 text-red-800', + blue: 'bg-blue-100 text-blue-800', +} + +export function Badge({ + tone, + children, +}: { + tone: keyof typeof badgeTones + children: ReactNode +}) { + return ( + + {children} + + ) +} + +export function Section({ + title, + concept, + suffix, + className = '', + children, +}: { + title: string + concept?: ConceptName + /** Shown after the title and its info mark. */ + suffix?: ReactNode + className?: string + children: ReactNode +}) { + return ( +
    +

    + {title} + {concept ? : null} + {suffix ? {suffix} : null} +

    + {children} +
    + ) +} + +/** + * A small "i" after a label: the definition as a native tooltip, and as a + * popover on click. + */ +export function InfoMark({concept}: {concept: ConceptName}) { + const [position, setPosition] = useState<{ + left: number + top: number + } | null>(null) + const definition = definitionOf(concept) + + return ( + <> + + {position ? ( + + {concept}: {definition} + + ) : null} + + ) +} + +export function Label({ + concept, + children, +}: { + concept: ConceptName + children: ReactNode +}) { + return ( + + {children} + + + ) +} + +export function Revision({rev}: {rev: string | null}) { + return ( + + {rev ?? '∅'} + + ) +} + +export function RevisionStep({ + from, + to, +}: { + from: string | null + to: string | null +}) { + return ( + + → + + ) +} + +/** + * A textspec value that toggles the Portable Text blocks behind it. + */ +export function TextspecValue({ + textspec, + blocks, +}: { + textspec: string + blocks: unknown +}) { + const [open, setOpen] = useState(false) + + return ( +
    + + {open ? : null} +
    + ) +} + +export function JsonView({value}: {value: unknown}) { + return ( +
    +      {JSON.stringify(value, null, 2)}
    +    
    + ) +} + +export function Empty({children}: {children: ReactNode}) { + return

    {children}

    +} + +export function ItemList({children}: {children: ReactNode}) { + return
      {children}
    +} + +/** A text button that opens the details drawer. */ +export function DetailsLink({ + label, + onClick, + children, +}: { + /** The accessible name, like "Details of transaction A-1". */ + label: string + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + +export function Button({ + onClick, + disabled, + suggested, + title, + children, +}: { + onClick: () => void + disabled?: boolean + /** Rings the button when the state calls for it. */ + suggested?: boolean + title?: string + children: ReactNode +}) { + return ( + + ) +} + +/** A button whose state comes from the protocol's applicability rules. */ +export function ActionButton({ + applicability, + onClick, + children, +}: { + applicability: Applicability + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + +/** Prompts the state calls for, one per line. */ +export function Prompts({prompts}: {prompts: Array}) { + return prompts.length === 0 ? null : ( +
      + {prompts.map((prompt) => ( +
    • + {prompt} +
    • + ))} +
    + ) +} + +export function TextInput({ + value, + onChange, + placeholder, + width = 'w-28', +}: { + value: string + onChange: (value: string) => void + placeholder?: string + width?: string +}) { + return ( + onChange(event.target.value)} + placeholder={placeholder} + className={`rounded border border-gray-300 px-1.5 py-0.5 font-mono text-xs ${width}`} + /> + ) +} + +/** + * The background classes for a panel: a short highlight whenever its data + * changes. + */ +export function useFlash(signature: string): string { + const previousSignature = useRef(signature) + const [flashing, setFlashing] = useState(false) + + useEffect(() => { + if (previousSignature.current === signature) { + return + } + + previousSignature.current = signature + setFlashing(true) + const timeout = setTimeout(() => setFlashing(false), 700) + + return () => clearTimeout(timeout) + }, [signature]) + + return `transition-colors duration-500 ${flashing ? 'bg-yellow-100' : 'bg-gray-50'}` +} + +export function plural(count: number, noun: string): string { + if (count === 1) { + return `${count} ${noun}` + } + + return `${count} ${noun}${/(ch|sh|s|x)$/.test(noun) ? 'es' : 's'}` +} + +/** Renders the backticked parts of a sentence as code. */ +export function WithCode({text}: {text: string}) { + return ( + <> + {text.split('`').map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + )} + + ) +} diff --git a/apps/io-playground/src/vite-env.d.ts b/apps/io-playground/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/apps/io-playground/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/io-playground/src/work-dropped.test.ts b/apps/io-playground/src/work-dropped.test.ts new file mode 100644 index 0000000000..8883f9fa19 --- /dev/null +++ b/apps/io-playground/src/work-dropped.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, test} from 'vitest' +import {droppedText} from './work-dropped' + +describe(droppedText.name, () => { + test('a typed text, a set span text and an inserted block each give a line', () => { + expect( + droppedText([ + { + type: 'diffMatchPatch', + path: [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text'], + value: '@@ -1,3 +1,6 @@\n foo\n+bar\n', + }, + { + type: 'set', + path: [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text'], + value: 'baz', + }, + { + type: 'insert', + path: [{_key: 'k0'}], + position: 'after', + items: [ + { + _type: 'block', + _key: 'k2', + children: [ + {_type: 'span', _key: 'k3', text: 'foo', marks: []}, + {_type: 'span', _key: 'k4', text: 'bar', marks: []}, + ], + }, + ], + }, + ]), + ).toEqual('bar\nbaz\nfoobar') + }) + + test('a set of a whole block gives its text, and a style or a removal gives none', () => { + expect( + droppedText([ + { + type: 'set', + path: [{_key: 'k0'}], + value: { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + }, + {type: 'set', path: [{_key: 'k0'}, 'style'], value: 'h1'}, + {type: 'unset', path: [{_key: 'k0'}]}, + ]), + ).toEqual('foo') + }) +}) diff --git a/apps/io-playground/src/work-dropped.tsx b/apps/io-playground/src/work-dropped.tsx new file mode 100644 index 0000000000..068e325ba6 --- /dev/null +++ b/apps/io-playground/src/work-dropped.tsx @@ -0,0 +1,155 @@ +import type { + MutationSnapshot, + EditorName, + HeardEvent, +} from '@portabletext/io/testing' +import {useState} from 'react' +import {decodeDiffText, describePatches} from './narration' +import {Button, plural} from './ui' + +type Patch = MutationSnapshot['patches'][number] + +type WorkDroppedEvent = Extract + +/** + * One notice per `work dropped` the editor heard and the user hasn't + * dismissed, newest last: the reason, the text that was given up, and a way + * to copy it. + */ +export function WorkDroppedNotices({ + name, + events, + dismissed, + onDismiss, +}: { + name: EditorName + events: Array + /** Indexes into `events` the user dismissed. */ + dismissed: Array + onDismiss: (index: number) => void +}) { + const notices = events.flatMap((event, index) => + event.type === 'work dropped' && !dismissed.includes(index) + ? [{event, index}] + : [], + ) + + return notices.length === 0 ? null : ( +
      + {notices.map(({event, index}) => ( + onDismiss(index)} + /> + ))} +
    + ) +} + +function WorkDroppedNotice({ + event, + onDismiss, +}: { + event: WorkDroppedEvent + onDismiss: () => void +}) { + const [copied, setCopied] = useState(false) + const text = droppedText(event.patches) + + return ( +
  • +

    + + Unsaved work dropped: {event.reason}. + {' '} + {reasonExplanations[event.reason]} +

    + {text === '' ? ( +

    + {plural(event.patchCount, 'change')} with no text to copy:{' '} + {describePatches(event.patches)}. +

    + ) : ( +
    +          {text}
    +        
    + )} +
    + {text === '' ? null : ( + + )} + +
    +
  • + ) +} + +const reasonExplanations: Record = { + 'no target': + 'Another change removed what these edits were for, so they did nothing, on the server too once sent.', + 'rejected': + 'The server refused the mutation for good, and the resync let go of it.', + 'closed while blocked': + 'The editor closed while sending was blocked by a rejection, so these edits never went out.', + 'closed out of step': + 'The editor closed while it was out of step with the server, so these edits never went out.', +} + +/** + * The text the dropped patches carried, one line per patch that carries + * any: a span's text for a `set` of it, the typed text for a + * `diffMatchPatch`, and the text of each block or span an `insert` or a + * `set` of a whole node held. + */ +export function droppedText(patches: Array): string { + return patches.flatMap(patchText).join('\n') +} + +function patchText(patch: Patch): Array { + switch (patch.type) { + case 'insert': + return patch.items.flatMap(nodeText) + case 'set': + return typeof patch.value === 'string' + ? patch.path.at(-1) === 'text' + ? [patch.value] + : [] + : nodeText(patch.value) + case 'diffMatchPatch': { + const added = decodeDiffText(patch.value.split('\n'), '+') + + return added === undefined || added === '' ? [] : [added] + } + default: + return [] + } +} + +function nodeText(node: unknown): Array { + if (typeof node !== 'object' || node === null) { + return [] + } + + const children: unknown = Reflect.get(node, 'children') + + if (Array.isArray(children)) { + return [children.flatMap(nodeText).join('')] + } + + const text: unknown = Reflect.get(node, 'text') + + return typeof text === 'string' ? [text] : [] +} diff --git a/apps/io-playground/tsconfig.app.json b/apps/io-playground/tsconfig.app.json new file mode 100644 index 0000000000..15dcfb1a56 --- /dev/null +++ b/apps/io-playground/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/io-playground/tsconfig.json b/apps/io-playground/tsconfig.json new file mode 100644 index 0000000000..ea9d0cd825 --- /dev/null +++ b/apps/io-playground/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/apps/io-playground/tsconfig.node.json b/apps/io-playground/tsconfig.node.json new file mode 100644 index 0000000000..96df58ea82 --- /dev/null +++ b/apps/io-playground/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/io-playground/vite.config.ts b/apps/io-playground/vite.config.ts new file mode 100644 index 0000000000..89fe823ab1 --- /dev/null +++ b/apps/io-playground/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import {defineConfig} from 'vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], +}) diff --git a/package.json b/package.json index e1d0ad542f..3e9e2d0f4e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build:docs": "turbo build --filter=docs", "build:editor": "turbo build --filter=@portabletext/editor...", "build:example-basic": "turbo build --filter=example-basic...", + "build:io-playground": "turbo build --filter=io-playground...", "build:keyboard-shortcuts": "turbo build --filter=@portabletext/keyboard-shortcuts...", "build:markdown": "turbo build --filter=@portabletext/markdown...", "build:patches": "turbo build --filter=@portabletext/patches...", @@ -29,6 +30,7 @@ "dev:docs": "turbo dev --filter=docs", "dev:editor": "turbo dev --filter=@portabletext/editor...", "dev:example-basic": "turbo dev --filter=example-basic", + "dev:io-playground": "turbo dev --filter=io-playground", "dev:keyboard-shortcuts": "turbo dev --filter=@portabletext/keyboard-shortcuts...", "dev:playground": "turbo dev --filter=playground", "dev:plugin-emoji-picker": "turbo dev --filter=@portabletext/plugin-emoji-picker...", diff --git a/packages/io/README.md b/packages/io/README.md new file mode 100644 index 0000000000..0ede1d6a8a --- /dev/null +++ b/packages/io/README.md @@ -0,0 +1,170 @@ +# `@portabletext/io` + +> A model of the editor's I/O protocol, tested with Gherkin scenarios + +This package is private. It exists to prove the host contract before it lands in `@portabletext/editor`: the editor's side of the protocol and the host adapter are real, and everything around them is fake. + +## What's real and what's fake + +| Part | Where | Real or fake | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The editor's protocol logic: base, one mutation in flight, pending changes, held transactions, confirmation by echo, rejection, resync, load and status, key rules | `src/protocol/io.ts` | Real. Plain TypeScript, no React, no XState, no dependency on `@portabletext/editor`. It speaks to an editor only through `EditorForIo`, so it can move into the editor | +| The host adapter: saves each mutation under the transaction ID it proposes, so a transaction usually carries one mutation (or, shaped like Studio, folds mutations into one request and reports `mutation sent`, so its transaction can carry several), reports a permanent failure as `mutation rejected` and retries a transient one, forwards the feed, fetches the server copy for `load` and `resync`, and finds out whether a mutation landed by re-submitting it | `src/protocol/host.ts` | Real. The same job the SDK plugin and a simple app will do | +| Patches | `@portabletext/patches` | Real. The same shapes and the same `applyAll` the editor uses | +| Content Lake semantics for applying a mutation | `src/protocol/content-lake.ts` | Real rules, shared by the editor's base and the fake server: a patch whose target is gone is a no-op, a `set` through a primitive replaces it, a `set` replaces an object or a list with anything, a `diffMatchPatch` on anything but a string fails | +| State notation | `@portabletext/test` | Real. Fixtures and assertions are textspec | +| The editor and the user's actions | `src/fakes/document.ts` | Fake. Blocks, spans and a caret behind `EditorForIo`. Setting a style is a `set`, typing a `diffMatchPatch`, inserting and deleting blocks `insert` and `unset`, splitting a block at the caret a `diffMatchPatch` that cuts the caret's span plus an `insert` of the rest after it, as the editor's `insert.break` emits them, the placeholder becoming content `setIfMissing` plus `insert`, emptying the field `unset([])`. It applies `apply` patch by patch and moves the caret by each | +| The server | `src/fakes/server.ts` | Fake. One document with a revision counter. Records a transaction for every mutation it receives, changed or not, as Content Lake does, stores whatever the patches leave behind, malformed blocks included, and fails the next request with the status a step injects | +| The network | `src/fakes/network.ts` | Fake. Queues that only the test steps drain, and a virtual clock. Per world, it carries each transaction with the field as the server held it right after | + +## API + +`createIo` returns io as a store, shaped like the editor: `getSnapshot`, `subscribe`, `on` and `send`, and nothing else. + +```ts +type Io = { + getSnapshot: () => IoSnapshot + subscribe: ( + observer: + | { + next?: (snapshot: IoSnapshot) => void + error?: (error: unknown) => void + complete?: () => void + } + | ((snapshot: IoSnapshot) => void), + ) => {unsubscribe: () => void} + on: ( + type: TType, + listener: ( + event: IoEvent & (TType extends '*' ? unknown : {type: TType}), + ) => void, + ) => {unsubscribe: () => void} + send: (message: IoMessage) => void +} + +type IoSnapshot = { + context: { + status: 'loading' | 'ready' | 'unmounted' + sync: 'synced' | 'saving' | 'blocked' | 'out of step' + rev: string | undefined + inFlight: {id: string; transactionId: string} | undefined + pending: number + } +} +``` + +`getSnapshot` returns the same object until something in the snapshot changes, and `subscribe` calls `next` once after every change, so `useSyncExternalStore` and `useSelector` from `@xstate/react` work against io as they work against the editor. `status` is `'loading'` until the editor's `ready` and `'unmounted'` after its `closing` or the host's `close`. `sync` is `'saving'` while a mutation is in flight or changes are pending, `'blocked'` after a rejection and `'out of step'` after an error or a lost feed, both until the next resync. `rev` is the base's revision, `inFlight` the mutation in flight and the transaction ID it is saved under, and `pending` how many local changes wait to be sent. + +`on` listens to what io tells the host: `mutation` (a mutation to save), `error` (io is out of step until a resync), `work dropped` (the user's unsaved work io gave up on) and `warning` (a message for the host's log), or all of them with `'*'`. `send` takes what the host tells io: `load`, `transaction`, `mutation sent`, `mutation rejected`, `feed lost`, `resync` and `close`. `close` does what the editor's `closing` does: io sends the final mutation, or drops the pending changes while sending is blocked or io is out of step, and stops. + +### Dropped work + +`work dropped` carries the user's own patches io gave up on, each reported once, with a reason. `no target` covers pending changes whose target a transaction or a resync took away (they stay pending and go out as no-ops), and the editor's own patches that come back in its echo with no target in the base right before they applied: another writer's transaction landed first and took their target away, so the server applied them as no-ops and the words in them are gone. `closed while blocked` and `closed out of step` carry the pending changes a close drops, and `rejected` the rejected mutation a resync drops. + +### Transaction IDs + +Every mutation proposes the transaction ID it is saved under, from `createIo`'s `transactionIdGenerator`. The default is a random UUID (`crypto.randomUUID()` where the runtime has it, a version 4 UUID from `Math.random` otherwise), since the ID must be unique among every writer of the document. A host that saves a mutation as its own request uses the ID as it is, and a host that chooses its own names it with `mutation sent`. The world injects a deterministic generator per editor (`A-tk0`, `A-tk1` for Editor A), so the scenarios and tests can name transactions. + +## The editor seam + +`createIo` takes an editor as the structural type `EditorForIo`, two functions and nothing else: + +```ts +type EditorForIo = { + on: ( + type: TType, + listener: (event: EditorEventForIo & {type: TType}) => void, + ) => {unsubscribe: () => void} + send: (message: EditorMessageForIo) => void +} +``` + +io listens to `change` (a local one carries the action's patches, which io books as pending), `ready` (the end of the first commit) and `closing` (the moment to send the final mutation). It sends `load` and `resync` as whole values and `apply` for each transaction it applies. + +### `apply` + +`apply` carries keyed instructions for the editor's tree in `patches`, and the transaction's patches as they moved the base in `underneath`. io authors the instructions from its working copy (the base, then the mutation in flight, then pending changes) before and after the transaction, deciding per list (the block list or a block's `children`): + +1. The editor's own patches in its echo apply nothing: they are on screen already. +2. A list is lined up when the transaction inserts into it, removes from it or changes a key in it, and unlanded work (the mutation in flight or pending changes) also touched it, by inserting, removing or changing anything in its items. It is also lined up whenever the transaction changes a key in it. Every patch of the transaction on that list is replaced by one line-up against the new working copy, key by key: keyed `unset`s, keyed `insert`s next to a sibling the list has by then, and a `set` of an item that stays but differs. None of them is forwarded on its own, since a later patch can depend on an earlier one: an insert after a new key, or a block removed and inserted again elsewhere. Two inserts after the same block land in different orders on the two sides. +3. On a list that isn't lined up, another writer's patch on a place no unlanded work touched is forwarded as it is, so the editor can move the caret by it. A patch addressed by an index in the stored array is first addressed to that block in the editor, by its key (by its position in the editor when it has none), since the editor leaves out the stored blocks that aren't objects. +4. On a list that isn't lined up, a patch on a block unlanded work also touched becomes a `set` of the whole block from the new working copy. The server applied the editor's work after the patch, and the screen applied it before. + +A transaction that moved the base and left the working copy as it was comes with empty `patches`, for the editor's history. The instructions are for the editor's tree only and never reach the server. + +After every `apply` and every local change, the editor's tree equals io's working copy, the placeholder aside, with the stored blocks that aren't objects left out of the working copy. + +### Undo + +Undo is not part of io. The model's undo is a stand-in for the editor's history until that design is done, and it lives in `testing`: `withModelUndo(io, applyLocalEdit)` (`src/scenario/model-undo.ts`) wraps io with an undo ledger, `undo()` and `getUndoDepth()`. The ledger hears io's local changes, mutations, transactions and resyncs through io's internals, reads each undo step from the local change and the working copy, and computes the revert against the working copy at undo time. io reads nothing from it. The world wires `applyLocalEdit` to the fake document's user-action path (the same path typing takes): the document applies the revert as the user's own edit and reports it as a local `change`, or refuses it while read-only. + +### `transaction.value` + +A transaction may carry `value`, the field as the server holds it after the transaction, from the listener's result. io then takes it as the new base instead of applying the transaction's patches to the old one. The patches still travel: io checks them for duplicate keys and failures, authors `apply` from them, and matches them against its mutation for the echo check. io authors the instructions against the working copy the patches alone make, so they are the same with `value` or without, and lines up whatever `value` changed beyond the patches after them, key by key. + +`createWorld({serverCopyOnTransactions: true})`, or the step `Given transactions that carry the server's copy`, has the network deliver every transaction with the server's copy after it, and the host pass it on. The test runner runs every feature in both modes, a `describe` per mode. + +### Out of step + +After an `error`, whatever its reason, or `feed lost`, the editor is out of step until a resync. io keeps booking local changes as pending and keeps recognizing its own mutations when they come back, but sends no mutation: pending work written against a copy io no longer trusts could repeat a key the server has by now. The resync re-applies the pending changes to the fresh copy, re-keys what collides, and sends them. Closing while out of step sends nothing and emits `work dropped` with reason `closed out of step`, carrying the pending patches. + +### Keys + +A key io mints while repairing a received whole value (a missing or duplicate `_key` in `load` or `resync`) comes from the value's revision and the repaired node's index path: the 32-bit FNV-1a hash of the revision and the path segments joined by `/` (`r1/0`, `r1/0/children/1`), as eight hex digits. When the value already has that key, io hashes again with `#1`, `#2` and so on appended. Two editors repairing the same revision received the same value, so they hash the same inputs and the taken-key suffix resolves the same way: they mint the same keys, and their repairs agree. Keys for local inserts come from the editor's key generator, and the world gives each editor's generator its own prefix (`a-`, `b-`), so the two never mint the same key by accident. + +Keys collide among siblings only: the block list, or one block's `children`. Applied work is never re-keyed in place. A transaction that gives a list of the new base (taken from its `value` when it carries one) a key that a pending insert into the same list also inserts emits `error` with reason `duplicate key`, as one that collides with an insert into the same list in the mutation in flight or the rejected mutation does, and the editor is out of step. A span keyed like a block is no collision. The resync gives the pending insert a new key from the key generator while it re-applies the pending changes, so the caret, which stays with its key, may land in the other writer's block. + +### The floor + +The floor is the set of shapes the editor cannot hold at all, defined in `src/protocol/floor.ts`: a block that isn't an object, a block without `_key` or `_type`, a text block (`_type: 'block'`) whose `children` isn't a non-empty array of objects, a text block's child without `_key` or `_type`, and a span (`_type: 'span'`) whose `text` isn't a string. + +On `load` and `resync`, io repairs the received value to the floor before anything else, and books the repairs, with the key repairs, as the editor's own pending work, so they go out in the next mutation: a missing `_key` gets a repair key, a missing `_type` becomes `'block'` (`'span'` for a text block's child), a `text` that isn't a string becomes `''`, a text block's child that isn't an object is removed by index, and a `children` that isn't an array or holds no object becomes one empty span with a repair key. A block that isn't an object is left out of what the editor gets and never written to: while the stored field holds one, io turns the whole-field `unset` of a local edit that empties the field into keyed `unset`s of the blocks the editor shows. The repairs address the stored array: io maps each of the editor's block positions to its index there, and a repair addresses its block by that index when its key is missing or repeated, and by key otherwise. + +On a `transaction`, io checks the blocks the transaction changed in the base against the floor. A base below the floor emits `error` with reason `invalid content`, and the editor is out of step until a resync, which repairs it. + +## Layout + +``` +src/ + index.ts the real halves: createIo, the host, and the types they speak + testing.ts the fakes, the world, the step library and the runner + protocol/ + io.ts the protocol's editor side, speaking EditorForIo + apply.ts the keyed instructions io authors for the editor's tree + host.ts the model host (plain, folding, self-confirming), as a reference host + types.ts the host messages and events, and EditorForIo + content-lake.ts applyAll with Content Lake semantics, and hasTarget + floor.ts the floor, and the repairs that bring a received value up to it + nodes.ts keys, children and equality, shared by io and the model's undo + fakes/ + document.ts the fake editor, implementing EditorForIo + server.ts + network.ts + scenario/ + world.ts, steps.ts, parameter-types.ts, compile.ts, check.ts + model-undo.ts the model's undo, wrapped around io + test/ + scenarios.test.ts the feature files, run with racejar +``` + +`@portabletext/io` exports `index.ts` and `@portabletext/io/testing` exports `testing.ts`. + +## The scenarios + +`gherkin-spec/*.feature` holds the scenarios: two editors, one server, and what each editor shows and sends. Every state is textspec (`B: foo|`, `H1: foo`, `B: foo;;B: bar`, `B _key="k9": baz`). A check compares keys only when the expected notation names them, and the caret only when the expected notation has one. Textspec can't spell content below the floor, so steps name it instead: `the server's block "k1" has no key` (or `has no type`, `has children "oops"`, `has a span whose text is 42`, `is the string "oops"`) before anyone loads, and `a script changes the server's block "k1" so it ...` as a transaction. `the server has "..."` compares the server's blocks that are objects, and `the server has a block that is not an object` checks for the rest. + +The step vocabulary lives in `src/scenario/steps.ts`, and `src/scenario/world.ts` wires two editors, two hosts, one server and the network together. Each editor is a fake document with io attached. Happenings are `When` steps (a user types, the server receives a mutation, the feed delivers a transaction, the host resyncs). The user's actions drive the fake document directly, and undo goes to the model's undo wrapped around io. Checks are `Then` steps, and every check observes the editor from the outside: what it shows, what it has sent, what io told it, what listeners heard, what the server has. Every step also ends by checking that each editor's tree equals io's working copy, at the end of the step and right after every message and local change during it. + +The fake document satisfies `EditorForIo`, and the real editor is meant to satisfy it once it exposes `apply` and `closing`. Then the same feature files run against either by swapping what the world constructs for each editor: the fake document today, the real editor with io attached later. + +## Running + +```sh +pnpm --filter @portabletext/io test:unit +``` + +`compileScenarios` (`src/scenario/compile.ts`) compiles a feature file into steps that run one at a time against a world, outside any test framework, and `world.snapshot()` returns the whole world as plain data. + +## Not modeled + +Sending on a timer (a change is sent as soon as nothing is in flight), operations in `change` events (the model carries patches as a stand-in, and a text operation is a `diffMatchPatch` built at the offset the user acted at, so it keeps the position the saved patch loses), redo, selection beyond a caret in one block, and an above-floor object block (an image) in the fake document: its textspec access throws. diff --git a/packages/io/gherkin-spec/concurrent-edits.feature b/packages/io/gherkin-spec/concurrent-edits.feature new file mode 100644 index 0000000000..3348f2221c --- /dev/null +++ b/packages/io/gherkin-spec/concurrent-edits.feature @@ -0,0 +1,243 @@ +Feature: Concurrent edits + + Scenario: Two editors type into the same block at once, and both keep their words + Given the document is "B: foo bar|" + When the caret is put after "bar" + And "x" is typed + Then Editor A shows "B: foo barx|" + And Editor A has sent mutation 1 + When the caret is put after "foo" in Editor B + And "y" is typed in Editor B + Then Editor B shows "B: fooy| bar" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And the server receives Editor A's mutation 1 + Then the server has "B: fooy barx" + When Editor A receives Editor B's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A shows "B: fooy barx" + When Editor B's mutation 1 comes back + And Editor B receives Editor A's mutation 1 + Then Editor B shows "B: fooy| barx" + + Scenario: One editor deletes a repeated word while another types next to it, every screen converges, and the second copy is the one that goes + Given the document is "B: copy copy|" + When the caret is put after "copy " in Editor B + And "hi" is typed in Editor B + Then Editor B shows "B: copy hi|copy" + And Editor B has sent mutation 1 + When "copy" is deleted before the caret + Then Editor A shows "B: copy |" + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + And the server receives Editor B's mutation 1 + Then the server has "B: copy hi" + When Editor A's mutation 1 comes back + And Editor A receives Editor B's mutation 1 + Then Editor A shows "B: copy hi" + When Editor B receives Editor A's mutation 1 + And Editor B's mutation 1 comes back + Then Editor B shows "B: copy hi" + + Scenario: A script replaces the whole field while Editor A has unsent typing + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When a script sets the field to "B: bar" + Then the server has "B: bar" + When Editor A receives the script's change + Then Editor A shows "B: bar" + And Editor A has been warned + And Editor A has been told work was dropped + When the server receives Editor A's mutation 1 + Then the server has "B: bar" + When Editor A's mutation 1 comes back + Then Editor A shows "B: bar" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: bar" + When Editor A's mutation 2 comes back + Then Editor A shows "B: bar" + And Editor A has sent nothing new + + Scenario: The caret stays with its word while Editor B types before it + Given the document is "B: |foo bar" + When the caret is put after "foo" + And "y" is typed in Editor B + Then Editor B shows "B: y|foo bar" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A shows "B: yfoo| bar" + And Editor A's last apply carries the patches of Editor B's mutation 1 + + Scenario: The caret stays after the typing when its echo comes back folded with Editor B's text before it + Given hosts that fold mutations into shared requests + And the document is "B: |foo bar" + When the caret is put after "bar" + And "x" is typed + Then Editor A shows "B: foo barx|" + And Editor A has sent mutation 1 + When "baz " is typed in Editor B + Then Editor B shows "B: baz |foo bar" + And Editor B has sent mutation 1 + When the server receives Editor A's mutation 1 and Editor B's mutation 1 as one transaction + Then the server has "B: baz foo barx" + When Editor A's mutation 1 comes back + Then Editor A shows "B: baz foo barx|" + + Scenario: The caret stays after unsent typing when Editor B types before earlier typing in the same span + Given the document is "B: |foo bar baz" + When "qux " is typed + Then Editor A shows "B: qux |foo bar baz" + And Editor A has sent mutation 1 + When the caret is put after "bar" + And "x" is typed + Then Editor A shows "B: qux foo barx| baz" + And Editor A has sent nothing new + When "remote " is typed in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A shows "B: remote qux foo barx| baz" + + Scenario: Two editors insert after the same block, and Editor A shows the server's order from the moment Editor B's insert arrives + Given the document is "B: a|" + When the block "B: x" is inserted + Then Editor A shows "B: a;;B: x|" + And Editor A has sent mutation 1 + When the block "B: y" is inserted in Editor B + Then Editor B shows "B: a;;B: y|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A shows "B: a;;B: x|;;B: y" + When the server receives Editor A's mutation 1 + Then the server has "B: a;;B: x;;B: y" + When Editor A's mutation 1 comes back + Then Editor A shows "B: a;;B: x|;;B: y" + When Editor B's mutation 1 comes back + And Editor B receives Editor A's mutation 1 + Then Editor B shows "B: a;;B: x;;B: y|" + + Scenario: Editor B removes the block Editor A's unsent insert went after, the removal lands first, and both leave A's screen + Given the document is "B: a|;;B: b" + When "q" is typed + Then Editor A shows "B: aq|;;B: b" + And Editor A has sent mutation 1 + When the block "B: x" is inserted + Then Editor A shows "B: aq;;B: x|;;B: b" + And Editor A has sent nothing new + When the block "a" is deleted in Editor B + Then Editor B shows "B: |b" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A shows "B: |b" + And Editor A has been told work was dropped, with reason "no target" + When the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + And Editor A's mutation 2 comes back + Then the server has "B: b" + And Editor A shows "B: |b" + + Scenario: Editor B types into the block whose style Editor A changed, B's typing lands first, and A gets the whole block as the server will have it + Given the document is "B: foo|" + When the style is set to "h2" + Then Editor A shows "H2: foo|" + And Editor A has sent mutation 1 + When "x" is typed in Editor B + Then Editor B shows "B: foox|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A shows "H2: foox" + And Editor A's last apply sets the whole block "foox" + When the server receives Editor A's mutation 1 + Then the server has "H2: foox" + When Editor A's mutation 1 comes back + Then Editor A shows "H2: foox" + When Editor B's mutation 1 comes back + And Editor B receives Editor A's mutation 1 + Then Editor B shows "H2: foox|" + + Scenario: Two editors fill an empty field at the same moment and end with two blocks + Given the document is "B: foo|" + When the block "foo" is deleted + Then Editor A shows "B: |" + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has no field + When Editor A's mutation 1 comes back + And Editor B receives Editor A's mutation 1 + Then Editor B shows "B: |" + When "x" is typed + Then Editor A shows "B: x|" + And Editor A has sent mutation 2 + When "y" is typed in Editor B + Then Editor B shows "B: y|" + And Editor B has sent mutation 1 + When the server receives Editor A's mutation 2 + And the server receives Editor B's mutation 1 + Then the server has "B: y;;B: x" + When Editor A's mutation 2 comes back + And Editor A receives Editor B's mutation 1 + Then Editor A shows "B: y;;B: x|" + When Editor B receives Editor A's mutation 2 + And Editor B's mutation 1 comes back + Then Editor B shows "B: y|;;B: x" + + Scenario: Editor B deletes the block Editor A is typing into, the deletion lands first, and A's unsent typing is reported as dropped + Given the document is "B: foo|;;B: bar" + When "x" is typed + Then Editor A shows "B: foox|;;B: bar" + And Editor A has sent mutation 1 + When the caret is put after "bar" + And "y" is typed + Then Editor A shows "B: foox;;B: bary|" + And Editor A has sent nothing new + When the block "bar" is deleted in Editor B + Then Editor B shows "B: foo|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And the server receives Editor A's mutation 1 + Then the server has "B: foox" + When Editor A receives Editor B's mutation 1 + Then Editor A has been told work was dropped, with reason "no target" + And Editor A shows "B: foox" + When Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: foox" + When Editor A's mutation 2 comes back + Then Editor A shows "B: foox" + And Editor A is in step + And Editor A's sync is "synced" + + Scenario: Editor B splits the block Editor A types at the end of, the split lands first, and A's word lands at the end of the first block, where A's text diff finds its context + Given the document is "B: foobar|" + When "x" is typed + Then Editor A shows "B: foobarx|" + And Editor A has sent mutation 1 + When the caret is put after "foo" in Editor B + And the block is split at the caret in Editor B + Then Editor B shows "B: foo;;B: |bar" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And the server receives Editor A's mutation 1 + Then the server has "B: foox;;B: bar" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "B: foox|;;B: bar" + When Editor A's mutation 1 comes back + Then Editor A shows "B: foox|;;B: bar" + And Editor A's sync is "synced" + And Editor A is in step + When Editor B's mutation 1 comes back + And Editor B receives Editor A's mutation 1 + Then Editor B shows "B: foox;;B: |bar" diff --git a/packages/io/gherkin-spec/keys.feature b/packages/io/gherkin-spec/keys.feature new file mode 100644 index 0000000000..f3606cb19c --- /dev/null +++ b/packages/io/gherkin-spec/keys.feature @@ -0,0 +1,76 @@ +Feature: Keys + + Scenario: A received insert with a key the editor has already sent puts it out of step + Given the document is "B: foo|" + When the block "B _key="k9": baz" is inserted + Then Editor A shows "B: foo;;B _key="k9": baz" + And Editor A has sent mutation 1 + When the block "B _key="k9": bar" is inserted in Editor B + Then Editor B shows "B: foo;;B _key="k9": bar" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "B: foo;;B _key="k9": bar" + When Editor A receives Editor B's mutation 1 + Then Editor A reports that it is out of step + And Editor A shows "B: foo;;B _key="k9": baz" + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B _key="k9": baz;;B _key="k9": bar" + When Editor A's mutation 1 comes back + Then Editor A shows "B: foo;;B _key="k9": baz" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo;;B: baz;;B: bar" + And every block in Editor A has a unique key + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then every block on the server has a unique key + + Scenario: A received insert that reuses a key the base already has puts the editor out of step + Given the document is "B: foo|" + When the block "B _key="k9": bar" is inserted + Then Editor A shows "B: foo;;B _key="k9": bar" + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A has sent nothing new + When the block "B _key="k9": baz" is inserted in Editor B + Then Editor B shows "B: foo;;B _key="k9": baz" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "B: foo;;B _key="k9": baz;;B _key="k9": bar" + When Editor A receives Editor B's mutation 1 + Then Editor A reports that it is out of step + And Editor A shows "B: foo;;B _key="k9": bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo;;B: baz;;B: bar" + And every block in Editor A has a unique key + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then every block on the server has a unique key + + Scenario: An unsent insert that would reuse a key another editor used puts the editor out of step, and the resync gives it a new key + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When the block "B _key="k9": baz" is inserted + Then Editor A shows "B: foox;;B _key="k9": baz|" + And Editor A has sent nothing new + When the block "B _key="k9": bar" is inserted in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "B: foo;;B _key="k9": bar" + When Editor A receives Editor B's mutation 1 + Then Editor A reports that it is out of step + And Editor A shows "B: foox;;B _key="k9": baz|" + When the server receives Editor A's mutation 1 + Then the server has "B: foox;;B _key="k9": bar" + When Editor A is resynced with the outcome of mutation 1 + Then Editor A is in step + And Editor A shows "B: foox;;B: baz;;B _key="k9": bar|" + And every block in Editor A has a unique key + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: foox;;B: baz;;B _key="k9": bar" + And every block on the server has a unique key diff --git a/packages/io/gherkin-spec/lifecycle.feature b/packages/io/gherkin-spec/lifecycle.feature new file mode 100644 index 0000000000..45c28e0857 --- /dev/null +++ b/packages/io/gherkin-spec/lifecycle.feature @@ -0,0 +1,65 @@ +Feature: Lifecycle + + Background: + Given the document is "B: foo|" + + Scenario: Read-only stops typing but not sending + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When Editor A becomes read-only + And "z" is typed + Then Editor A shows "B: fooxy|" + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: fooxy" + + Scenario: Changes made just before the editor closes go out as a final mutation, even with a mutation still out + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A has sent nothing new + When Editor A is closed + Then Editor A has sent a final mutation + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When the server receives Editor A's final mutation + Then the server has "B: fooxy" + + Scenario: Closing while sending is blocked sends nothing, and the unsent changes are dropped with a warning + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When the server's next request fails with 404 + And the server receives Editor A's mutation 1 + And the save reply for Editor A's mutation 1 arrives + Then Editor A has sent nothing new + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When Editor A is closed + Then Editor A has sent nothing new + And Editor A has been warned + And the server has "B: foo" + + Scenario: Closing while out of step sends nothing, and the unsent changes are dropped + When "x" is typed + Then Editor A has sent mutation 1 + When Editor A's feed is lost + And "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A has sent nothing new + When Editor A is closed + Then Editor A has sent nothing new + And Editor A has been told work was dropped, with reason "closed out of step" + And the server has "B: foox" diff --git a/packages/io/gherkin-spec/listeners.feature b/packages/io/gherkin-spec/listeners.feature new file mode 100644 index 0000000000..28bf0ac38d --- /dev/null +++ b/packages/io/gherkin-spec/listeners.feature @@ -0,0 +1,19 @@ +Feature: Listeners + + Scenario: Listeners hear one change per user action and per received change, none for the first load or their own echoes, and only a local change carries patches + Given the document is "B: foo|" + Then Editor A has emitted no change + When "x" is typed + Then Editor A has emitted 1 change + And Editor A has sent mutation 1 + And Editor A's change 1 carries the patches of mutation 1 + When the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A has emitted 1 change + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A shows "H1: foox|" + And Editor A has emitted 2 changes + And Editor A's change 2 carries no patches diff --git a/packages/io/gherkin-spec/loading-and-empty.feature b/packages/io/gherkin-spec/loading-and-empty.feature new file mode 100644 index 0000000000..96cba2aaea --- /dev/null +++ b/packages/io/gherkin-spec/loading-and-empty.feature @@ -0,0 +1,62 @@ +Feature: Loading and empty + + Scenario: A load in the first commit makes the editor ready with the content, and no change + Given the server has "B: foo" + And the editors are in their first commit + When Editor A is loaded + Then Editor A's status is "loading" + When Editor A's first commit ends + Then Editor A's status is "ready" + And Editor A shows "B: foo" + And Editor A has emitted no change + + Scenario: An editor nobody loads is ready and empty when its first commit ends, and a resync fills it + Given the server has "B: foo" + And the editors are in their first commit + When Editor A's first commit ends + Then Editor A's status is "ready" + And Editor A shows "B: |" + When Editor A is resynced + Then Editor A shows "B: foo" + + Scenario: A load after the editor is ready is refused + Given the server has "B: foo" + And the editors are in their first commit + When Editor A's first commit ends + And Editor A is loaded again + Then the load was refused + And Editor A shows "B: |" + + Scenario Outline: An empty field shows the placeholder, and a lone empty block is real content (the server has ) + Given the server has + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B: |" + When "x" is typed + Then Editor A shows "B: x|" + And Editor A has sent mutation 1 + And Editor A's mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "" + When Editor A's mutation 1 comes back + Then Editor A has sent nothing new + + Examples: + | copy | patches | server | + | no document | creates the block | B: x | + | no field | creates the block | B: x | + | an empty list | creates the block | B: x | + | one empty block "b1" | does not create a block | B _key="b1": x | + + Scenario: Emptying the field sends a whole-field unset + Given the document is "B: foo|" + When the block "foo" is deleted + Then Editor A shows "B: |" + And Editor A has sent mutation 1 + And Editor A's mutation 1 empties the field + When the server receives Editor A's mutation 1 + Then the server has no field + When Editor A's mutation 1 comes back + Then Editor A shows "B: |" + And Editor A has sent nothing new diff --git a/packages/io/gherkin-spec/malformed-content.feature b/packages/io/gherkin-spec/malformed-content.feature new file mode 100644 index 0000000000..4a5e431262 --- /dev/null +++ b/packages/io/gherkin-spec/malformed-content.feature @@ -0,0 +1,210 @@ +Feature: Malformed content + + Scenario: Content received without keys is repaired, and the repair goes out in the next mutation + Given the server has "B _key="k1": foo" + And the server's block "k1" has no key + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B: foo" + And every block in Editor A has a unique key + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo" + And every block on the server has a unique key + When Editor A's mutation 1 comes back + Then Editor A has sent nothing new + + Scenario: A block received without a type is repaired as a text block, and the repair goes out in the next mutation + Given the server has "B _key="k1": foo;;B _key="k2": bar" + And the server's block "k2" has no type + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B: foo;;B: bar" + And Editor A has been warned + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B: bar" + When Editor A's mutation 1 comes back + Then Editor A is in step + And Editor A has sent nothing new + + Scenario: A text block received with children that aren't a list of objects gets one empty span, and the repair goes out in the next mutation + Given the server has "B _key="k1": foo;;B _key="k2": bar" + And the server's block "k2" has children "oops" + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B: foo;;B: " + And Editor A has been warned + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B: " + When Editor A's mutation 1 comes back + Then Editor A is in step + And Editor A has sent nothing new + + Scenario: A span received with a text that isn't a string gets an empty text, and the repair goes out in the next mutation + Given the server has "B _key="k1": foo;;B _key="k2": bar" + And the server's block "k2" has a span whose text is 42 + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B: foo;;B: " + And Editor A has been warned + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B: " + When Editor A's mutation 1 comes back + Then Editor A is in step + And Editor A has sent nothing new + + Scenario: A block received as a string is left out and never written to, and a repair by index still hits the stored block + Given the server has "B _key="k1": foo;;B _key="k2": bar" + And the server's block "k1" is the string "oops" + And the server's block "k2" has no key + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B _key="51491b98": |bar" + And Editor A has been warned + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B _key="51491b98": bar" + And the server has a block that is not an object + When Editor A's mutation 1 comes back + And "x" is typed + Then Editor A shows "B: x|bar" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: xbar" + And the server has a block that is not an object + + Scenario: Emptying a field that still holds a block that is not an object removes the blocks the editor shows and leaves the stored one + Given the server has "B _key="k1": foo;;B _key="k2": bar" + And the server's block "k1" is the string "oops" + And the editors are in their first commit + When Editor A is loaded + And Editor A's first commit ends + Then Editor A shows "B: |bar" + When the block "bar" is deleted + Then Editor A shows "B: |" + And Editor A has sent mutation 1 + And Editor A's mutation 1 does not empty the field + When the server receives Editor A's mutation 1 + Then the server has a block that is not an object + When Editor A's mutation 1 comes back + And "x" is typed + Then Editor A shows "B: x|" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: x" + And the server has a block that is not an object + + Scenario: The server's copy changes without a transaction before any transaction is recorded, and the resync repairs it + Given the document is "B _key="k1": foo|" + When the server's copy changes without a transaction so its block "k1" has no type + And Editor A is resynced + Then Editor A shows "B: foo" + And Editor A has been warned + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo" + + Scenario: A transaction that removes a block's key puts the editor out of step, and the resync repairs it + Given the document is "B _key="k1": foo|;;B _key="k2": bar" + When a script changes the server's block "k2" so it has no key + And Editor A receives the script's corruption + Then Editor A reports that it is out of step, with reason "invalid content" + And Editor A's sync is "out of step" + And Editor A shows "B: foo|;;B _key="k2": bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo|;;B _key="241fea5d": bar" + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B _key="241fea5d": bar" + + Scenario: A transaction that removes a block's type puts the editor out of step, and the resync repairs it + Given the document is "B _key="k1": foo|;;B _key="k2": bar" + When a script changes the server's block "k2" so it has no type + And Editor A receives the script's corruption + Then Editor A reports that it is out of step, with reason "invalid content" + And Editor A's sync is "out of step" + And Editor A shows "B: foo|;;B: bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo|;;B: bar" + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B: bar" + + Scenario: A transaction that sets a block's children to a string puts the editor out of step, and the resync repairs it + Given the document is "B _key="k1": foo|;;B _key="k2": bar" + When a script changes the server's block "k2" so it has children "oops" + And Editor A receives the script's corruption + Then Editor A reports that it is out of step, with reason "invalid content" + And Editor A's sync is "out of step" + And Editor A shows "B: foo|;;B: bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo|;;B: " + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B: " + + Scenario: A transaction that sets a span's text to a number puts the editor out of step, and the resync repairs it + Given the document is "B _key="k1": foo|;;B _key="k2": bar" + When a script changes the server's block "k2" so it has a span whose text is 42 + And Editor A receives the script's corruption + Then Editor A reports that it is out of step, with reason "invalid content" + And Editor A's sync is "out of step" + And Editor A shows "B: foo|;;B: bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo|;;B: " + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo;;B: " + + Scenario: A transaction that replaces a block with a string puts the editor out of step, and the resync leaves the string out + Given the document is "B _key="k1": foo|;;B _key="k2": bar" + When a script changes the server's block "k2" so it is the string "oops" + And Editor A receives the script's corruption + Then Editor A reports that it is out of step, with reason "invalid content" + And Editor A's sync is "out of step" + And Editor A shows "B: foo|;;B: bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foo|" + And Editor A has sent nothing new + And the server has "B: foo" + And the server has a block that is not an object + + Scenario: Two editors repairing the same missing key of the same revision mint the same key + Given the server has "B _key="k1": foo" + And the server's block "k1" has no key + And the editors are in their first commit + When Editor A is loaded + And Editor B is loaded + And Editor A's first commit ends + And Editor B's first commit ends + Then Editor A shows "B _key="52491d2b": foo" + And Editor B shows "B _key="52491d2b": foo" + And Editor A has sent mutation 1 + And Editor B has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B _key="52491d2b": foo" + When the server receives Editor B's mutation 1 + Then the server has "B _key="52491d2b": foo" + When Editor A's mutation 1 comes back + And Editor A receives Editor B's mutation 1 + And Editor B receives Editor A's mutation 1 + And Editor B's mutation 1 comes back + Then Editor A is in step + And Editor B is in step + And Editor A shows "B _key="52491d2b": foo" + And Editor B shows "B _key="52491d2b": foo" + And Editor A has sent nothing new + And Editor B has sent nothing new diff --git a/packages/io/gherkin-spec/other-editors.feature b/packages/io/gherkin-spec/other-editors.feature new file mode 100644 index 0000000000..09cf52af8f --- /dev/null +++ b/packages/io/gherkin-spec/other-editors.feature @@ -0,0 +1,160 @@ +Feature: Other editors + + Scenario: Another editor's change to a different block lands without disturbing sent or unsent changes + Given the document is "B: foo|;;B: bar" + When "x" is typed + Then Editor A shows "B: foox|;;B: bar" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "B: fooxy|;;B: bar" + And Editor A has sent nothing new + When the caret is put after "bar" in Editor B + And "z" is typed in Editor B + Then Editor B shows "B: foo;;B: barz|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "B: foo;;B: barz" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "B: fooxy|;;B: barz" + And Editor A has sent nothing new + When the server receives Editor A's mutation 1 + Then the server has "B: foox;;B: barz" + When Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: fooxy;;B: barz" + + Scenario Outline: Two editors set the same block's style at once: the last one the server saved wins on every screen (the server saves then ) + Given the document is "B: foo|" + When the style is set to "h2" + Then Editor A shows "H2: foo|" + And Editor A has sent mutation 1 + When the style is set to "h1" in Editor B + Then Editor B shows "H1: foo|" + And Editor B has sent mutation 1 + When the server receives + And the server receives + Then the server has "" + When + Then Editor A shows "" + When + Then Editor A shows "|" + And Editor A has sent nothing new + + Examples: + | first save | second save | server | first | after first | second | + | Editor B's mutation 1 | Editor A's mutation 1 | H2: foo | Editor A receives Editor B's mutation 1 | H2: foo\| | Editor A's mutation 1 comes back | + | Editor A's mutation 1 | Editor B's mutation 1 | H1: foo | Editor A's mutation 1 comes back | H2: foo\| | Editor A receives Editor B's mutation 1 | + + Scenario: A host that folds two mutations into one request names its transaction for each, and each mutation is confirmed + Given hosts that fold mutations into shared requests + And the document is "B: foo|;;B: bar" + When "x" is typed + Then Editor A shows "B: foox|;;B: bar" + And Editor A has sent mutation 1 + When the caret is put after "bar" in Editor B + And "y" is typed in Editor B + Then Editor B shows "B: foo;;B: bary|" + And Editor B has sent mutation 1 + When "z" is typed + And "w" is typed in Editor B + Then Editor A has sent nothing new + And Editor B has sent nothing new + When the server receives Editor A's mutation 1 and Editor B's mutation 1 as one transaction + Then the server has "B: foox;;B: bary" + And Editor A's host has named the transaction for mutation 1 + And Editor B's host has named the transaction for mutation 1 + When Editor A's mutation 1 comes back + Then Editor A shows "B: fooxz|;;B: bary" + And Editor A has sent mutation 2 + When Editor B's mutation 1 comes back + Then Editor B shows "B: foox;;B: baryw|" + And Editor B has sent mutation 2 + + Scenario: The editor's own echo applies nothing, and a transaction that also carries another editor's mutation applies only that mutation + Given hosts that fold mutations into shared requests + And the document is "B: foo|;;B: bar" + When "x" is typed + Then Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A shows "B: foox|;;B: bar" + And Editor A's last apply carries no patches + And Editor A's last apply has Editor A's mutation 1 underneath + When "y" is typed + Then Editor A has sent mutation 2 + When the caret is put after "bar" in Editor B + And "z" is typed in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor A's mutation 2 and Editor B's mutation 1 as one transaction + And Editor A's mutation 2 comes back + Then Editor A shows "B: fooxy|;;B: barz" + And Editor A's last apply carries the patches of Editor B's mutation 1 + + Scenario: Undo reverts only this editor's changes, and a resync clears the undo history + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + And Editor A's mutation 2 comes back + Then Editor A has sent nothing new + When the style is set to "h1" in Editor B + Then Editor B shows "H1: foo|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "H1: fooxy" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "H1: fooxy|" + When undo is performed + Then Editor A shows "H1: foox|" + And Editor A has sent mutation 3 + When the server receives Editor A's mutation 3 + Then the server has "H1: foox" + When Editor A's mutation 3 comes back + And Editor A is resynced + And undo is performed + Then Editor A shows "H1: foox|" + And Editor A has sent nothing new + + # Its Given turns on the server's copy, so it runs with values in the "patches only" mode too + Scenario: A transaction that carries the server's copy gives the editor its base, with a change the patches don't carry + Given transactions that carry the server's copy + And the document is "B _key="k1": foo|;;B _key="k2": bar" + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And the server's copy changes without a transaction so its block "k2" has a span whose text is 42 + And Editor A receives Editor B's mutation 1 + Then Editor A reports that it is out of step, with reason "invalid content" + And Editor A shows "B: foo|;;B: bar" + When Editor A is resynced + Then Editor A is in step + And Editor A shows "H1: foo|;;B: " + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "H1: foo;;B: " + + Scenario: Editor B removes the block Editor A typed into, the removal lands first, and A's echo comes back as a no-op that A reports as dropped + Given the document is "B: foo|;;B: bar" + When "x" is typed + Then Editor A shows "B: foox|;;B: bar" + And Editor A has sent mutation 1 + When the block "foo" is deleted in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And the server receives Editor A's mutation 1 + Then the server has "B: bar" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "B: bar" + When Editor A's mutation 1 comes back + Then Editor A has been told work was dropped, with reason "no target" + And Editor A shows "B: bar" + And Editor A's sync is "synced" + And the server has "B: bar" diff --git a/packages/io/gherkin-spec/out-of-step-and-resync.feature b/packages/io/gherkin-spec/out-of-step-and-resync.feature new file mode 100644 index 0000000000..3a4f55fa80 --- /dev/null +++ b/packages/io/gherkin-spec/out-of-step-and-resync.feature @@ -0,0 +1,267 @@ +Feature: Out of step and resync + + Scenario: A transaction that skips ahead is held until the missing one arrives + Given the document is "B: foo|" + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "H1: foo" + When Editor B's mutation 1 comes back + Then Editor B has sent nothing new + When the style is set to "h2" in Editor B + Then Editor B has sent mutation 2 + When the server receives Editor B's mutation 2 + Then the server has "H2: foo" + When Editor A receives Editor B's mutation 2 + Then Editor A shows "B: foo|" + And Editor A is in step + When Editor A receives Editor B's mutation 1 + Then Editor A shows "H2: foo|" + And Editor A is in step + + Scenario: A transaction that skips ahead and never connects puts the editor out of step until it's resynced + Given the document is "B: foo|" + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor B's mutation 1 comes back + And the style is set to "h2" in Editor B + Then Editor B has sent mutation 2 + When the server receives Editor B's mutation 2 + Then the server has "H2: foo" + When Editor A receives Editor B's mutation 2 + Then Editor A shows "B: foo|" + When the wait for the missing transaction runs out + Then Editor A reports that it is out of step + And Editor A's sync is "out of step" + And Editor A shows "B: foo|" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "B: foo|" + When Editor A is resynced + Then Editor A shows "H2: foo|" + And Editor A is in step + + Scenario: A transaction the resync copy already covers is dropped by the host + Given the document is "B: foo|" + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "H1: foo" + When Editor A is resynced + Then Editor A shows "H1: foo|" + When Editor A receives Editor B's mutation 1 + And the wait for the missing transaction runs out + Then Editor A is in step + And Editor A shows "H1: foo|" + + Scenario: A transaction that touches only another field moves the revision and changes nothing + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + And Editor A has emitted 1 change + When another field of the document is changed on the server + And Editor A receives the other field's change + Then Editor A shows "B: foox|" + And Editor A has emitted 1 change + And Editor A is in step + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "H1: foo" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "H1: foox|" + And Editor A is in step + + Scenario: A change for content the editor no longer has does nothing, as on the server + Given the document is "B: foo|;;B: bar" + When the block "bar" is deleted + Then Editor A shows "B: foo|" + And Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foo" + When Editor A's mutation 1 comes back + Then Editor A has sent nothing new + When the caret is put after "bar" in Editor B + And "y" is typed in Editor B + Then Editor B shows "B: foo;;B: bary|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "B: foo" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "B: foo|" + And Editor A is in step + When Editor B receives Editor A's mutation 1 + Then Editor B shows "B: foo" + When Editor B's mutation 1 comes back + Then Editor B shows "B: foo" + And Editor B has sent nothing new + And Editor B is in step + + Scenario: A resync requested before the mutation in flight has an outcome is refused + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When Editor A is resynced + Then the resync is refused + And Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + + Scenario: A deleted and recreated document doesn't put the editor out of step + Given the document is "B: foo|" + When the document is deleted + Then the server has no document + When Editor A receives the deletion + Then Editor A shows "B: |" + And Editor A is in step + When the document is recreated as "B: bar" + Then the server has "B: bar" + When Editor A receives the recreation + Then Editor A shows "B: bar" + And Editor A is in step + + Scenario: An echo that skips ahead is held, confirms the mutation, and keeps the typing on screen + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + When the style is set to "h1" in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And the server receives Editor A's mutation 1 + Then the server has "H1: foox" + When Editor A's mutation 1 comes back + Then Editor A shows "B: foox|" + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent mutation 2 + When Editor A receives Editor B's mutation 1 + Then Editor A shows "H1: fooxy|" + And Editor A is in step + + Scenario: The feed is lost while a mutation is in flight, and the mutation had landed + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When Editor A's feed is lost + Then Editor A has been warned + And Editor A's sync is "out of step" + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When Editor A is resynced with the outcome of mutation 1 + Then Editor A shows "B: fooxy|" + And Editor A has sent mutation 2 + And Editor A's sync is "saving" + When Editor A's mutation 1 comes back + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When the server receives Editor A's mutation 2 + Then the server has "B: fooxy" + When Editor A's mutation 2 comes back + Then Editor A shows "B: fooxy|" + And Editor A is in step + And Editor A's sync is "synced" + + Scenario: The feed is lost while a mutation is in flight, and the mutation had not landed + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When Editor A's feed is lost + And "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent nothing new + When the server's next request fails with 404 + And the server receives Editor A's mutation 1 + Then the server has "B: foo" + When the server's next request fails with 404 + And Editor A is resynced with the outcome of mutation 1 + Then Editor A shows "B: fooxy|" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: fooxy" + When Editor A's mutation 2 comes back + Then Editor A shows "B: fooxy|" + And Editor A is in step + And Editor A's sync is "synced" + + Scenario: An editor out of step sends nothing, even after its mutation comes back, until the resync re-keys its unsent insert + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the block "B _key="k9": baz" is inserted + Then Editor A shows "B: foox;;B _key="k9": baz|" + And Editor A has sent nothing new + When the block "B _key="k9": bar" is inserted in Editor B + Then Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + And Editor A receives Editor B's mutation 1 + Then Editor A reports that it is out of step, with reason "duplicate key" + When the server receives Editor A's mutation 1 + Then the server has "B: foox;;B _key="k9": bar" + When Editor A's mutation 1 comes back + Then Editor A has sent nothing new + And Editor A's sync is "out of step" + When "y" is typed + Then Editor A shows "B: foox;;B _key="k9": bazy|" + And Editor A has sent nothing new + When Editor A is resynced + Then Editor A is in step + And Editor A shows "B: foox;;B: bazy;;B _key="k9": bar|" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: foox;;B: bazy;;B _key="k9": bar" + And every block on the server has a unique key + + Scenario: A host that rewrites a keyed removal as a whole-field unset is caught when the echo comes back + Given the document is "B: foo|;;B: bar" + When the block "bar" is deleted + Then Editor A shows "B: foo|" + And Editor A has sent mutation 1 + When the host rewrites Editor A's mutation 1 as a whole-field unset + Then the server has no field + When Editor A's mutation 1 comes back + Then Editor A reports that it is out of step + And Editor A's sync is "out of step" + And Editor A shows "B: foo|" + When Editor A is resynced + Then Editor A shows "B: |" + And Editor A is in step + And Editor A's sync is "synced" + + Scenario: The quiet document: the feed dies before the echo, and the host finds the mutation had landed by re-submitting it + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When Editor A's feed is lost + And Editor A is resynced with the outcome of mutation 1 + Then the retry of Editor A's mutation 1 was refused as a duplicate + And Editor A is in step + And Editor A's sync is "synced" + And Editor A shows "B: foox|" + And the server has "B: foox" + And the server has saved Editor A's mutation 1 once + + # known red: no stalled state + @skip + Scenario: The quiet document, and the host never says the feed is lost: the user is told saving has stalled + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When 60 seconds pass + Then Editor A has been warned + And Editor A's sync is "stalled" diff --git a/packages/io/gherkin-spec/sending-and-confirming.feature b/packages/io/gherkin-spec/sending-and-confirming.feature new file mode 100644 index 0000000000..fc7af2cb53 --- /dev/null +++ b/packages/io/gherkin-spec/sending-and-confirming.feature @@ -0,0 +1,152 @@ +Feature: Sending and confirming + + Scenario: Queued changes wait for the mutation's echo + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent mutation 1 + And Editor A's sync is "saving" + When "y" is typed + And "z" is typed + Then Editor A shows "B: fooxyz|" + And Editor A has sent nothing new + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + And Editor A has sent nothing new + When Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: fooxyz" + When Editor A's mutation 2 comes back + Then Editor A has sent nothing new + And Editor A's sync is "synced" + + Scenario: A host that saves each mutation under the transaction ID it proposes never names a transaction, and each mutation is confirmed + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When "y" is typed + And the server receives Editor A's mutation 1 + And Editor A's mutation 1 comes back + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + And Editor A's mutation 2 comes back + Then Editor A shows "B: fooxy|" + And Editor A's sync is "synced" + And the server has "B: fooxy" + And the server saved Editor A's mutation 1 under the transaction ID it proposed + And the server saved Editor A's mutation 2 under the transaction ID it proposed + And Editor A's host has not named a transaction + + Scenario: A mutation that changes nothing on the server still comes back, and is confirmed like any other + Given the document is "B: foo|" + When the style is set to "h1" in Editor B + Then Editor B shows "H1: foo|" + And Editor B has sent mutation 1 + When the server receives Editor B's mutation 1 + Then the server has "H1: foo" + And Editor A shows "B: foo|" + When the style is set to "h1" + Then Editor A shows "H1: foo|" + And Editor A has sent mutation 1 + When "x" is typed + Then Editor A shows "H1: foox|" + And Editor A has sent nothing new + When the server receives Editor A's mutation 1 + Then the server has "H1: foo" + When Editor A receives Editor B's mutation 1 + Then Editor A shows "H1: foox|" + And Editor A has sent nothing new + When Editor A's mutation 1 comes back + Then Editor A shows "H1: foox|" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "H1: foox" + + Scenario: A rejected mutation stops sending until a resync, which keeps the unsent changes + Given the document is "B: foo|" + When the style is set to "h1" + Then Editor A shows "H1: foo|" + And Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "H1: fooy|" + And Editor A has sent nothing new + When the server's next request fails with 400 + And the server receives Editor A's mutation 1 + Then the server has "B: foo" + When the save reply for Editor A's mutation 1 arrives + Then Editor A has sent nothing new + And Editor A shows "H1: fooy|" + And Editor A's sync is "blocked" + When "z" is typed + Then Editor A shows "H1: fooyz|" + And Editor A has sent nothing new + When Editor A is resynced + Then Editor A shows "B: fooyz|" + And Editor A has been told work was dropped, with reason "rejected" + And Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then the server has "B: fooyz" + + Scenario: Loading the saved version discards the unsent changes, because the user asked for it + Given the document is "B: foo|" + When the style is set to "h1" + Then Editor A has sent mutation 1 + When "y" is typed + Then Editor A shows "H1: fooy|" + When the server's next request fails with 403 + And the server receives Editor A's mutation 1 + And the save reply for Editor A's mutation 1 arrives + Then Editor A has sent nothing new + When Editor A is resynced, discarding unsent changes + Then Editor A shows "B: foo|" + And Editor A has sent nothing new + + Scenario: A lost save reply is retried with the same transaction ID, and the mutation lands once + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then the server has "B: foox" + When the save reply for Editor A's mutation 1 is lost + And Editor A's mutation 1 is retried + Then the retry of Editor A's mutation 1 was refused as a duplicate + And the server has "B: foox" + And Editor A has sent nothing new + When Editor A's mutation 1 comes back + Then Editor A shows "B: foox|" + And Editor A's sync is "synced" + And the server has saved Editor A's mutation 1 once + + Scenario: A request that fails with a 503 is retried with the same transaction ID, and the mutation lands once + Given the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the server's next request fails with 503 + And the server receives Editor A's mutation 1 + Then the server has "B: foo" + When the save reply for Editor A's mutation 1 arrives + Then the server has "B: foox" + And Editor A's sync is "saving" + And Editor A has sent nothing new + When Editor A's mutation 1 comes back + Then Editor A shows "B: foox|" + And Editor A's sync is "synced" + And the server has saved Editor A's mutation 1 once + And the server saved Editor A's mutation 1 under the transaction ID it proposed + + Scenario: A host with no listener and one writer confirms each mutation itself with the transaction its save answers with + Given hosts that confirm each mutation themselves + And the document is "B: foo|" + When "x" is typed + Then Editor A has sent mutation 1 + When the server receives Editor A's mutation 1 + Then Editor A is in step + And Editor A's sync is "synced" + When "y" is typed + Then Editor A has sent mutation 2 + When the server receives Editor A's mutation 2 + Then Editor A is in step + And Editor A's sync is "synced" + And Editor A shows "B: fooxy|" + And the server has "B: fooxy" diff --git a/packages/io/package.json b/packages/io/package.json new file mode 100644 index 0000000000..c8dd6f21b2 --- /dev/null +++ b/packages/io/package.json @@ -0,0 +1,42 @@ +{ + "name": "@portabletext/io", + "version": "0.0.0", + "private": true, + "description": "A model of the editor I/O protocol, tested with Gherkin scenarios", + "license": "MIT", + "author": "Sanity.io ", + "type": "module", + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./testing": "./src/testing.ts", + "./gherkin-spec/*": "./gherkin-spec/*", + "./package.json": "./package.json" + }, + "scripts": { + "check:types": "tsc", + "check:types:watch": "tsc --watch", + "clean": "del .turbo && del node_modules", + "test:unit": "vitest --run --project unit", + "test:unit:watch": "vitest --project unit" + }, + "dependencies": { + "@cucumber/gherkin": "^41.0.0", + "@cucumber/messages": "^34.0.1", + "@portabletext/patches": "workspace:^", + "@portabletext/schema": "workspace:^", + "@portabletext/test": "workspace:^", + "@sanity/diff-match-patch": "catalog:", + "@textspec/notation": "^1.0.2", + "racejar": "workspace:^" + }, + "devDependencies": { + "@sanity/tsconfig": "catalog:tooling", + "typescript": "catalog:tooling", + "vite": "catalog:tooling", + "vitest": "catalog:tooling" + }, + "engines": { + "node": ">=22.12" + } +} diff --git a/packages/io/src/fakes/document.test.ts b/packages/io/src/fakes/document.test.ts new file mode 100644 index 0000000000..1c0b048694 --- /dev/null +++ b/packages/io/src/fakes/document.test.ts @@ -0,0 +1,1247 @@ +import { + applyAll, + diffMatchPatch, + insert, + set, + setIfMissing, + unset, +} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {textEditPatch} from '../protocol/text-edits' +import type {EditorEventForIo} from '../protocol/types' +import { + comparableTextspec, + createFakeDocument, + createsBlock, + emptiesField, + formatTextspec, + parseTextspec, + type FakeDocument, +} from './document' + +describe(parseTextspec.name, () => { + test('reads blocks, styles and the caret', () => { + const keyGenerator = createTestKeyGenerator() + + expect(parseTextspec({keyGenerator}, 'B: foo;;H1: ba|r')).toEqual({ + value: [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + style: 'h1', + }, + ], + caret: {blockKey: 'k2', offset: 2}, + }) + }) + + test('reads named keys and no caret', () => { + const keyGenerator = createTestKeyGenerator() + + expect(parseTextspec({keyGenerator}, 'B _key="k9": baz')).toEqual({ + value: [ + { + _type: 'block', + _key: 'k9', + children: [{_type: 'span', _key: 'k0', text: 'baz', marks: []}], + style: 'normal', + }, + ], + caret: undefined, + }) + }) +}) + +describe(createFakeDocument.name, () => { + test('writes back the notation it was built from', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;H2: ba|r;;H1: '), + ) + + expect(document.toTextspec()).toEqual('B: foo;;H2: ba|r;;H1: ') + }) + + test('writes keys on request', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B _key="k9": baz;;H1: fo|o'), + ) + + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="k9": baz;;H1 _key="k1": fo|o', + ) + }) + + test('puts the caret at the start of the first block when none is given', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + {value: parseTextspec({keyGenerator}, 'B: foo;;B: bar').value}, + ) + + expect(document.getCaret()).toEqual({blockKey: 'k0', offset: 0}) + expect(document.toTextspec()).toEqual('B: |foo;;B: bar') + }) + + test('setting a style sets it on the caret block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: ba|r'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [set('h1', [{_key: 'k2'}, 'style'])] + + document.setStyle('h1') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec()).toEqual('B: foo;;H1: ba|r') + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('setting the h3 style works, and the notation reads and writes it', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + + document.setStyle('h3') + + expect(document.toTextspec()).toEqual('H3: foo|') + expect(parseTextspec({keyGenerator}, 'H3: foo')).toEqual({ + value: [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'foo', marks: []}], + style: 'h3', + }, + ], + caret: undefined, + }) + expect(formatTextspec(document.getValue())).toEqual('H3: foo') + }) + + test('setting an unknown style throws', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + + expect(() => document.setStyle('h9')).toThrow('Unknown style "h9"') + }) + + test('typing inserts at the caret and sends a text diff', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fo|o;;B: bar'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [ + diffMatchPatch('foo', 'foxyo', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ] + + document.type('xy') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec()).toEqual('B: foxy|o;;B: bar') + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('deleting before the caret removes the text that ends at the caret and sends a text diff', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo bar|;;B: baz'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [ + diffMatchPatch('foo bar', 'foo', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ] + + document.deleteBeforeCaret(' bar') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec()).toEqual('B: foo|;;B: baz') + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('deleting text that is not right before the caret throws and changes nothing', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo| bar'), + ) + + expect(() => document.deleteBeforeCaret('bar')).toThrow( + 'Expected "bar" right before the caret, found "foo"', + ) + expect(() => document.deleteBeforeCaret('xfoo')).toThrow( + 'Expected "xfoo" right before the caret, found "foo"', + ) + expect(() => document.deleteBeforeCaret('')).toThrow( + 'Expected "" right before the caret, found "foo"', + ) + expect(document.toTextspec()).toEqual('B: foo| bar') + }) + + test('the caret is put after text found in one block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|;;B: bar'), + ) + + document.putCaretAfter('ba') + + expect(document.getCaret()).toEqual({blockKey: 'k2', offset: 2}) + expect(document.toTextspec()).toEqual('B: foo;;B: ba|r') + }) + + test('putting the caret after ambiguous or missing text throws', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|;;B: foobar;;B: baba'), + ) + + expect(() => document.putCaretAfter('foo')).toThrow( + 'Expected "foo" to occur once, found it 2 times', + ) + expect(() => document.putCaretAfter('ba')).toThrow( + 'Expected "ba" to occur once, found it 3 times', + ) + expect(() => document.putCaretAfter('baz')).toThrow( + 'Expected "baz" to occur once, found it 0 times', + ) + }) + + test('an inserted block keeps its named key and goes after the caret block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|;;B: bar'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [ + insert( + [ + { + _type: 'block', + _key: 'k9', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ] + + document.insertBlock('B _key="k9": baz') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="k0": foo;;B _key="k9": baz|;;B _key="k2": bar', + ) + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('an inserted block without a named key gets a generated one', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + const events = listen(document) + + const patches = [ + insert( + [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'baz', marks: []}], + style: 'h2', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ] + + document.insertBlock('H2: baz') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec()).toEqual('B: foo;;H2: baz|') + }) + + test("splitting at the caret cuts the caret's span and inserts the rest after the block, the span's key kept, as the editor's `insert.break` does", () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|bar;;B: baz'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [ + diffMatchPatch('foobar', 'foo', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + insert( + [ + { + _type: 'block', + _key: 'k4', + children: [{_type: 'span', _key: 'k1', text: 'bar', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ] + + document.splitAtCaret() + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="k0": foo;;B _key="k4": |bar;;B _key="k2": baz', + ) + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('deleting the caret block moves the caret to the end of the previous block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: ba|r;;B: baz'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [unset([{_key: 'k2'}])] + + document.deleteBlock('bar') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec()).toEqual('B: foo|;;B: baz') + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('deleting the first block records that it had no previous sibling', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fo|o;;B: bar'), + ) + const events = listen(document) + const before = document.getValue() + + const patches = [unset([{_key: 'k0'}])] + + document.deleteBlock('foo') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec()).toEqual('B: |bar') + expect(applyAll(before, patches)).toEqual(document.getValue()) + }) + + test('deleting another block leaves the caret where it is', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fo|o;;B: bar'), + ) + + document.deleteBlock('bar') + + expect(document.toTextspec()).toEqual('B: fo|o') + }) + + test('deleting a block with ambiguous or missing text throws', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|;;B: foo'), + ) + + expect(() => document.deleteBlock('foo')).toThrow( + 'Expected one block with the text "foo", found 2', + ) + expect(() => document.deleteBlock('bar')).toThrow( + 'Expected one block with the text "bar", found 0', + ) + }) + + test('an inserted block whose key a sibling has gets a new key', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B _key="k9": foo|'), + ) + const events = listen(document) + + const patches = [ + insert( + [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k1', text: 'bar', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k9'}], + ), + ] + + document.insertBlock('B _key="k9": bar') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="k9": foo;;B _key="k2": bar|', + ) + }) + + test('new content keeps the caret in its block, clamped to the text', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: bar|'), + ) + + document.send({ + type: 'resync', + value: parseTextspec({keyGenerator}, 'B _key="k0": foox;;B _key="k2": b') + .value, + }) + + expect(document.toTextspec()).toEqual('B: foox;;B: b|') + }) + + test('new content without the caret block puts the caret at the start', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: bar|'), + ) + + document.send({ + type: 'resync', + value: parseTextspec({keyGenerator}, 'B _key="k0": foo').value, + }) + + expect(document.toTextspec()).toEqual('B: |foo') + }) +}) + +describe('the placeholder', () => { + test('an empty field shows one empty block that is not content yet', () => { + const keyGenerator = createTestKeyGenerator() + + for (const value of [undefined, []]) { + const document = createReadyDocument({keyGenerator}, {value}) + const placeholderKey = document.getPlaceholderKey() + + expect(document.toTextspec()).toEqual('B: |') + expect(document.getValue()).toEqual([ + { + _type: 'block', + _key: placeholderKey, + style: 'normal', + markDefs: [], + children: [ + { + _type: 'span', + _key: value === undefined ? 'k1' : 'k3', + text: '', + marks: [], + }, + ], + }, + ]) + expect(placeholderKey).toEqual(value === undefined ? 'k0' : 'k2') + } + }) + + test('the first keystroke creates the field and the block in the same mutation', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument({keyGenerator}, {value: undefined}) + const events = listen(document) + + const firstPatches = [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + diffMatchPatch('', 'x', [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text']), + ] + + document.type('x') + + expect(events.splice(0)).toEqual([ + { + type: 'change', + origin: 'local', + operations: firstPatches, + patches: firstPatches, + }, + ]) + expect(createsBlock(firstPatches)).toEqual(true) + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(document.toTextspec()).toEqual('B: x|') + expect(applyAll(undefined, firstPatches)).toEqual(document.getValue()) + + const secondPatches = [ + diffMatchPatch('x', 'xy', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ] + + document.type('y') + + expect(events.splice(0)).toEqual([ + { + type: 'change', + origin: 'local', + operations: secondPatches, + patches: secondPatches, + }, + ]) + expect(createsBlock(secondPatches)).toEqual(false) + }) + + test('setting a style on the placeholder creates the block first', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument({keyGenerator}, {value: []}) + const events = listen(document) + + const patches = [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + set('h1', [{_key: 'k0'}, 'style']), + ] + + document.setStyle('h1') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(applyAll([], patches)).toEqual(document.getValue()) + }) + + test('inserting a block after the placeholder creates the placeholder first', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument({keyGenerator}, {value: undefined}) + const events = listen(document) + + const patches = [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + insert( + [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'foo', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ] + + document.insertBlock('B: foo') + + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(applyAll(undefined, patches)).toEqual(document.getValue()) + }) + + test('a lone empty block from the host is content, and typing sends only the text change', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B _key="b1": '), + ) + const events = listen(document) + + const patches = [ + diffMatchPatch('', 'x', [{_key: 'b1'}, 'children', {_key: 'k0'}, 'text']), + ] + + document.type('x') + + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(events.splice(0)).toEqual([ + {type: 'change', origin: 'local', operations: patches, patches: patches}, + ]) + expect(createsBlock(patches)).toEqual(false) + }) + + test('deleting the last block empties the field and shows a fresh placeholder', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + const events = listen(document) + const before = document.getValue() + + const deletePatches = [unset([{_key: 'k0'}]), unset([])] + + document.deleteBlock('foo') + + expect(events.splice(0)).toEqual([ + { + type: 'change', + origin: 'local', + operations: deletePatches, + patches: deletePatches, + }, + ]) + expect(emptiesField(deletePatches)).toEqual(true) + expect(document.getPlaceholderKey()).toEqual('k2') + expect(document.toTextspec({keys: true})).toEqual('B _key="k2": |') + expect(applyAll(before, deletePatches)).toEqual(undefined) + + document.type('x') + + const [typeChange] = events.splice(0) + + expect( + typeChange?.type === 'change' && typeChange.origin === 'local' + ? [createsBlock(typeChange.patches), emptiesField(typeChange.patches)] + : undefined, + ).toEqual([true, false]) + }) + + test('deleting the placeholder sends nothing', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument({keyGenerator}, {value: undefined}) + + const events = listen(document) + + document.deleteBlock('') + + expect(events).toEqual([]) + expect(document.getPlaceholderKey()).toEqual('k0') + }) + + test('the placeholder survives new empty content, and new content replaces it', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument({keyGenerator}, {value: undefined}) + + document.send({type: 'resync', value: []}) + + expect(document.getPlaceholderKey()).toEqual('k0') + + document.send({ + type: 'resync', + value: parseTextspec({keyGenerator}, 'B: foo').value, + }) + + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(document.toTextspec({keys: true})).toEqual('B _key="k2": |foo') + + document.send({type: 'resync', value: undefined}) + + expect(document.getPlaceholderKey()).toEqual('k4') + expect(document.toTextspec({keys: true})).toEqual('B _key="k4": |') + }) +}) + +describe('the editor seam', () => { + test('a user action emits a local change that carries its patches', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + const textPatch = diffMatchPatch('foo', 'foox', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]) + const events = listen(document) + + document.type('x') + document.putCaretAfter('f') + + expect(events).toEqual([ + { + type: 'change', + origin: 'local', + operations: [textPatch], + patches: [textPatch], + }, + ]) + }) + + test('typing or deleting a repeated word names its offset in the operation, while the patch is computed from the text before and after', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: |foofoo'), + ) + const textPath = [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text'] + const events = listen(document) + + document.type('foo') + document.deleteBeforeCaret('foo') + + expect(events).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'diffMatchPatch', + path: textPath, + value: '@@ -1,6 +1,9 @@\n+foo\n foofoo\n', + }, + ], + patches: [diffMatchPatch('foofoo', 'foofoofoo', textPath)], + }, + { + type: 'change', + origin: 'local', + operations: [ + { + type: 'diffMatchPatch', + path: textPath, + value: '@@ -1,9 +1,6 @@\n-foo\n foofoo\n', + }, + ], + patches: [diffMatchPatch('foofoofoo', 'foofoo', textPath)], + }, + ]) + }) + + test('a listener hears only the event type it listens to, until it unsubscribes', () => { + const keyGenerator = createTestKeyGenerator() + const document = createFakeDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + const events: Array = [] + const subscription = document.on('change', (event) => events.push(event)) + + document.mount() + document.type('x') + subscription.unsubscribe() + document.type('y') + document.close() + + expect(events).toEqual([ + { + type: 'change', + origin: 'local', + operations: [ + diffMatchPatch('foo', 'foox', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + patches: [ + diffMatchPatch('foo', 'foox', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + }, + ]) + }) + + test('`ready` fires when the first commit ends and `closing` just before the editor stops, and actions after that do nothing', () => { + const keyGenerator = createTestKeyGenerator() + const document = createFakeDocument({keyGenerator}, {value: undefined}) + const events: Array<{type: string; status: string}> = [] + + for (const type of ['ready', 'closing'] as const) { + document.on(type, (event) => { + events.push({type: event.type, status: document.getStatus()}) + }) + } + document.send({ + type: 'load', + value: parseTextspec({keyGenerator}, 'B: foo').value, + }) + document.mount() + document.close() + document.type('x') + document.putCaretAfter('f') + document.close() + + expect(events).toEqual([ + {type: 'ready', status: 'ready'}, + {type: 'closing', status: 'ready'}, + ]) + expect(document.getStatus()).toEqual('unmounted') + expect(document.toTextspec()).toEqual('B: |foo') + }) + + test('a load in the first commit replaces the content without a change, and a load after it throws', () => { + const keyGenerator = createTestKeyGenerator() + const document = createFakeDocument({keyGenerator}, {value: undefined}) + const events = listen(document) + + document.send({ + type: 'load', + value: parseTextspec({keyGenerator}, 'B: foo').value, + }) + document.send({ + type: 'load', + value: parseTextspec({keyGenerator}, 'B: bar').value, + }) + document.mount() + + expect(() => + document.send({ + type: 'load', + value: parseTextspec({keyGenerator}, 'B: baz').value, + }), + ).toThrow( + '`load` is only accepted in the first commit, before the editor is ready', + ) + expect(events).toEqual([{type: 'ready'}]) + expect(document.toTextspec()).toEqual('B: |bar') + }) + + test('a resync or an apply emits a remote change only when it changed the content, and the caret stays in the block with its key', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: ba|r'), + ) + const {value} = parseTextspec( + {keyGenerator}, + 'B _key="k2": bar;;B _key="k0": foo', + ) + const events = listen(document) + + document.send({type: 'resync', value: [...document.getValue()]}) + document.send({type: 'resync', value}) + document.send({type: 'apply', patches: [set(value, [])], underneath: []}) + document.send({ + type: 'apply', + patches: [], + underneath: [set('h1', [{_key: 'k0'}, 'style'])], + }) + + expect(events).toEqual([ + {type: 'change', origin: 'remote', operations: [set(value, [])]}, + ]) + expect(document.toTextspec()).toEqual('B: ba|r;;B: foo') + }) + + test('an apply that gives the caret block a new key keeps the caret in that block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: ba|r'), + ) + const [bazBlock] = parseTextspec({keyGenerator}, 'B _key="k2": baz').value + + document.send({ + type: 'apply', + patches: [ + set('k9', [{_key: 'k2'}, '_key']), + insert([bazBlock], 'after', [{_key: 'k9'}]), + ], + underneath: [], + }) + + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="k0": foo;;B _key="k9": ba|r;;B _key="k2": baz', + ) + }) + + test('Scenario: an apply moves the caret past text a patch on its span inserts before it, leaves it for an insert, and takes it to the previous block when its block goes', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: bar;;B: foo|foo'), + ) + const [bazBlock] = parseTextspec({keyGenerator}, 'B: baz').value + const textPath = [{_key: 'k2'}, 'children', {_key: 'k3'}, 'text'] + + document.send({ + type: 'apply', + patches: [ + textEditPatch('foofoo', {offset: 0, insertText: 'foo'}, textPath), + ], + underneath: [], + }) + + expect(document.toTextspec()).toEqual('B: bar;;B: foofoo|foo') + + document.send({ + type: 'apply', + patches: [set('xfoofoofoo', textPath)], + underneath: [], + }) + + expect(document.toTextspec()).toEqual('B: bar;;B: xfoofoo|foo') + + document.send({ + type: 'apply', + patches: [insert([bazBlock], 'after', [{_key: 'k0'}])], + underneath: [], + }) + + expect(document.toTextspec()).toEqual('B: bar;;B: baz;;B: xfoofoo|foo') + + document.send({ + type: 'apply', + patches: [unset([{_key: 'k2'}])], + underneath: [], + }) + + expect(document.toTextspec()).toEqual('B: bar;;B: baz|') + }) + + test('Scenario: an apply that inserts a span before the caret span leaves the caret in its span at its offset', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + + document.send({ + type: 'apply', + patches: [ + insert( + [{_type: 'span', _key: 'k9', text: 'bar ', marks: []}], + 'before', + [{_key: 'k0'}, 'children', {_key: 'k1'}], + ), + ], + underneath: [], + }) + + expect(document.getValue()).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [ + {_type: 'span', _key: 'k9', text: 'bar ', marks: []}, + {_type: 'span', _key: 'k1', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ]) + expect(document.getSelection()).toEqual({ + anchor: {path: [{_key: 'k0'}, 'children', {_key: 'k1'}], offset: 3}, + focus: {path: [{_key: 'k0'}, 'children', {_key: 'k1'}], offset: 3}, + }) + expect(document.toTextspec()).toEqual('B: bar foo|') + }) + + test('a local edit is a user action: it emits a local change, the caret moves back over removed text and out of a removed block, and read-only refuses it', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: barx|'), + ) + const textPatch = diffMatchPatch('barx', 'bar', [ + {_key: 'k2'}, + 'children', + {_key: 'k3'}, + 'text', + ]) + const events = listen(document) + + document.applyLocalEdit([textPatch]) + + expect(document.toTextspec()).toEqual('B: foo;;B: bar|') + + document.applyLocalEdit([unset([{_key: 'k2'}])]) + + expect(document.toTextspec()).toEqual('B: foo|') + + document.updateReadOnly(true) + document.applyLocalEdit([unset([{_key: 'k0'}]), unset([])]) + document.type('y') + + expect(document.toTextspec()).toEqual('B: foo|') + expect(events).toEqual([ + { + type: 'change', + origin: 'local', + operations: [textPatch], + patches: [textPatch], + }, + { + type: 'change', + origin: 'local', + operations: [unset([{_key: 'k2'}])], + patches: [unset([{_key: 'k2'}])], + }, + ]) + }) +}) + +describe(formatTextspec.name, () => { + test('writes content on one line, with keys on request and empty content as an empty string', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;H1: bar|') + + expect([ + formatTextspec(value), + formatTextspec(value, {keys: true}), + formatTextspec([]), + ]).toEqual(['B: foo;;H1: bar', 'B _key="k0": foo;;H1 _key="k2": bar', '']) + }) +}) + +describe(comparableTextspec.name, () => { + test('ignores keys when the expected notation names none', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;H1: bar|'), + ) + + expect( + comparableTextspec( + {value: document.getValue(), selection: document.getSelection()}, + 'B: foo;;H1: bar', + ), + ).toEqual({actual: 'B: foo;;H1: bar', expected: 'B: foo;;H1: bar'}) + }) + + test('compares the keys the expected notation names, and only those', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B _key="k9": baz|'), + ) + const actual = { + value: document.getValue(), + selection: document.getSelection(), + } + + expect(comparableTextspec(actual, 'B: foo;;B _key="k9": baz')).toEqual({ + actual: 'B: foo;;B _key="k9": baz', + expected: 'B: foo;;B _key="k9": baz', + }) + expect(comparableTextspec(actual, 'B: foo;;B _key="k8": baz')).toEqual({ + actual: 'B: foo;;B: baz', + expected: 'B: foo;;B _key="k8": baz', + }) + }) + + test('compares a named key that looks like a generated one', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B _key="k9": foo') + + expect( + comparableTextspec({value, selection: null}, 'B _key="expected-k0": foo'), + ).toEqual({ + actual: 'B: foo', + expected: 'B _key="expected-k0": foo', + }) + }) + + test('shows every block that carries a named key', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec( + {keyGenerator}, + 'B: foo;;B _key="k9": baz;;B _key="k9": bar', + ) + + expect( + comparableTextspec({value, selection: null}, 'B: foo;;B _key="k9": bar'), + ).toEqual({ + actual: 'B: foo;;B _key="k9": baz;;B _key="k9": bar', + expected: 'B: foo;;B _key="k9": bar', + }) + }) + + test('compares the caret only when the expected notation has one', () => { + const keyGenerator = createTestKeyGenerator() + const document = createReadyDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fo|o'), + ) + const actual = { + value: document.getValue(), + selection: document.getSelection(), + } + + expect(comparableTextspec(actual, 'B: foo')).toEqual({ + actual: 'B: foo', + expected: 'B: foo', + }) + expect(comparableTextspec(actual, 'B: foo|')).toEqual({ + actual: 'B: fo|o', + expected: 'B: foo|', + }) + expect(comparableTextspec(actual, 'B: fo|o')).toEqual({ + actual: 'B: fo|o', + expected: 'B: fo|o', + }) + }) + + test('compares blocks without keys', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const {_key: _removedKey, ...blockWithoutKey} = value[0] + + expect( + comparableTextspec( + {value: [blockWithoutKey as (typeof value)[number]], selection: null}, + 'B: foo', + ), + ).toEqual({actual: 'B: foo', expected: 'B: foo'}) + }) +}) + +describe(createsBlock.name, () => { + test('needs a whole-field setIfMissing followed by an insert', () => { + const keyGenerator = createTestKeyGenerator() + const [block] = parseTextspec({keyGenerator}, 'B: ').value + + expect( + createsBlock([setIfMissing([], []), insert([block], 'before', [0])]), + ).toEqual(true) + expect(createsBlock([insert([block], 'before', [0])])).toEqual(false) + expect( + createsBlock([ + setIfMissing([], [{_key: block._key}, 'children']), + insert([block], 'before', [0]), + ]), + ).toEqual(false) + expect(createsBlock([setIfMissing([], [])])).toEqual(false) + }) +}) + +describe(emptiesField.name, () => { + test('needs a whole-field unset', () => { + const keyGenerator = createTestKeyGenerator() + const [block] = parseTextspec({keyGenerator}, 'B: foo').value + + expect(emptiesField([unset([{_key: block._key}]), unset([])])).toEqual(true) + expect(emptiesField([unset([{_key: block._key}])])).toEqual(false) + }) +}) + +function createReadyDocument( + ...parameters: Parameters +): ReturnType { + const document = createFakeDocument(...parameters) + document.mount() + + return document +} + +function listen(document: FakeDocument): Array { + const events: Array = [] + + for (const type of ['change', 'ready', 'closing'] as const) { + document.on(type, (event) => events.push(event)) + } + + return events +} diff --git a/packages/io/src/fakes/document.ts b/packages/io/src/fakes/document.ts new file mode 100644 index 0000000000..6851d512aa --- /dev/null +++ b/packages/io/src/fakes/document.ts @@ -0,0 +1,998 @@ +import { + diffMatchPatch, + insert, + set, + setIfMissing, + unset, + type Patch, +} from '@portabletext/patches' +import { + compileSchema, + defineSchema, + isSpan, + isTextBlock, + type PortableTextBlock, + type PortableTextSpan, + type PortableTextTextBlock, +} from '@portabletext/schema' +import { + createTestKeyGenerator, + fromTextspec, + toTextspec, + type TextspecSelection, +} from '@portabletext/test' +import {parse} from '@textspec/notation' +import {applyWithContentLakeSemantics} from '../protocol/content-lake' +import { + mapOffsetThrough, + textEditPatch, + textEditsOf, +} from '../protocol/text-edits' +import type { + EditorEventForIo, + EditorForIo, + EditorMessageForIo, +} from '../protocol/types' + +const schema = compileSchema( + defineSchema({styles: [{name: 'h1'}, {name: 'h2'}, {name: 'h3'}]}), +) + +/** + * A collapsed selection: a block key plus a character offset into the + * block's text. + */ +export type Caret = {blockKey: string; offset: number} + +/** `'loading'` until the first commit ends with `mount`. */ +export type FakeDocumentStatus = 'loading' | 'ready' | 'unmounted' + +/** + * What a user action did: `operations` with the positions it acted at, and + * the `patches` that save it. Typing and deleting text differ: a patch is + * computed from the text before and after, as the editor computes it, so + * typing `foo` before `foofoo` saves as an insertion at the end, while the + * operation says the start. + */ +type LocalEdit = {operations: Array; patches: Array} + +/** + * The fake editor. It satisfies `EditorForIo`: every user action that + * changes the content emits a local `change` with the action's patches, + * `load`, `resync` and `apply` replace or patch the content, `mount` ends + * the first commit with `ready`, and `close` emits `closing` before it + * stops. Actions before `mount` throw, and actions after `close` or while + * read-only do nothing. + * + * `applyLocalEdit` is the user action behind the model's undo: it applies + * the patches as the user's own edit. It isn't part of `EditorForIo`. + */ +export type FakeDocument = EditorForIo & { + getStatus: () => FakeDocumentStatus + getReadOnly: () => boolean + /** Ends the first commit. */ + mount: () => void + close: () => void + updateReadOnly: (readOnly: boolean) => void + /** The content on screen, the placeholder included. */ + getValue: () => Array + getCaret: () => Caret + /** Puts the caret at a block key and offset. Throws if either is invalid. */ + setCaret: (caret: Caret) => void + getSelection: () => TextspecSelection + /** The key of the placeholder block, while one is shown. */ + getPlaceholderKey: () => string | undefined + toTextspec: (options?: {keys?: boolean}) => string + setStyle: (style: string) => void + type: (text: string) => void + /** + * Deletes text that ends at the caret within the caret's span, as a run of + * backspaces. Throws if the text isn't right before the caret. + */ + deleteBeforeCaret: (text: string) => void + putCaretAfter: (text: string) => void + insertBlock: (textspec: string) => void + deleteBlock: (text: string) => void + /** + * Splits the caret's block at the caret, as the editor's `insert.break` + * does: a `diffMatchPatch` that cuts the caret's span at the caret, an + * `unset` of each child after it, and an `insert` after the block of a new + * block with the rest. The rest of the caret's span keeps the span's key, + * and the caret goes to the start of the new block. + */ + splitAtCaret: () => void + applyLocalEdit: (patches: Array) => void +} + +export function createFakeDocument( + context: {keyGenerator: () => string}, + initial: {value: Array | undefined; caret?: Caret}, +): FakeDocument { + const listeners = new Set<(event: EditorEventForIo) => void>() + let status: FakeDocumentStatus = 'loading' + let readOnly = false + let value: Array = [] + let placeholderKey: string | undefined + let caret: Caret = {blockKey: '', offset: 0} + + setValue(initial.value) + + if (initial.caret) { + placeCaret(initial.caret) + } + + function emit(event: EditorEventForIo) { + for (const listener of listeners) { + listener(event) + } + } + + function mount() { + if (status === 'ready') { + throw new Error('The editor is already mounted') + } + + if (status === 'unmounted') { + throw new Error('The editor is unmounted') + } + + status = 'ready' + emit({type: 'ready'}) + } + + function close() { + if (status === 'unmounted') { + return + } + + emit({type: 'closing'}) + status = 'unmounted' + } + + function canAct(): boolean { + if (status === 'unmounted') { + return false + } + + if (status !== 'ready') { + throw new Error(`The editor is ${status}`) + } + + return !readOnly + } + + function act(run: () => Array | LocalEdit) { + if (!canAct()) { + return + } + + const result = run() + const {operations, patches} = Array.isArray(result) + ? {operations: result, patches: result} + : result + + if (patches.length > 0) { + emit({type: 'change', origin: 'local', operations, patches}) + } + } + + function send(message: EditorMessageForIo) { + switch (message.type) { + case 'load': + if (status !== 'loading') { + throw new Error( + '`load` is only accepted in the first commit, before the editor is ready', + ) + } + + setValue(message.value) + return + case 'resync': + changeRemotely(() => setValue(message.value)) + return + case 'apply': + changeRemotely(() => applyPatches(message.patches)) + } + } + + function changeRemotely(run: () => void) { + if (status === 'unmounted') { + return + } + + const before = value + run() + + if (!isEqual(before, value)) { + emit({type: 'change', origin: 'remote', operations: [set(value, [])]}) + } + } + + /** + * Applies patches to the content one at a time, the placeholder left out, + * by Content Lake's rules and with no normalization, and moves the caret + * by each patch: through a new `_key` of its block, to the end of the + * previous block (or else the start of the next) when its block is + * removed, and past text a patch inserted or deleted before it in its + * span: by the edits of a text patch on the span, and by the change + * between the common prefix and suffix of the span's text for any other + * patch that changes it, a `set` of the block included. Anything else, + * a span inserted before it included, leaves the caret in its span at + * its offset there. + */ + function applyPatches(patches: Array) { + let content: Array | undefined = + placeholderKey === undefined ? value : undefined + + for (const patch of patches) { + const nextContent = applyWithContentLakeSemantics(content, [patch]) + caret = followCaret(caret, patch, content ?? [], nextContent ?? []) + content = nextContent + } + + setValue(content) + } + + function setValue(nextValue: Array | undefined) { + if (nextValue === undefined || nextValue.length === 0) { + if (placeholderKey === undefined) { + const placeholder = createPlaceholder(context.keyGenerator) + placeholderKey = placeholder._key + value = [placeholder] + } + } else { + placeholderKey = undefined + value = nextValue + } + + const caretBlock = value.find((block) => block._key === caret.blockKey) + + caret = caretBlock + ? { + blockKey: caret.blockKey, + offset: Math.min(caret.offset, getTextBlock(caretBlock).text.length), + } + : {blockKey: value[0]._key, offset: 0} + } + + function placeCaret(nextCaret: Caret) { + const block = value.find( + (candidate) => candidate._key === nextCaret.blockKey, + ) + + if (!block) { + throw new Error(`No block with key "${nextCaret.blockKey}"`) + } + + if (nextCaret.offset > getTextBlock(block).text.length) { + throw new Error( + `Offset ${nextCaret.offset} is past the end of block "${nextCaret.blockKey}"`, + ) + } + + caret = nextCaret + } + + function getSelection(): TextspecSelection { + const block = getTextBlock(findBlock(caret.blockKey)) + const {span, offset} = locateSpan(block.block, caret.offset) + const point = { + path: [{_key: block.block._key}, 'children', {_key: span._key}], + offset, + } + + return {anchor: point, focus: point} + } + + function findBlock(key: string) { + const block = value.find((candidate) => candidate._key === key) + + if (!block) { + throw new Error(`No block with key "${key}"`) + } + + return block + } + + function withPlaceholderCreation(patches: Array): Array { + if (placeholderKey === undefined) { + return patches + } + + const placeholder = findBlock(placeholderKey) + placeholderKey = undefined + + return [ + setIfMissing([], []), + insert([placeholder], 'before', [0]), + ...patches, + ] + } + + function setStyle(style: string): Array { + if (!schema.styles.some((definition) => definition.name === style)) { + throw new Error(`Unknown style "${style}"`) + } + + const blockIndex = value.findIndex((block) => block._key === caret.blockKey) + const block = getTextBlock(value[blockIndex]).block + const patches = withPlaceholderCreation([ + set(style, [{_key: block._key}, 'style']), + ]) + + value = replaceAt(value, blockIndex, {...block, style}) + + return patches + } + + function type(text: string): LocalEdit { + const blockIndex = value.findIndex((block) => block._key === caret.blockKey) + const block = getTextBlock(value[blockIndex]).block + const {span, offset} = locateSpan(block, caret.offset) + const nextText = span.text.slice(0, offset) + text + span.text.slice(offset) + const path = [{_key: block._key}, 'children', {_key: span._key}, 'text'] + const patches = withPlaceholderCreation([ + diffMatchPatch(span.text, nextText, path), + ]) + const operations = [ + ...patches.slice(0, -1), + textEditPatch(span.text, {offset, insertText: text}, path), + ] + + value = replaceAt(value, blockIndex, { + ...block, + children: block.children.map((child) => + child._key === span._key ? {...span, text: nextText} : child, + ), + }) + caret = {blockKey: block._key, offset: caret.offset + text.length} + + return {operations, patches} + } + + function deleteBeforeCaret(text: string): LocalEdit { + const blockIndex = value.findIndex((block) => block._key === caret.blockKey) + const block = getTextBlock(value[blockIndex]).block + const {span, offset} = locateSpan(block, caret.offset) + const start = offset - text.length + + if (text === '' || start < 0 || span.text.slice(start, offset) !== text) { + throw new Error( + `Expected "${text}" right before the caret, found "${span.text.slice(0, offset)}"`, + ) + } + + const nextText = span.text.slice(0, start) + span.text.slice(offset) + const path = [{_key: block._key}, 'children', {_key: span._key}, 'text'] + + value = replaceAt(value, blockIndex, { + ...block, + children: block.children.map((child) => + child._key === span._key ? {...span, text: nextText} : child, + ), + }) + caret = {blockKey: block._key, offset: caret.offset - text.length} + + return { + operations: [ + textEditPatch( + span.text, + {offset: start, deleteLength: text.length}, + path, + ), + ], + patches: [diffMatchPatch(span.text, nextText, path)], + } + } + + function putCaretAfter(text: string) { + const matches = value.flatMap((block) => { + const blockText = getTextBlock(block).text + const offsets: Array = [] + let index = blockText.indexOf(text) + + while (index !== -1) { + offsets.push({blockKey: block._key, offset: index + text.length}) + index = blockText.indexOf(text, index + 1) + } + + return offsets + }) + + if (matches.length !== 1) { + throw new Error( + `Expected "${text}" to occur once, found it ${matches.length} times`, + ) + } + + caret = matches[0] + } + + function insertBlock(textspec: string): Array { + const {blocks} = fromTextspec( + {schema, keyGenerator: context.keyGenerator}, + textspec, + ) + + if (blocks.length !== 1) { + throw new Error( + `Expected one block in "${textspec}", found ${blocks.length}`, + ) + } + + const siblingKeys = new Set(value.map((block) => block._key)) + const newBlock = siblingKeys.has(blocks[0]._key) + ? { + ...blocks[0], + _key: generateUniqueKey(context.keyGenerator, siblingKeys), + } + : blocks[0] + const caretBlockKey = caret.blockKey + const blockIndex = value.findIndex((block) => block._key === caretBlockKey) + const patches = withPlaceholderCreation([ + insert([newBlock], 'after', [{_key: caretBlockKey}]), + ]) + + value = [ + ...value.slice(0, blockIndex + 1), + newBlock, + ...value.slice(blockIndex + 1), + ] + caret = { + blockKey: newBlock._key, + offset: getTextBlock(newBlock).text.length, + } + + return patches + } + + function splitAtCaret(): Array { + const blockIndex = value.findIndex((block) => block._key === caret.blockKey) + const block = getTextBlock(value[blockIndex]).block + const {span, offset} = locateSpan(block, caret.offset) + const spanIndex = block.children.findIndex( + (child) => child._key === span._key, + ) + const head = span.text.slice(0, offset) + const movedChildren = block.children.slice(spanIndex + 1) + const newBlock: PortableTextTextBlock = { + ...block, + _key: generateUniqueKey( + context.keyGenerator, + new Set(value.map((candidate) => candidate._key)), + ), + children: [{...span, text: span.text.slice(offset)}, ...movedChildren], + } + const blockPath = [{_key: block._key}] + const patches = withPlaceholderCreation([ + ...(head === span.text + ? [] + : [ + diffMatchPatch(span.text, head, [ + ...blockPath, + 'children', + {_key: span._key}, + 'text', + ]), + ]), + ...movedChildren.map((child) => + unset([...blockPath, 'children', {_key: child._key}]), + ), + insert([newBlock], 'after', blockPath), + ]) + + value = [ + ...value.slice(0, blockIndex), + { + ...block, + children: [ + ...block.children.slice(0, spanIndex), + {...span, text: head}, + ], + }, + newBlock, + ...value.slice(blockIndex + 1), + ] + caret = {blockKey: newBlock._key, offset: 0} + + return patches + } + + function deleteBlock(text: string): Array { + const matches = value.filter((block) => getTextBlock(block).text === text) + + if (matches.length !== 1) { + throw new Error( + `Expected one block with the text "${text}", found ${matches.length}`, + ) + } + + const block = matches[0] + + if (block._key === placeholderKey) { + return [] + } + + const blockKey = block._key + const blockIndex = value.indexOf(block) + const previousBlock = value[blockIndex - 1] + const nextBlock = value[blockIndex + 1] + const remainingValue = value.filter( + (candidate) => candidate._key !== blockKey, + ) + + if (remainingValue.length === 0) { + setValue(undefined) + + return [unset([{_key: blockKey}]), unset([])] + } + + if (caret.blockKey === blockKey) { + caret = previousBlock + ? { + blockKey: previousBlock._key, + offset: getTextBlock(previousBlock).text.length, + } + : {blockKey: nextBlock._key, offset: 0} + } + + value = remainingValue + + return [unset([{_key: blockKey}])] + } + + return { + on: (type, listener) => { + const listenToType = (event: EditorEventForIo) => { + if (isOfType(event, type)) { + listener(event) + } + } + listeners.add(listenToType) + + return { + unsubscribe: () => { + listeners.delete(listenToType) + }, + } + }, + send, + getStatus: () => status, + getReadOnly: () => readOnly, + mount, + close, + updateReadOnly: (nextReadOnly) => { + readOnly = nextReadOnly + }, + getValue: () => value, + getCaret: () => caret, + setCaret: placeCaret, + getSelection, + getPlaceholderKey: () => placeholderKey, + toTextspec: (options) => + serializeTextspec({ + value, + selection: getSelection(), + keys: options?.keys ?? false, + }), + setStyle: (style) => act(() => setStyle(style)), + type: (text) => act(() => type(text)), + deleteBeforeCaret: (text) => act(() => deleteBeforeCaret(text)), + putCaretAfter: (text) => { + if (status !== 'unmounted') { + putCaretAfter(text) + } + }, + insertBlock: (textspec) => act(() => insertBlock(textspec)), + deleteBlock: (text) => act(() => deleteBlock(text)), + splitAtCaret: () => act(splitAtCaret), + applyLocalEdit: (patches) => + act(() => { + applyPatches(patches) + return patches + }), + } +} + +function isOfType( + event: EditorEventForIo, + type: TType, +): event is EditorEventForIo & {type: TType} { + return event.type === type +} + +/** + * Calls the key generator until it returns a key that isn't taken, and marks + * that key as taken. + */ +function generateUniqueKey( + keyGenerator: () => string, + takenKeys: Set, +): string { + let key = keyGenerator() + + while (takenKeys.has(key)) { + key = keyGenerator() + } + + takenKeys.add(key) + + return key +} + +/** + * Parses textspec into content and a caret. The caret is `undefined` when the + * notation has none. + */ +export function parseTextspec( + context: {keyGenerator: () => string}, + textspec: string, +): {value: Array; caret: Caret | undefined} { + const {blocks, selection} = fromTextspec( + {schema, keyGenerator: context.keyGenerator}, + textspec, + ) + + if (!hasCaret(textspec) || !selection) { + return {value: blocks, caret: undefined} + } + + const [blockSegment, , spanSegment] = selection.focus.path + const block = blocks.find( + (candidate) => + typeof blockSegment === 'object' && + '_key' in blockSegment && + candidate._key === blockSegment._key, + ) + + if (!block) { + throw new Error(`The caret in "${textspec}" is not in a block`) + } + + const textBlock = getTextBlock(block).block + let offset = selection.focus.offset + + for (const child of textBlock.children) { + if ( + typeof spanSegment === 'object' && + '_key' in spanSegment && + child._key === spanSegment._key + ) { + break + } + + offset += isSpan({schema}, child) ? child.text.length : 0 + } + + return {value: blocks, caret: {blockKey: block._key, offset}} +} + +/** + * Turns an actual state and an expected notation into two strings to compare. + * Keys are compared only for the blocks whose keys the expected notation + * names, and the caret only when the expected notation has one. + */ +export function comparableTextspec( + actual: {value: Array; selection: TextspecSelection}, + expected: string, +): {actual: string; expected: string} { + const parsed = fromTextspec( + {schema, keyGenerator: createTestKeyGenerator('expected-')}, + expected, + ) + const namedKeys = new Set( + parse(hasCaret(expected) ? expected : `${expected}|`).blocks.flatMap( + (block) => + typeof block.attrs?.['_key'] === 'string' ? [block.attrs['_key']] : [], + ), + ) + const keys = namedKeys.size > 0 ? namedKeys : false + const compareCaret = hasCaret(expected) + + return { + actual: serializeTextspec({ + value: actual.value, + selection: compareCaret ? actual.selection : null, + keys, + }), + expected: serializeTextspec({ + value: parsed.blocks, + selection: compareCaret ? parsed.selection : null, + keys, + }), + } +} + +/** + * Content as one line of textspec, without a caret. Empty content is an empty + * string. + */ +export function formatTextspec( + value: Array, + options?: {keys?: boolean}, +): string { + return serializeTextspec({ + value, + selection: null, + keys: options?.keys ?? false, + }) +} + +/** + * Whether a mutation turns the placeholder into content: it starts with a + * whole-field `setIfMissing` followed by an `insert`. + */ +export function createsBlock(patches: Array): boolean { + const [first, second] = patches + + return ( + first?.type === 'setIfMissing' && + first.path.length === 0 && + second?.type === 'insert' + ) +} + +/** + * Whether a mutation empties the field: it contains a whole-field `unset`. + */ +export function emptiesField(patches: Array): boolean { + return patches.some( + (patch) => patch.type === 'unset' && patch.path.length === 0, + ) +} + +function followCaret( + caret: Caret, + patch: Patch, + before: Array, + after: Array, +): Caret { + const [head, field, spanSegment] = patch.path + + if ( + typeof head !== 'object' || + Array.isArray(head) || + head._key !== caret.blockKey + ) { + return caret + } + + if ( + patch.type === 'set' && + patch.path.length === 2 && + field === '_key' && + typeof patch.value === 'string' + ) { + return {blockKey: patch.value, offset: caret.offset} + } + + if (patch.type === 'unset' && patch.path.length === 1) { + const index = before.findIndex((block) => block._key === caret.blockKey) + const survives = (block: PortableTextBlock) => + after.find((candidate) => candidate._key === block._key) + const previous = before.slice(0, index).reverse().find(survives) + const next = before.slice(index + 1).find(survives) + + if (previous) { + return { + blockKey: previous._key, + offset: getTextBlock(survives(previous)).text.length, + } + } + + return next ? {blockKey: next._key, offset: 0} : caret + } + + const beforeBlock = before.find((block) => block._key === caret.blockKey) + const afterBlock = after.find((block) => block._key === caret.blockKey) + + if (!beforeBlock || !afterBlock) { + return caret + } + + const {span, offset} = locateSpan( + getTextBlock(beforeBlock).block, + caret.offset, + ) + const onCaretSpan = + patch.path.length === 4 && + field === 'children' && + typeof spanSegment === 'object' && + !Array.isArray(spanSegment) && + spanSegment._key === span._key && + patch.path[3] === 'text' + const afterSpan = getTextBlock(afterBlock).block.children.find( + (child) => child._key === span._key, + ) + let spanOffset = offset + + if (onCaretSpan && patch.type === 'diffMatchPatch') { + spanOffset = mapOffsetThrough(offset, textEditsOf(patch.value, span.text)) + } else if (afterSpan && isSpan({schema}, afterSpan)) { + spanOffset = mapOffset(offset, span.text, afterSpan.text) + } + + return { + blockKey: caret.blockKey, + offset: blockOffset(getTextBlock(afterBlock).block, span._key, spanOffset), + } +} + +/** + * The offset in a block of an offset in one of its spans, clamped to the + * span's text. A span the block no longer has puts it at the block's end. + */ +function blockOffset( + block: PortableTextTextBlock, + spanKey: string, + spanOffset: number, +): number { + let offset = 0 + + for (const child of block.children) { + if (!isSpan({schema}, child)) { + continue + } + + if (child._key === spanKey) { + return offset + Math.min(spanOffset, child.text.length) + } + + offset += child.text.length + } + + return offset +} + +/** + * Where an offset in `before` lands in `after`, taking the change as the + * span between their common prefix and common suffix. An offset inside the + * changed span goes to its end. + */ +function mapOffset(offset: number, before: string, after: string): number { + let prefix = 0 + + while ( + prefix < before.length && + prefix < after.length && + before[prefix] === after[prefix] + ) { + prefix++ + } + + let suffix = 0 + + while ( + suffix < before.length - prefix && + suffix < after.length - prefix && + before[before.length - 1 - suffix] === after[after.length - 1 - suffix] + ) { + suffix++ + } + + if (offset <= prefix) { + return offset + } + + if (offset >= before.length - suffix) { + return offset + after.length - before.length + } + + return after.length - suffix +} + +/** Whether two plain values are deeply equal, key order aside. */ +export function isEqual(valueA: unknown, valueB: unknown): boolean { + if (valueA === valueB) { + return true + } + + if ( + typeof valueA !== 'object' || + typeof valueB !== 'object' || + valueA === null || + valueB === null || + Array.isArray(valueA) !== Array.isArray(valueB) + ) { + return false + } + + const keysA = Object.keys(valueA) + const keysB = Object.keys(valueB) + + return ( + keysA.length === keysB.length && + keysA.every( + (key) => + Object.hasOwn(valueB, key) && + isEqual(Reflect.get(valueA, key), Reflect.get(valueB, key)), + ) + ) +} + +function createPlaceholder(keyGenerator: () => string): PortableTextTextBlock { + return { + _type: 'block', + _key: keyGenerator(), + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: keyGenerator(), text: '', marks: []}], + } +} + +function getTextBlock(block: PortableTextBlock | undefined): { + block: PortableTextTextBlock + text: string +} { + if (!block || !isTextBlock({schema}, block)) { + throw new Error(`Expected a text block, got ${JSON.stringify(block)}`) + } + + const text = block.children + .map((child) => (isSpan({schema}, child) ? child.text : '')) + .join('') + + return {block, text} +} + +function locateSpan( + block: PortableTextTextBlock, + blockOffset: number, +): {span: PortableTextSpan; offset: number} { + const spans = block.children.filter((child) => isSpan({schema}, child)) + let remaining = blockOffset + + for (const span of spans) { + if (remaining <= span.text.length) { + return {span, offset: remaining} + } + + remaining -= span.text.length + } + + throw new Error( + `Offset ${blockOffset} is past the end of block "${block._key}"`, + ) +} + +function replaceAt( + items: Array, + index: number, + item: TItem, +): Array { + return items.map((candidate, candidateIndex) => + candidateIndex === index ? item : candidate, + ) +} + +function hasCaret(textspec: string): boolean { + return /(? + selection: TextspecSelection + keys: boolean | Set +}): string { + return value + .map((block) => + toTextspec( + {schema, value: [block], selection}, + {singleLine: true, keys}, + ).replace( + /^B( _key="[^"]*")? style="([a-z][a-z0-9]*)"(?=:)/, + (_match, keyAttribute: string | undefined, style: string) => + `${style.toUpperCase()}${keyAttribute ?? ''}`, + ), + ) + .join(';;') +} diff --git a/packages/io/src/fakes/network.test.ts b/packages/io/src/fakes/network.test.ts new file mode 100644 index 0000000000..4508adf41f --- /dev/null +++ b/packages/io/src/fakes/network.test.ts @@ -0,0 +1,321 @@ +import {set} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from './document' +import {createFakeNetwork, type FailureReply, type Reply} from './network' +import type {ServerTransaction} from './server' + +describe(createFakeNetwork.name, () => { + test('save requests leave in the order the caller takes them', () => { + const network = createFakeNetwork() + const mutationA1 = {id: 'a1', patches: [set('h1', [{_key: 'k0'}, 'style'])]} + const mutationA2 = {id: 'a2', patches: [set('h2', [{_key: 'k0'}, 'style'])]} + const mutationB1 = { + id: 'b1', + patches: [set('normal', [{_key: 'k0'}, 'style'])], + } + + network.send('A', mutationA1) + network.send('A', mutationA2) + network.send('B', mutationB1) + + expect(network.takeSaveRequest('b1')).toEqual({ + editorId: 'B', + mutation: mutationB1, + }) + expect(network.takeSaveRequest('a2')).toEqual({ + editorId: 'A', + mutation: mutationA2, + }) + expect(network.getSaveRequests()).toEqual([ + {editorId: 'A', mutation: mutationA1}, + ]) + expect(network.takeSaveRequest('a1')).toEqual({ + editorId: 'A', + mutation: mutationA1, + }) + expect(network.getSaveRequests()).toEqual([]) + expect(() => network.takeSaveRequest('a1')).toThrow( + 'No save request for mutation "a1"', + ) + }) + + test('taking a save request tells the editor that sent it', () => { + const network = createFakeNetwork() + const taken: Array<{editorId: string; mutationId: string}> = [] + + for (const editorId of ['A', 'B']) { + network.connect(editorId, { + receiveTransaction: () => {}, + receiveReply: () => {}, + receiveSaveTaken: (mutationId) => { + taken.push({editorId, mutationId}) + }, + }) + } + + network.send('A', {id: 'a1', patches: []}) + network.send('B', {id: 'b1', patches: []}) + network.takeSaveRequest('b1') + + expect(taken).toEqual([{editorId: 'B', mutationId: 'b1'}]) + }) + + test("a network that includes the server's copy delivers each transaction with the copy after it", () => { + const {value} = parseTextspec( + {keyGenerator: createTestKeyGenerator()}, + 'B: foo', + ) + const transaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + mutationIds: [], + } + const received = [false, true].map((includesCopy) => { + const network = createFakeNetwork( + includesCopy + ? { + copyAfter: (transactionId) => + transactionId === 't1' ? value : [], + } + : {}, + ) + const transactions: Array = [] + + network.connect('A', { + receiveTransaction: (carried) => { + transactions.push(carried) + }, + receiveReply: () => {}, + receiveSaveTaken: () => {}, + }) + network.publish(transaction) + network.deliver('A', 't1') + + return transactions + }) + + expect(received).toEqual([[transaction], [{...transaction, value}]]) + }) + + test('replies reach the sending editor in the order the caller delivers them', () => { + const network = createFakeNetwork() + const received: Array<{editorId: string; reply: FailureReply}> = [] + + for (const editorId of ['A', 'B']) { + network.connect(editorId, { + receiveTransaction: () => {}, + receiveReply: (reply) => { + received.push({editorId, reply}) + }, + receiveSaveTaken: () => {}, + }) + } + + network.queueReply({editorId: 'A', mutationId: 'a1', status: 400}) + network.queueReply({editorId: 'B', mutationId: 'b1', status: 503}) + network.deliverReply('b1') + + expect(network.getReplies()).toEqual([ + {editorId: 'A', mutationId: 'a1', status: 400}, + ]) + + network.deliverReply('a1') + + expect(received).toEqual([ + { + editorId: 'B', + reply: {editorId: 'B', mutationId: 'b1', status: 503}, + }, + { + editorId: 'A', + reply: {editorId: 'A', mutationId: 'a1', status: 400}, + }, + ]) + expect(network.getReplies()).toEqual([]) + }) + + test('a lost reply waits until the host retries the save', () => { + const network = createFakeNetwork() + const reply: Reply = {editorId: 'A', mutationId: 'a1'} + + network.loseReply(reply) + + expect(network.getLostReplies()).toEqual([reply]) + expect(() => network.loseReply(reply)).toThrow( + 'The reply for mutation "a1" is lost already', + ) + expect(network.takeLostReply('a1')).toEqual(reply) + expect(network.getLostReplies()).toEqual([]) + expect(() => network.takeLostReply('a1')).toThrow( + 'No lost reply for mutation "a1"', + ) + }) + + test('each editor receives its feed in the order the caller delivers it', () => { + const network = createFakeNetwork() + const received: Array<{editorId: string; transactionId: string}> = [] + const firstTransaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k0'}, 'style'])], + mutationIds: ['b1'], + } + const secondTransaction: ServerTransaction = { + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + mutationIds: [], + } + + for (const editorId of ['A', 'B']) { + network.connect(editorId, { + receiveTransaction: (transaction) => { + received.push({editorId, transactionId: transaction.transactionId}) + }, + receiveReply: () => {}, + receiveSaveTaken: () => {}, + }) + } + + network.publish(firstTransaction) + network.publish(secondTransaction) + + expect(network.getFeed('A')).toEqual([firstTransaction, secondTransaction]) + + network.deliver('A', 't2') + network.deliver('A', 't1') + network.deliver('B', 't1') + + expect(received).toEqual([ + {editorId: 'A', transactionId: 't2'}, + {editorId: 'A', transactionId: 't1'}, + {editorId: 'B', transactionId: 't1'}, + ]) + expect(network.getFeed('A')).toEqual([]) + expect(network.getFeed('B')).toEqual([secondTransaction]) + expect(() => network.deliver('A', 't1')).toThrow( + 'No transaction "t1" waiting for editor "A"', + ) + }) + + test('an editor that connects late gets only later transactions', () => { + const network = createFakeNetwork() + const receiver = { + receiveTransaction: () => {}, + receiveReply: () => {}, + receiveSaveTaken: () => {}, + } + const transaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + mutationIds: [], + } + + network.connect('A', receiver) + network.publish(transaction) + network.connect('B', receiver) + + expect(network.getFeed('A')).toEqual([transaction]) + expect(network.getFeed('B')).toEqual([]) + }) + + test('an editor connected without a listener gets no transactions', () => { + const network = createFakeNetwork() + const receiver = { + receiveTransaction: () => {}, + receiveReply: () => {}, + receiveSaveTaken: () => {}, + } + const transaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + mutationIds: [], + } + + network.connect('A', receiver) + network.connect('B', receiver, {listening: false}) + network.publish(transaction) + + expect(network.getFeed('A')).toEqual([transaction]) + expect(network.getFeed('B')).toEqual([]) + }) + + test('an editor that reconnects with a listener gets transactions again', () => { + const network = createFakeNetwork() + const receiver = { + receiveTransaction: () => {}, + receiveReply: () => {}, + receiveSaveTaken: () => {}, + } + const transaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + mutationIds: [], + } + + network.connect('A', receiver, {listening: false}) + network.connect('A', receiver, {listening: true}) + network.publish(transaction) + + expect(network.getFeed('A')).toEqual([transaction]) + }) + + test('the clock runs what falls due, in due order, only when advanced', () => { + const network = createFakeNetwork() + const fired: Array<{name: string; at: number}> = [] + + network.clock.schedule(10_000, () => { + fired.push({name: 'hold', at: network.clock.now()}) + }) + network.clock.schedule(5_000, () => { + fired.push({name: 'warning', at: network.clock.now()}) + }) + const cancel = network.clock.schedule(5_000, () => { + fired.push({name: 'cancelled', at: network.clock.now()}) + }) + network.clock.schedule(5_000, () => { + fired.push({name: 'second warning', at: network.clock.now()}) + }) + cancel() + + network.clock.advance(4_999) + + expect(fired).toEqual([]) + expect(network.clock.now()).toEqual(4_999) + + network.clock.advance(6_000) + + expect(fired).toEqual([ + {name: 'warning', at: 5_000}, + {name: 'second warning', at: 5_000}, + {name: 'hold', at: 10_000}, + ]) + expect(network.clock.now()).toEqual(10_999) + }) + + test('a callback can schedule another that falls due in the same advance', () => { + const network = createFakeNetwork() + const fired: Array = [] + + network.clock.schedule(1_000, () => { + fired.push(network.clock.now()) + network.clock.schedule(2_000, () => { + fired.push(network.clock.now()) + }) + }) + network.clock.advance(10_000) + + expect(fired).toEqual([1_000, 3_000]) + }) +}) diff --git a/packages/io/src/fakes/network.ts b/packages/io/src/fakes/network.ts new file mode 100644 index 0000000000..339eb1eb30 --- /dev/null +++ b/packages/io/src/fakes/network.ts @@ -0,0 +1,274 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import type {RequestFailure} from '../protocol/host' +import type {SavedMutation, ServerTransaction} from './server' + +/** + * A transaction as the network carries it to a host: with the field as the + * server held it right after, when the network includes the server's copy. + */ +export type CarriedTransaction = ServerTransaction & { + value?: Array | undefined +} + +export type SaveRequest = { + editorId: string + mutation: TMutation +} + +/** + * A save reply for a mutation's request. + */ +export type Reply = { + editorId: string + mutationId: string +} + +/** + * A save reply that says the request failed. A mutation the server saves is + * confirmed by its transaction coming back on the feed, so only a failure + * travels back as a reply the host acts on. + */ +export type FailureReply = Reply & {status: RequestFailure} + +/** + * What an editor's host receives from the network. + */ +export type NetworkReceiver = { + receiveTransaction: (transaction: CarriedTransaction) => void + receiveReply: (reply: FailureReply) => void + /** The server has taken this editor's save request for a mutation. */ + receiveSaveTaken: (mutationId: string) => void +} + +export type VirtualClock = { + now: () => number + /** + * Moves time forward and runs every scheduled callback that falls due, in + * due order. + */ + advance: (milliseconds: number) => void + /** Returns a function that cancels the callback. */ + schedule: (delay: number, callback: () => void) => () => void +} + +export type Network = { + clock: VirtualClock + /** + * `listening: false` connects a host with no feed listener: it gets save + * replies, and its feed stays empty. + */ + connect: ( + editorId: string, + receiver: NetworkReceiver, + options?: {listening: boolean}, + ) => void + send: (editorId: string, mutation: TMutation) => void + getSaveRequests: () => Array> + /** Removes the request and tells the sending editor, if it is connected. */ + takeSaveRequest: (mutationId: string) => SaveRequest + queueReply: (reply: FailureReply) => void + getReplies: () => Array + deliverReply: (mutationId: string) => void + /** + * The server took and saved the mutation, but its host never heard back, so + * the host can't tell whether the save landed. + */ + loseReply: (reply: Reply) => void + getLostReplies: () => Array + /** Removes the lost reply, as when the host retries the save. */ + takeLostReply: (mutationId: string) => Reply + /** Appends the transaction to the feed of every listening editor. */ + publish: (transaction: ServerTransaction) => void + getFeed: (editorId: string) => Array + deliver: (editorId: string, transactionId: string) => void + /** The transaction as the network carries it to a host. */ + carry: (transaction: ServerTransaction) => CarriedTransaction +} + +/** + * Queues between the editors' hosts and the server. Nothing moves until a + * caller takes or delivers it, in whatever order the caller asks for. With + * `copyAfter`, each transaction carries the field as the server held it + * right after, as a listener with `includeResult` reports it. + */ +export function createFakeNetwork< + TMutation extends SavedMutation = SavedMutation, +>( + options: { + copyAfter?: (transactionId: string) => Array | undefined + } = {}, +): Network { + const receivers = new Map() + const feeds = new Map>() + const deafEditorIds = new Set() + let saveRequests: Array> = [] + let replies: Array = [] + let lostReplies: Array = [] + + function getReceiver(editorId: string) { + const receiver = receivers.get(editorId) + + if (!receiver) { + throw new Error(`No editor "${editorId}" is connected`) + } + + return receiver + } + + return { + clock: createVirtualClock(), + connect: (editorId, receiver, {listening} = {listening: true}) => { + receivers.set(editorId, receiver) + feeds.set(editorId, []) + + if (listening) { + deafEditorIds.delete(editorId) + } else { + deafEditorIds.add(editorId) + } + }, + send: (editorId, mutation) => { + saveRequests = [...saveRequests, {editorId, mutation}] + }, + getSaveRequests: () => saveRequests, + takeSaveRequest: (mutationId) => { + const request = saveRequests.find( + (candidate) => candidate.mutation.id === mutationId, + ) + + if (!request) { + throw new Error(`No save request for mutation "${mutationId}"`) + } + + saveRequests = saveRequests.filter((candidate) => candidate !== request) + receivers.get(request.editorId)?.receiveSaveTaken(mutationId) + + return request + }, + queueReply: (reply) => { + replies = [...replies, reply] + }, + getReplies: () => replies, + deliverReply: (mutationId) => { + const reply = replies.find( + (candidate) => candidate.mutationId === mutationId, + ) + + if (!reply) { + throw new Error(`No reply for mutation "${mutationId}"`) + } + + replies = replies.filter((candidate) => candidate !== reply) + getReceiver(reply.editorId).receiveReply(reply) + }, + loseReply: (reply) => { + if ( + lostReplies.some( + (candidate) => candidate.mutationId === reply.mutationId, + ) + ) { + throw new Error( + `The reply for mutation "${reply.mutationId}" is lost already`, + ) + } + + lostReplies = [...lostReplies, reply] + }, + getLostReplies: () => lostReplies, + takeLostReply: (mutationId) => { + const reply = lostReplies.find( + (candidate) => candidate.mutationId === mutationId, + ) + + if (!reply) { + throw new Error(`No lost reply for mutation "${mutationId}"`) + } + + lostReplies = lostReplies.filter((candidate) => candidate !== reply) + + return reply + }, + publish: (transaction) => { + for (const [editorId, feed] of feeds) { + if (!deafEditorIds.has(editorId)) { + feeds.set(editorId, [...feed, transaction]) + } + } + }, + getFeed: (editorId) => { + const feed = feeds.get(editorId) + + if (!feed) { + throw new Error(`No editor "${editorId}" is connected`) + } + + return feed + }, + deliver: (editorId, transactionId) => { + const feed = feeds.get(editorId) ?? [] + const transaction = feed.find( + (candidate) => candidate.transactionId === transactionId, + ) + + if (!transaction) { + throw new Error( + `No transaction "${transactionId}" waiting for editor "${editorId}"`, + ) + } + + feeds.set( + editorId, + feed.filter((candidate) => candidate !== transaction), + ) + getReceiver(editorId).receiveTransaction(carry(transaction)) + }, + carry, + } + + function carry(transaction: ServerTransaction): CarriedTransaction { + return options.copyAfter + ? {...transaction, value: options.copyAfter(transaction.transactionId)} + : transaction + } +} + +function createVirtualClock(): VirtualClock { + let now = 0 + let nextTimerId = 0 + let timers: Array<{id: number; dueAt: number; callback: () => void}> = [] + + return { + now: () => now, + advance: (milliseconds) => { + const target = now + milliseconds + + while (true) { + const [nextTimer] = timers + .filter((timer) => timer.dueAt <= target) + .sort( + (timerA, timerB) => + timerA.dueAt - timerB.dueAt || timerA.id - timerB.id, + ) + + if (!nextTimer) { + break + } + + timers = timers.filter((timer) => timer !== nextTimer) + now = nextTimer.dueAt + nextTimer.callback() + } + + now = target + }, + schedule: (delay, callback) => { + const timer = {id: nextTimerId, dueAt: now + delay, callback} + nextTimerId++ + timers = [...timers, timer] + + return () => { + timers = timers.filter((candidate) => candidate !== timer) + } + }, + } +} diff --git a/packages/io/src/fakes/server.test.ts b/packages/io/src/fakes/server.test.ts new file mode 100644 index 0000000000..392a7248da --- /dev/null +++ b/packages/io/src/fakes/server.test.ts @@ -0,0 +1,527 @@ +import { + diffMatchPatch, + insert, + set, + setIfMissing, + unset, +} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from './document' +import {createFakeServer} from './server' + +describe(createFakeServer.name, () => { + test('an existing document starts at the first revision', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + ], + rev: 'r1', + }) + expect(server.getTransactions()).toEqual([]) + }) + + test('a missing document has no revision', () => { + const server = createFakeServer({ + documentId: 'document', + document: undefined, + }) + + expect(server.copy()).toEqual({value: undefined, rev: undefined}) + }) + + test('a mutation that changes nothing is still recorded and moves the revision', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'H1: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + const transaction = server.receive( + {id: 'b1', patches: [set('h1', [{_key: 'k0'}, 'style'])]}, + 't1', + ) + + expect(transaction).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k0'}, 'style'])], + mutationIds: ['b1'], + }) + expect(server.getTransactions()).toEqual([transaction]) + expect(server.copy()).toEqual({value, rev: 'r2'}) + }) + + test('a patch for a block that is gone does nothing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + const transaction = server.receive( + { + id: 'b1', + patches: [ + diffMatchPatch('bar', 'bary', [ + {_key: 'k9'}, + 'children', + {_key: 'k8'}, + 'text', + ]), + set('h1', [{_key: 'k9'}, 'style']), + unset([{_key: 'k9'}]), + insert([value[0]], 'after', [{_key: 'k9'}]), + ], + }, + 't1', + ) + + expect(transaction.resultRev).toEqual('r2') + expect(server.copy()).toEqual({value, rev: 'r2'}) + }) + + test('a patch into a field that is gone does nothing', () => { + const server = createFakeServer({ + documentId: 'document', + document: {value: undefined}, + }) + + server.receive( + { + id: 'b1', + patches: [ + diffMatchPatch('foo', 'foox', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + set('h1', [{_key: 'k0'}, 'style']), + unset([{_key: 'k0'}]), + insert([], 'before', [0]), + ], + }, + 't1', + ) + + expect(server.copy()).toEqual({value: undefined, rev: 'r2'}) + }) + + test('an insert with a key that already exists is stored', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;B _key="k9": baz') + const server = createFakeServer({documentId: 'document', document: {value}}) + const [barBlock] = parseTextspec({keyGenerator}, 'B _key="k9": bar').value + + server.receive( + {id: 'b1', patches: [insert([barBlock], 'after', [{_key: 'k9'}])]}, + 't1', + ) + + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + { + _type: 'block', + _key: 'k9', + children: [{_type: 'span', _key: 'k2', text: 'baz', marks: []}], + style: 'normal', + }, + barBlock, + ], + rev: 'r2', + }) + }) + + test('a mutation for a missing document creates it', () => { + const server = createFakeServer({ + documentId: 'document', + document: undefined, + }) + const keyGenerator = createTestKeyGenerator() + const [placeholder] = parseTextspec({keyGenerator}, 'B: ').value + const patches = [ + setIfMissing([], []), + insert([placeholder], 'before', [0]), + diffMatchPatch('', 'x', [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text']), + ] + + const transaction = server.receive({id: 'b1', patches}, 't1') + + expect(transaction).toEqual({ + transactionId: 't1', + previousRev: undefined, + resultRev: 'r1', + patches, + mutationIds: ['b1'], + }) + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'x', marks: []}], + style: 'normal', + }, + ], + rev: 'r1', + }) + }) + + test('two mutations received as one are one transaction', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;B: bar') + const server = createFakeServer({documentId: 'document', document: {value}}) + const fooPatch = diffMatchPatch('foo', 'foox', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]) + const barPatch = diffMatchPatch('bar', 'bary', [ + {_key: 'k2'}, + 'children', + {_key: 'k3'}, + 'text', + ]) + + const result = server.submit( + [ + {id: 'a1', patches: [fooPatch]}, + {id: 'b1', patches: [barPatch]}, + ], + 't1', + ) + const transaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [fooPatch, barPatch], + mutationIds: ['a1', 'b1'], + } + + expect(result).toEqual({type: 'saved', transaction}) + expect(server.getTransactions()).toEqual([transaction]) + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foox', marks: []}], + style: 'normal', + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bary', marks: []}], + style: 'normal', + }, + ], + rev: 'r2', + }) + }) + + test('a save request with a transaction ID the history lists is refused with a 409 and changes nothing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + const mutation = {id: 'b1', patches: [set('h1', [{_key: 'k0'}, 'style'])]} + + const first = server.submit([mutation], 't1') + const retry = server.submit([mutation], 't1') + + expect({first, retry}).toEqual({ + first: { + type: 'saved', + transaction: { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k0'}, 'style'])], + mutationIds: ['b1'], + }, + }, + retry: {type: 'duplicate'}, + }) + expect(server.getTransactions()).toEqual([ + { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k0'}, 'style'])], + mutationIds: ['b1'], + }, + ]) + expect(server.getDuplicates()).toEqual([ + {transactionId: 't1', mutationIds: ['b1']}, + ]) + expect([server.hasTransaction('t1'), server.hasTransaction('t2')]).toEqual([ + true, + false, + ]) + expect(server.copy().rev).toEqual('r2') + expect(() => server.receive(mutation, 't1')).toThrow( + 'Transaction "t1" already exists', + ) + }) + + test('an injected failure fails the next request only, whatever it carries, and records nothing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + const mutation = {id: 'b1', patches: [set('h1', [{_key: 'k0'}, 'style'])]} + + server.failNextRequest(503) + const failed = server.submit([mutation], 't1') + const failedCopy = server.copy() + const retried = server.submit([mutation], 't1') + + expect({failed, failedCopy, retried}).toEqual({ + failed: {type: 'failed', status: 503}, + failedCopy: {value, rev: 'r1'}, + retried: { + type: 'saved', + transaction: { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k0'}, 'style'])], + mutationIds: ['b1'], + }, + }, + }) + expect(server.getDuplicates()).toEqual([]) + expect(server.getNextFailure()).toEqual(undefined) + }) + + test('a change to another field moves the revision with no field patches', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + expect(server.changeOtherField('t1')).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + mutationIds: [], + }) + expect(server.copy()).toEqual({value, rev: 'r2'}) + }) + + test('setting the whole field records a whole-field set and moves the revision', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + const nextValue = parseTextspec({keyGenerator}, 'B: bar').value + + const transaction = server.setField(nextValue, 't1') + + expect(transaction).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set(nextValue, [])], + mutationIds: [], + }) + expect(server.copy()).toEqual({value: nextValue, rev: 'r2'}) + expect(server.getLog()).toEqual([{transaction, changesField: true}]) + }) + + test("a script's patches are stored whatever they leave behind, as Content Lake stores them", () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;B: bar;;B: baz') + const server = createFakeServer({documentId: 'document', document: {value}}) + const patches = [ + unset([{_key: 'k0'}, '_key']), + unset([1, '_type']), + set(42, [1, 'children', {_key: 'k3'}, 'text']), + set('oops', [{_key: 'k4'}, 'children']), + set('oops', [{_key: 'k4'}]), + ] + + const transaction = server.patchField(patches, 't1') + + expect(transaction).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches, + mutationIds: [], + }) + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + { + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 42, marks: []}], + style: 'normal', + }, + 'oops', + ], + rev: 'r2', + }) + }) + + test('setting the whole field of a missing document throws', () => { + const keyGenerator = createTestKeyGenerator() + const server = createFakeServer({ + documentId: 'document', + document: undefined, + }) + + expect(() => + server.setField(parseTextspec({keyGenerator}, 'B: bar').value, 't1'), + ).toThrow('The document does not exist') + }) + + test('Scenario: the stored copy of a document with no transaction yet can be altered', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + server.alterStoredCopy([set('h1', [{_key: 'k0'}, 'style'])]) + + expect({ + transactions: server.getTransactions(), + copy: server.copy(), + }).toEqual({ + transactions: [], + copy: {value: [{...value[0], style: 'h1'}], rev: 'r1'}, + }) + }) + + test('the copy after each transaction is kept, and altering the stored copy changes the field without a transaction', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + server.patchField([set('h1', [{_key: 'k0'}, 'style'])], 't1') + + server.patchField([set('h2', [{_key: 'k0'}, 'style'])], 't2') + server.alterStoredCopy([ + set('foox', [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text']), + ]) + + expect({ + transactions: server + .getTransactions() + .map((transaction) => transaction.transactionId), + first: server.getCopyAfter('t1'), + latest: server.getCopyAfter('t2'), + copy: server.copy(), + }).toEqual({ + transactions: ['t1', 't2'], + first: [{...value[0], style: 'h1'}], + latest: [ + { + ...value[0], + style: 'h2', + children: [{_type: 'span', _key: 'k1', text: 'foox', marks: []}], + }, + ], + copy: { + value: [ + { + ...value[0], + style: 'h2', + children: [{_type: 'span', _key: 'k1', text: 'foox', marks: []}], + }, + ], + rev: 'r3', + }, + }) + expect(() => server.getCopyAfter('t9')).toThrow('No transaction "t9"') + }) + + test('deleting and recreating the document', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + const recreatedValue = parseTextspec({keyGenerator}, 'B: bar').value + + const deletion = server.deleteDocument('t1') + + expect(deletion).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: undefined, + patches: [unset([])], + mutationIds: [], + }) + expect(server.copy()).toEqual({value: undefined, rev: undefined}) + + const recreation = server.recreate(recreatedValue, 't2') + + expect(recreation).toEqual({ + transactionId: 't2', + previousRev: undefined, + resultRev: 'r2', + patches: [set(recreatedValue, [])], + mutationIds: [], + }) + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + style: 'normal', + }, + ], + rev: 'r2', + }) + expect(server.getTransactions()).toEqual([deletion, recreation]) + expect(server.getTransaction('t2')).toEqual(recreation) + }) + + test('the log marks the transactions that left the field as it was', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'H1: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + const unchanged = server.receive( + {id: 'b1', patches: [set('h1', [{_key: 'k0'}, 'style'])]}, + 't1', + ) + const changed = server.receive( + {id: 'b2', patches: [set('h2', [{_key: 'k0'}, 'style'])]}, + 't2', + ) + const otherField = server.changeOtherField('t3') + const deletion = server.deleteDocument('t4') + + expect(server.getLog()).toEqual([ + {transaction: unchanged, changesField: false}, + {transaction: changed, changesField: true}, + {transaction: otherField, changesField: false}, + {transaction: deletion, changesField: true}, + ]) + }) + + test('a copy does not share content with the server', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createFakeServer({documentId: 'document', document: {value}}) + + const copy = server.copy() + copy.value?.pop() + + expect(server.copy()).toEqual({value, rev: 'r1'}) + }) +}) diff --git a/packages/io/src/fakes/server.ts b/packages/io/src/fakes/server.ts new file mode 100644 index 0000000000..5b38c43bff --- /dev/null +++ b/packages/io/src/fakes/server.ts @@ -0,0 +1,303 @@ +import {set, unset, type Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {applyWithContentLakeSemantics} from '../protocol/content-lake' +import type {RequestFailure} from '../protocol/host' + +/** + * A mutation as the server sees it: the mutation ID and its patches, scoped to the + * field. + */ +export type SavedMutation = {id: string; patches: Array} + +export type ServerTransaction = { + transactionId: string + previousRev: string | undefined + resultRev: string | undefined + patches: Array + mutationIds: Array +} + +/** + * The answer to a save request: saved as a new transaction, refused with a + * 409 `transactionAlreadyExistsError` because the transaction ID is taken, or + * failed with the failure the test injected. + */ +export type SubmitResult = + | {type: 'saved'; transaction: ServerTransaction} + | {type: 'duplicate'} + | {type: 'failed'; status: RequestFailure} + +export type ServerCopy = { + value: Array | undefined + rev: string | undefined +} + +export type Server = { + documentId: string + /** + * Applies the mutation and records a transaction, whether or not anything + * changed. Creates the document if it doesn't exist. Throws when the + * transaction ID is taken. + */ + receive: (mutation: SavedMutation, transactionId: string) => ServerTransaction + /** + * Saves a request's mutations as one transaction, as `receive` does for one, + * unless the transaction ID is taken: then it changes nothing and answers + * 409, as Content Lake does for a retried request. A request that meets an + * injected failure changes nothing and fails with it. + */ + submit: ( + mutations: ReadonlyArray, + transactionId: string, + ) => SubmitResult + /** Whether the document's transaction history lists the ID. */ + hasTransaction: (transactionId: string) => boolean + /** The save requests refused with a 409, in the order they arrived. */ + getDuplicates: () => Array<{ + transactionId: string + mutationIds: Array + }> + /** + * Makes the next request `submit` gets fail with the status, whatever it + * carries. + */ + failNextRequest: (status: RequestFailure) => void + /** The failure the next request meets, if one is injected. */ + getNextFailure: () => RequestFailure | undefined + /** Records a transaction that changes only another field of the document. */ + changeOtherField: (transactionId: string) => ServerTransaction + /** + * Applies the patches to the field and records a transaction, as a script + * does, whatever they leave behind. + */ + patchField: ( + patches: Array, + transactionId: string, + ) => ServerTransaction + /** Records a transaction that sets the whole field, as a script does. */ + setField: ( + value: Array, + transactionId: string, + ) => ServerTransaction + deleteDocument: (transactionId: string) => ServerTransaction + recreate: ( + value: Array, + transactionId: string, + ) => ServerTransaction + copy: () => ServerCopy + /** The field as it was right after the transaction. */ + getCopyAfter: (transactionId: string) => Array | undefined + /** + * Applies the patches to the stored field without recording a + * transaction, whether or not one was recorded before. The copy after the + * latest transaction, if there is one, then holds a change its patches + * don't. + */ + alterStoredCopy: (patches: Array) => void + getTransactions: () => Array + /** + * Every transaction in the order it was recorded, with whether it changed + * the field. + */ + getLog: () => Array<{transaction: ServerTransaction; changesField: boolean}> + getTransaction: (transactionId: string) => ServerTransaction +} + +/** + * A fake Content Lake holding one document. Revisions count up from `r1`, and + * the revision is `undefined` while the document doesn't exist. + */ +export function createFakeServer(initial: { + documentId: string + document: {value: Array | undefined} | undefined +}): Server { + let revisionCounter = 0 + let value: Array | undefined + let rev: string | undefined + const transactions: Array = [] + const copiesAfter = new Map | undefined>() + const log: Array<{transaction: ServerTransaction; changesField: boolean}> = [] + let nextFailure: RequestFailure | undefined + const duplicates: Array<{transactionId: string; mutationIds: Array}> = + [] + + if (initial.document) { + value = initial.document.value + rev = nextRevision() + } + + function hasTransaction(transactionId: string) { + return transactions.some( + (transaction) => transaction.transactionId === transactionId, + ) + } + + function receiveMutations( + mutations: ReadonlyArray, + transactionId: string, + ) { + if (hasTransaction(transactionId)) { + throw new Error(`Transaction "${transactionId}" already exists`) + } + + const patches = mutations.flatMap((mutation) => mutation.patches) + const valueBefore = value + value = applyWithContentLakeSemantics(value, patches) + + return record( + { + transactionId, + patches, + mutationIds: mutations.map((mutation) => mutation.id), + }, + nextRevision(), + JSON.stringify(valueBefore) !== JSON.stringify(value), + ) + } + + function record( + transaction: Omit, + resultRev: string | undefined, + changesField: boolean, + ): ServerTransaction { + const recorded = {...transaction, previousRev: rev, resultRev} + transactions.push(recorded) + copiesAfter.set(recorded.transactionId, structuredClone(value)) + log.push({transaction: recorded, changesField}) + rev = resultRev + return recorded + } + + function nextRevision() { + revisionCounter++ + return `r${revisionCounter}` + } + + return { + documentId: initial.documentId, + receive: (mutation, transactionId) => + receiveMutations([mutation], transactionId), + submit: (mutations, transactionId) => { + if (nextFailure !== undefined) { + const status = nextFailure + nextFailure = undefined + return {type: 'failed', status} + } + + if (hasTransaction(transactionId)) { + duplicates.push({ + transactionId, + mutationIds: mutations.map((mutation) => mutation.id), + }) + return {type: 'duplicate'} + } + + return { + type: 'saved', + transaction: receiveMutations(mutations, transactionId), + } + }, + hasTransaction, + getDuplicates: () => duplicates, + failNextRequest: (status) => { + nextFailure = status + }, + getNextFailure: () => nextFailure, + changeOtherField: (transactionId) => { + if (rev === undefined) { + throw new Error('The document does not exist') + } + + return record( + {transactionId, patches: [], mutationIds: []}, + nextRevision(), + false, + ) + }, + patchField: (patches, transactionId) => { + if (rev === undefined) { + throw new Error('The document does not exist') + } + + const valueBefore = value + value = applyWithContentLakeSemantics(value, patches) + + return record( + {transactionId, patches, mutationIds: []}, + nextRevision(), + JSON.stringify(valueBefore) !== JSON.stringify(value), + ) + }, + setField: (nextValue, transactionId) => { + if (rev === undefined) { + throw new Error('The document does not exist') + } + + const valueBefore = value + value = nextValue + + return record( + {transactionId, patches: [set(nextValue, [])], mutationIds: []}, + nextRevision(), + JSON.stringify(valueBefore) !== JSON.stringify(value), + ) + }, + deleteDocument: (transactionId) => { + if (rev === undefined) { + throw new Error('The document does not exist') + } + + const valueBefore = value + value = undefined + + return record( + {transactionId, patches: [unset([])], mutationIds: []}, + undefined, + valueBefore !== undefined, + ) + }, + recreate: (nextValue, transactionId) => { + if (rev !== undefined) { + throw new Error('The document already exists') + } + + value = nextValue + + return record( + {transactionId, patches: [set(nextValue, [])], mutationIds: []}, + nextRevision(), + true, + ) + }, + copy: () => ({value: structuredClone(value), rev}), + getCopyAfter: (transactionId) => { + if (!copiesAfter.has(transactionId)) { + throw new Error(`No transaction "${transactionId}"`) + } + + return structuredClone(copiesAfter.get(transactionId)) + }, + alterStoredCopy: (patches) => { + const latest = transactions.at(-1) + + value = applyWithContentLakeSemantics(value, patches) + + if (latest) { + copiesAfter.set(latest.transactionId, structuredClone(value)) + } + }, + getTransactions: () => transactions, + getLog: () => log, + getTransaction: (transactionId) => { + const transaction = transactions.find( + (candidate) => candidate.transactionId === transactionId, + ) + + if (!transaction) { + throw new Error(`No transaction "${transactionId}"`) + } + + return transaction + }, + } +} diff --git a/packages/io/src/global.d.ts b/packages/io/src/global.d.ts new file mode 100644 index 0000000000..932715b597 --- /dev/null +++ b/packages/io/src/global.d.ts @@ -0,0 +1,4 @@ +declare module '*.feature?raw' { + const content: string + export default content +} diff --git a/packages/io/src/index.ts b/packages/io/src/index.ts new file mode 100644 index 0000000000..190a05cdd6 --- /dev/null +++ b/packages/io/src/index.ts @@ -0,0 +1,31 @@ +export {createIo} from './protocol/io' +export type { + Clock, + Io, + IoEvent, + IoMessage, + IoSnapshot, + IoStatus, + IoSync, +} from './protocol/io' +export {createPassThroughHost} from './protocol/host' +export type { + FrozenRequest, + PassThroughHost, + RequestFailure, + SaveAnswer, +} from './protocol/host' +export type { + ChangeEvent, + EditorEventForIo, + EditorForIo, + EditorMessageForIo, + ErrorEvent, + Load, + Mutation, + MutationRejected, + MutationSent, + Resync, + Transaction, + WorkDropped, +} from './protocol/types' diff --git a/packages/io/src/protocol/apply.test.ts b/packages/io/src/protocol/apply.test.ts new file mode 100644 index 0000000000..01b38cfb79 --- /dev/null +++ b/packages/io/src/protocol/apply.test.ts @@ -0,0 +1,485 @@ +import {diffMatchPatch, insert, set, unset} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from '../fakes/document' +import {createFakeNetwork} from '../fakes/network' +import {createEditorWithIo} from '../scenario/world' +import {authorInstructions} from './apply' +import {applyWithContentLakeSemantics} from './content-lake' +import {getIoInternals} from './io' + +describe(authorInstructions.name, () => { + test('a remote patch on a block no unsaved work touched reaches the editor as it is', () => { + const {editor, document, received} = createLoadedEditor('B: foo;;B: bar|') + const fooTextPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const remotePatch = diffMatchPatch('foo', 'yfoo', fooTextPath) + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remotePatch], + }) + + expect(document.toTextspec()).toEqual('B: yfoo;;B: barx|') + expect(received.slice(1)).toEqual([ + {type: 'apply', patches: [remotePatch], underneath: [remotePatch]}, + ]) + }) + + test("Scenario: the editor's own echo applies nothing, and a transaction that mixes it with another writer's patch on another block applies only that patch", () => { + const {editor, document, heard, received} = + createLoadedEditor('B: foo|;;B: bar') + const remotePatch = set('h1', [{_key: 'd-k2'}, 'style']) + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + document.type('y') + editor.send({ + type: 'transaction', + transactionId: 'A-tk1', + previousRev: 'r2', + resultRev: 'r3', + patches: [...heard.mutations[1].patches, remotePatch], + }) + + expect(document.toTextspec()).toEqual('B: fooxy|;;H1: bar') + expect(received.slice(1)).toEqual([ + {type: 'apply', patches: [], underneath: heard.mutations[0].patches}, + { + type: 'apply', + patches: [remotePatch], + underneath: [...heard.mutations[1].patches, remotePatch], + }, + ]) + }) + + test("a remote patch on a block the editor's unsaved work also touched becomes a `set` of the block from the working copy", () => { + const {editor, document, received} = createLoadedEditor('B: foo|') + const remotePatch = diffMatchPatch('foo', 'foox', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + 'text', + ]) + + document.setStyle('h2') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remotePatch], + }) + + expect(document.toTextspec()).toEqual('H2: foo|x') + expect(received.slice(1)).toEqual([ + { + type: 'apply', + patches: [ + set( + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foox', marks: []}, + ], + style: 'h2', + }, + [{_key: 'd-k0'}], + ), + ], + underneath: [remotePatch], + }, + ]) + }) + + test("a remote insert after the block the editor's unsaved insert went after lines up in the server's order", () => { + const {editor, document, heard, received} = createLoadedEditor('B: foo|') + const [barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B: bar', + ).value + const remotePatch = insert([barBlock], 'after', [{_key: 'd-k0'}]) + + document.insertBlock('B: baz') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remotePatch], + }) + + expect(document.toTextspec()).toEqual('B: foo;;B: baz|;;B: bar') + expect(received.slice(1)).toEqual([ + { + type: 'apply', + patches: [insert([barBlock], 'after', [{_key: 'a-k2'}])], + underneath: [remotePatch], + }, + ]) + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(getIoInternals(editor).getBase().value).toEqual(document.getValue()) + }) + + test("Scenario: a remote span inserted after the span the editor's unsaved span went after lines up in the server's order", () => { + const {editor, document, received} = createLoadedEditor('B: foo|') + const spanPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}] + const spanKeyGenerator = createTestKeyGenerator('s-') + const barSpan = { + _type: 'span', + _key: spanKeyGenerator(), + text: 'bar', + marks: [], + } + const bazSpan = { + _type: 'span', + _key: spanKeyGenerator(), + text: 'baz', + marks: [], + } + const remotePatch = insert([bazSpan], 'after', spanPath) + + document.applyLocalEdit([insert([barSpan], 'after', spanPath)]) + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remotePatch], + }) + + expect(document.getValue()).toEqual([ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foo', marks: []}, + barSpan, + bazSpan, + ], + style: 'normal', + }, + ]) + expect(received.slice(1)).toEqual([ + { + type: 'apply', + patches: [ + insert([bazSpan], 'after', [ + {_key: 'd-k0'}, + 'children', + {_key: 's-k0'}, + ]), + ], + underneath: [remotePatch], + }, + ]) + }) + + test('a remote removal of the block an unsent insert went after removes both from the screen', () => { + const {editor, document, heard, received} = + createLoadedEditor('B: foo|;;B: bar') + const remotePatch = unset([{_key: 'd-k0'}]) + + document.type('x') + document.insertBlock('B: baz') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remotePatch], + }) + + expect(document.toTextspec()).toEqual('B: |bar') + expect(received.slice(1)).toEqual([ + { + type: 'apply', + patches: [unset([{_key: 'd-k0'}]), unset([{_key: 'a-k2'}])], + underneath: [remotePatch], + }, + ]) + expect(heard.workDropped).toEqual([ + { + patches: [ + insert( + [ + { + _type: 'block', + _key: 'a-k2', + children: [ + {_type: 'span', _key: 'a-k3', text: 'baz', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k0'}], + ), + ], + reason: 'no target', + }, + ]) + }) + + test('Scenario: a transaction that moves the block the editor typed in, by removing it and inserting its key after another block, lines up the block list, with `value` or without', () => { + const results = [false, true].map((carriesValue) => { + const {editor, document, received, treeMismatches} = + createLoadedEditor('B: foo|;;B: bar') + const [fooBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo', + ).value + const patches = [ + unset([{_key: 'd-k0'}]), + insert([fooBlock], 'after', [{_key: 'd-k2'}]), + ] + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches, + ...(carriesValue + ? { + value: applyWithContentLakeSemantics( + getIoInternals(editor).getBase().value, + patches, + ), + } + : {}), + }) + + return { + apply: received.at(-1), + screen: document.toTextspec({keys: true}), + treeMismatches, + } + }) + const expected = { + apply: { + type: 'apply', + patches: [ + unset([{_key: 'd-k0'}]), + insert( + [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foox', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k2'}], + ), + ], + underneath: [ + unset([{_key: 'd-k0'}]), + insert( + [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k2'}], + ), + ], + }, + screen: 'B _key="d-k2": |bar;;B _key="d-k0": foox', + treeMismatches: [], + } + + expect(results).toEqual([expected, expected]) + }) + + test('Scenario: a transaction that changes the key of the block the editor typed in and inserts a block after the new key lines up the block list, with `value` or without', () => { + const results = [false, true].map((carriesValue) => { + const {editor, document, received, treeMismatches} = + createLoadedEditor('B: foo|;;B: bar') + const [quxBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B: qux', + ).value + const patches = [ + set('k9', [{_key: 'd-k0'}, '_key']), + insert([quxBlock], 'after', [{_key: 'k9'}]), + ] + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches, + ...(carriesValue + ? { + value: applyWithContentLakeSemantics( + getIoInternals(editor).getBase().value, + patches, + ), + } + : {}), + }) + + return { + apply: received.at(-1), + screen: document.toTextspec({keys: true}), + treeMismatches, + } + }) + const expected = { + apply: { + type: 'apply', + patches: [ + unset([{_key: 'd-k0'}]), + insert( + [ + { + _type: 'block', + _key: 'k9', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + 'before', + [{_key: 'd-k2'}], + ), + insert( + [ + { + _type: 'block', + _key: 'b-k0', + children: [ + {_type: 'span', _key: 'b-k1', text: 'qux', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'k9'}], + ), + ], + underneath: [ + set('k9', [{_key: 'd-k0'}, '_key']), + insert( + [ + { + _type: 'block', + _key: 'b-k0', + children: [ + {_type: 'span', _key: 'b-k1', text: 'qux', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'k9'}], + ), + ], + }, + screen: 'B _key="k9": foo;;B _key="b-k0": qux;;B _key="d-k2": |bar', + treeMismatches: [], + } + + expect(results).toEqual([expected, expected]) + }) + + test('Scenario: a remote patch addressed by index in a stored array with a block that is not an object reaches the editor addressed by key', () => { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + received, + treeMismatches, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const value = applyWithContentLakeSemantics( + parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: left;;B: oops;;B: right', + ).value, + [set('oops', [1])], + ) + const remotePatch = set('h1', [2, 'style']) + + editor.send({type: 'load', value, rev: 'r1'}) + document.mount() + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remotePatch], + }) + + expect(received.at(-1)).toEqual({ + type: 'apply', + patches: [set('h1', [{_key: 'd-k4'}, 'style'])], + underneath: [remotePatch], + }) + expect(document.toTextspec()).toEqual('B: |left;;H1: right') + expect(treeMismatches).toEqual([]) + }) +}) + +function createLoadedEditor(textspec: string | undefined) { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + received, + treeMismatches, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const {value, caret} = + textspec === undefined + ? {value: undefined, caret: undefined} + : parseTextspec({keyGenerator: createTestKeyGenerator('d-')}, textspec) + + editor.send({type: 'load', value, rev: 'r1'}) + document.mount() + + if (caret) { + document.setCaret(caret) + } + + return {editor, document, clock, heard, received, treeMismatches} +} diff --git a/packages/io/src/protocol/apply.ts b/packages/io/src/protocol/apply.ts new file mode 100644 index 0000000000..ed5d558693 --- /dev/null +++ b/packages/io/src/protocol/apply.ts @@ -0,0 +1,419 @@ +import { + insert, + set, + setIfMissing, + unset, + type Patch, +} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {applyWithContentLakeSemantics} from './content-lake' +import {blocksForEditor, isObject} from './floor' +import {childrenOf, findBlock, isEqual, itemKey, keyOf} from './nodes' + +/** + * The instructions that take the editor's tree from `shown` to `wanted` for + * other writers' `patches`, given the `unlanded` work the editor had on top + * of the base, decided per list (the block list, or a block's `children`). + * A list is lined up against `wanted` key by key when the patches insert + * into it, remove from it or change a key in it while unlanded work touched + * it (changed its items or anything in them), and whenever the patches + * change a key in it: none of the patches on that list is forwarded, since + * a later patch can depend on an earlier one (an insert after a new key, a + * block removed and inserted again elsewhere). A patch on the whole field, + * or by index, while there is unlanded work lines up the block list. + * + * On a list that isn't lined up, a patch on a place no unlanded patch + * touched is forwarded as it is, and a patch on a block unlanded work + * touched becomes a `set` of the block from `wanted` (or an `unset` when + * `wanted` lost it), since the server applied the editor's work after the + * patch and the screen applied it before. + * + * With no unlanded work, a patch addressed by index in `stored`, the base + * the patches apply to, is addressed to the editor's block first (see + * `addressForEditor`), since the editor leaves out the stored blocks that + * aren't objects. + * + * The forwarded patches go first: they touch nothing the rest lines up, and + * a forwarded patch into a block a later instruction inserts does nothing, + * as the block arrives from `wanted` with it applied. + */ +export function authorInstructions({ + stored, + shown, + wanted, + patches, + unlanded, +}: { + stored: Array | undefined + shown: Array | undefined + wanted: Array | undefined + patches: Array + unlanded: Array +}): Array { + const addressed = + unlanded.length === 0 ? addressForEditor(patches, stored) : patches + + if (addressed === undefined) { + return lineUpList(shown ?? [], wanted ?? [], []) + } + + const touched = touchedPlaces(unlanded) + const places = addressed.map((patch) => ({patch, place: placeOf(patch)})) + const lineUpBlockList = places.some( + ({place}) => + (place.type === 'field' && unlanded.length > 0) || + (place.type === 'block list' && + (place.change === 'key' || + (place.change === 'membership' && touched.blockList))), + ) + const linedUpChildLists = new Set( + places.flatMap(({place}) => + place.type === 'child list' && + (place.change === 'key' || + (place.change === 'membership' && + touched.childLists.has(place.blockKey))) + ? [place.blockKey] + : [], + ), + ) + const forwarded: Array = [] + const conflictingBlocks = new Set() + + for (const {patch, place} of places) { + if (place.type === 'field' || touched.field) { + if (unlanded.length === 0) { + forwarded.push(patch) + } + } else if (place.type === 'block list' && lineUpBlockList) { + continue + } else if ( + place.type === 'child list' && + linedUpChildLists.has(place.blockKey) + ) { + continue + } else if (touched.blocks.has(place.blockKey)) { + conflictingBlocks.add(place.blockKey) + } else { + forwarded.push(patch) + } + } + + const current = applyWithContentLakeSemantics(shown, forwarded) ?? [] + const target = wanted ?? [] + + if (lineUpBlockList || touched.field) { + return [...forwarded, ...lineUpList(current, target, [])] + } + + const fixes: Array = [] + + for (const blockKey of new Set([ + ...conflictingBlocks, + ...linedUpChildLists, + ])) { + const shownBlock = findBlock(current, blockKey) + const wantedBlock = findBlock(target, blockKey) + + if (wantedBlock === undefined) { + fixes.push(...(shownBlock ? [unset([{_key: blockKey}])] : [])) + } else if (shownBlock === undefined) { + return [...forwarded, ...lineUpList(current, target, [])] + } else if ( + !conflictingBlocks.has(blockKey) && + isEqual( + {...shownBlock, children: undefined}, + {...wantedBlock, children: undefined}, + ) + ) { + fixes.push( + ...lineUpList(childrenOf(shownBlock), childrenOf(wantedBlock), [ + {_key: blockKey}, + 'children', + ]), + ) + } else if (!isEqual(shownBlock, wantedBlock)) { + fixes.push(set(wantedBlock, [{_key: blockKey}])) + } + } + + return [...forwarded, ...fixes] +} + +/** + * `patches` with each path that starts with an index in the stored array, + * taken patch by patch from `stored`, addressed to that block in the + * editor instead: by its key, or else by its position among the blocks + * that are objects. `undefined` when a patch by index targets no block + * that is an object, or brings a block that isn't one. + */ +function addressForEditor( + patches: Array, + stored: Array | undefined, +): Array | undefined { + const addressed: Array = [] + let value = stored + + for (const patch of patches) { + const [head, ...tail] = patch.path + + if (typeof head === 'number') { + const storedBlocks = value ?? [] + const {blocks, storedIndexes} = blocksForEditor(storedBlocks) + const position = storedIndexes.indexOf( + head < 0 ? storedBlocks.length + head : head, + ) + + if (position === -1 || bringsNonObjectBlock(patch)) { + return undefined + } + + const [key] = itemKey(blocks[position]) + + addressed.push({ + ...patch, + path: [key === undefined ? position : {_key: key}, ...tail], + }) + } else { + addressed.push(patch) + } + + value = applyWithContentLakeSemantics(value, [patch]) + } + + return addressed +} + +function bringsNonObjectBlock(patch: Patch): boolean { + if (patch.path.length !== 1) { + return false + } + + if (patch.type === 'insert') { + return patch.items.some((item) => !isObject(item)) + } + + return patch.type === 'set' && !isObject(patch.value) +} + +/** + * Where a patch acts: the whole `field` (a path that is empty or starts + * with an index), the `block list` (a block, or a field of a block outside + * its keyed children), or the `child list` of `blockKey` (a keyed child, or + * anything under one). `change` says whether the patch changes the list's + * `membership` (an insert into it, or an `unset` of one of its items) or + * an item's `key` (a patch on its `_key`, or a `set` of the item whose + * value has another `_key`). + */ +function placeOf( + patch: Patch, +): + | {type: 'field'} + | {type: 'block list'; blockKey: string; change: ListChange} + | {type: 'child list'; blockKey: string; change: ListChange} { + const [head, field, childSegment] = patch.path + const blockKey = keyOf(head) + + if (blockKey === undefined) { + return {type: 'field'} + } + + const childKey = field === 'children' ? keyOf(childSegment) : undefined + + if (childKey === undefined) { + return { + type: 'block list', + blockKey, + change: listChangeOf(patch, 1, blockKey), + } + } + + return { + type: 'child list', + blockKey, + change: listChangeOf(patch, 3, childKey), + } +} + +type ListChange = 'membership' | 'key' | undefined + +/** + * What a patch does to the list whose item is at `itemDepth` in its path, + * the item keyed `key`. + */ +function listChangeOf( + patch: Patch, + itemDepth: number, + key: string, +): ListChange { + const {path} = patch + + if ( + path.length === itemDepth && + (patch.type === 'insert' || patch.type === 'unset') + ) { + return 'membership' + } + + if (path.length === itemDepth + 1 && path[itemDepth] === '_key') { + return 'key' + } + + if ( + path.length === itemDepth && + patch.type === 'set' && + key !== itemKey(patch.value)[0] + ) { + return 'key' + } + + return undefined +} + +/** + * The places unlanded work touched: the whole `field`; the block list, by + * any patch on or under a block; the `children` lists, by any patch on or + * under one of their children; and the blocks it acted on or under for + * anything but their removal. A patch under a removed block finds nothing + * on either side. + */ +function touchedPlaces(unlanded: Array): { + field: boolean + blockList: boolean + childLists: Set + blocks: Set +} { + const touched = { + field: false, + blockList: false, + childLists: new Set(), + blocks: new Set(), + } + + for (const patch of unlanded) { + const place = placeOf(patch) + + if (place.type === 'field') { + touched.field = true + continue + } + + touched.blockList = true + + if (place.type === 'child list') { + touched.childLists.add(place.blockKey) + } + + if (place.type === 'child list' || place.change !== 'membership') { + touched.blocks.add(place.blockKey) + } + } + + return touched +} + +/** + * Keyed instructions that line `shown` up with `wanted`, the list at + * `listPath`: the longest run of keys in the same order on both sides stays, + * every other shown item is removed, every other wanted item is inserted + * next to a keyed sibling that is there by then, and an item that stays but + * differs is `set`. A list with a missing or repeated key gets a `set` of the + * whole list, and an empty one gets its items by index. + */ +export function lineUpList( + shown: Array, + wanted: Array, + listPath: Patch['path'], +): Array { + const shownKeys = shown.map((item) => itemKey(item)[0]) + const wantedKeys = wanted.map((item) => itemKey(item)[0]) + + if (!hasUniqueKeys(shownKeys) || !hasUniqueKeys(wantedKeys)) { + return [set(wanted, listPath)] + } + + if (shown.length === 0) { + return wanted.length === 0 + ? [] + : [setIfMissing([], listPath), insert(wanted, 'before', [...listPath, 0])] + } + + const stays = new Set(longestCommonRun(shownKeys, wantedKeys)) + const itemPath = (key: string) => [...listPath, {_key: key}] + const firstStaying = wantedKeys.find((key) => stays.has(key)) + + if (firstStaying === undefined) { + return [ + ...(wanted.length > 0 + ? [insert(wanted, 'before', itemPath(shownKeys[0]))] + : []), + ...shownKeys.map((key) => unset(itemPath(key))), + ] + } + + const removals = shownKeys + .filter((key) => !stays.has(key)) + .map((key) => unset(itemPath(key))) + const inserts = wanted.flatMap((item, index) => { + if (stays.has(wantedKeys[index])) { + return [] + } + + const previousKey = wantedKeys[index - 1] + + return previousKey === undefined + ? [insert([item], 'before', itemPath(firstStaying))] + : [insert([item], 'after', itemPath(previousKey))] + }) + const sets = wanted.flatMap((item, index) => { + const key = wantedKeys[index] + + return stays.has(key) && !isEqual(shown[shownKeys.indexOf(key)], item) + ? [set(item, itemPath(key))] + : [] + }) + + return [...removals, ...inserts, ...sets] +} + +function hasUniqueKeys(keys: Array): keys is Array { + return ( + keys.every((key) => key !== undefined) && new Set(keys).size === keys.length + ) +} + +/** The longest sequence of keys that appears in both lists in order. */ +function longestCommonRun( + keysA: Array, + keysB: Array, +): Array { + const lengths = Array.from({length: keysA.length + 1}, () => + Array.from({length: keysB.length + 1}, () => 0), + ) + + for (let indexA = keysA.length - 1; indexA >= 0; indexA--) { + for (let indexB = keysB.length - 1; indexB >= 0; indexB--) { + lengths[indexA][indexB] = + keysA[indexA] === keysB[indexB] + ? lengths[indexA + 1][indexB + 1] + 1 + : Math.max(lengths[indexA + 1][indexB], lengths[indexA][indexB + 1]) + } + } + + const run: Array = [] + let indexA = 0 + let indexB = 0 + + while (indexA < keysA.length && indexB < keysB.length) { + if (keysA[indexA] === keysB[indexB]) { + run.push(keysA[indexA]) + indexA++ + indexB++ + } else if (lengths[indexA + 1][indexB] >= lengths[indexA][indexB + 1]) { + indexA++ + } else { + indexB++ + } + } + + return run +} diff --git a/packages/io/src/protocol/content-lake.test.ts b/packages/io/src/protocol/content-lake.test.ts new file mode 100644 index 0000000000..29e675e4d0 --- /dev/null +++ b/packages/io/src/protocol/content-lake.test.ts @@ -0,0 +1,130 @@ +import {diffMatchPatch, insert, set, unset} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from '../fakes/document' +import {applyWithContentLakeSemantics, hasTarget} from './content-lake' + +describe(applyWithContentLakeSemantics.name, () => { + test('a patch whose parent is missing does nothing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + + expect( + applyWithContentLakeSemantics(value, [ + set('h1', [{_key: 'k9'}, 'style']), + diffMatchPatch('bar', 'bary', [ + {_key: 'k0'}, + 'children', + {_key: 'k9'}, + 'text', + ]), + set('x', [{_key: 'k0'}, 'markDefs', {_key: 'k9'}, 'href']), + ]), + ).toEqual(value) + expect( + applyWithContentLakeSemantics(undefined, [ + set('h1', [{_key: 'k0'}, 'style']), + insert(value, 'before', [0]), + ]), + ).toEqual(undefined) + }) + + test('a `set` through a primitive replaces it with the structure the path names, and other patches through one do nothing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + + expect( + applyWithContentLakeSemantics(value, [ + set('x', [{_key: 'k0'}, 'style', 'name', 'first']), + ]), + ).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: {name: {first: 'x'}}, + }, + ]) + expect( + applyWithContentLakeSemantics(value, [ + set('x', [{_key: 'k0'}, 'style', {_key: 'k9'}]), + unset([{_key: 'k0'}, 'children', {_key: 'k1'}, 'text', 0]), + insert(['x'], 'after', [{_key: 'k0'}, 'style', 0]), + ]), + ).toEqual(value) + }) + + test('a `set` replaces an object or a list with a value of another kind', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;B: bar') + + expect( + applyWithContentLakeSemantics(value, [ + set('oops', [{_key: 'k2'}]), + set('oops', [{_key: 'k0'}, 'children']), + ]), + ).toEqual([ + {_type: 'block', _key: 'k0', children: 'oops', style: 'normal'}, + 'oops', + ]) + expect( + applyWithContentLakeSemantics(value, [ + set(42, [1]), + set(['x'], [{_key: 'k0'}, 'children', {_key: 'k1'}]), + ]), + ).toEqual([ + {_type: 'block', _key: 'k0', children: [['x']], style: 'normal'}, + 42, + ]) + }) + + test('a `diffMatchPatch` on anything but a string fails, unless its keyed target is missing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + + expect(() => + applyWithContentLakeSemantics(value, [ + diffMatchPatch('foo', 'foox', [{_key: 'k0'}, 'children']), + ]), + ).toThrow("Can't apply a `diffMatchPatch` to [") + expect(() => + applyWithContentLakeSemantics(value, [ + diffMatchPatch('foo', 'foox', [{_key: 'k0'}, 'listItem']), + ]), + ).toThrow("Can't apply a `diffMatchPatch` to null") + expect(() => + applyWithContentLakeSemantics(value, [ + diffMatchPatch('foo', 'foox', [{_key: 'k0'}, 'style', 'name']), + ]), + ).toThrow("Can't apply a `diffMatchPatch` through a string") + expect( + applyWithContentLakeSemantics(value, [ + diffMatchPatch('foo', 'foox', [{_key: 'k0'}, 'children', {_key: 'k9'}]), + ]), + ).toEqual(value) + }) +}) + +describe(hasTarget.name, () => { + test('needs the named item, or an existing object for a field', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + + expect(hasTarget(value, set('h1', [{_key: 'k0'}, 'style']))).toEqual(true) + expect(hasTarget(value, set('x', [{_key: 'k0'}, 'listItem']))).toEqual(true) + expect(hasTarget(value, unset([{_key: 'k0'}]))).toEqual(true) + expect(hasTarget(value, insert([], 'after', [{_key: 'k0'}]))).toEqual(true) + expect(hasTarget(value, unset([{_key: 'k9'}]))).toEqual(false) + expect(hasTarget(value, set('h1', [{_key: 'k9'}, 'style']))).toEqual(false) + expect(hasTarget(undefined, insert([], 'after', [{_key: 'k0'}]))).toEqual( + false, + ) + expect(hasTarget(undefined, set([], []))).toEqual(true) + expect(hasTarget(value, set('x', [{_key: 'k0'}, 'style', 'name']))).toEqual( + true, + ) + expect(hasTarget(value, unset([{_key: 'k0'}, 'style', 'name']))).toEqual( + false, + ) + }) +}) diff --git a/packages/io/src/protocol/content-lake.ts b/packages/io/src/protocol/content-lake.ts new file mode 100644 index 0000000000..0e4b8933e6 --- /dev/null +++ b/packages/io/src/protocol/content-lake.ts @@ -0,0 +1,260 @@ +import { + applyAll, + set, + type Patch, + type Path, + type PathSegment, +} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' + +/** + * Applies patches the way Content Lake does: a patch whose target is gone is + * a no-op. `applyAll` already skips a keyed segment it can't find inside an + * existing array, but throws when a path runs into a missing field, so those + * patches are skipped here. A `set` through a string, number or boolean + * replaces it with the structure the rest of the path names, and any other + * patch through one selects nothing. A `set` replaces an object or a list + * with whatever it carries, a string or a number included, which + * `applyAll` refuses. A `diffMatchPatch` on anything but a string fails, as + * does one on a missing field that isn't keyed. Duplicate keys are stored + * as sent. + */ +export function applyWithContentLakeSemantics( + value: Array | undefined, + patches: Array, +): Array | undefined { + return patches.reduce | undefined>( + (currentValue, patch) => applyPatch(currentValue, patch), + value, + ) +} + +function applyPatch( + value: Array | undefined, + patch: Patch, +): Array | undefined { + const location = locate(value, patch.path) + + if (location.type === 'missing') { + return value + } + + if (location.type === 'primitive') { + if (patch.type === 'diffMatchPatch') { + throw new Error( + `Can't apply a \`diffMatchPatch\` through a ${location.primitiveType}`, + ) + } + + const replacement = + patch.type === 'set' + ? nestUnder(patch.path.slice(location.depth), patch.value) + : undefined + + return replacement === undefined + ? value + : applyAll(value, [ + set(replacement.value, patch.path.slice(0, location.depth)), + ]) + } + + if (patch.type === 'set' && patch.path.length > 0) { + const target = resolvePath(value, patch.path) + + if (isOfOtherKind(target, patch.value)) { + return replaceAt(value, patch.path, patch.value) + } + } + + if (patch.type === 'diffMatchPatch') { + const target = resolvePath(value, patch.path) + const last = patch.path.at(-1) + + if (target === undefined && isKeyedSegment(last)) { + return value + } + + if (typeof target !== 'string') { + throw new Error( + `Can't apply a \`diffMatchPatch\` to ${JSON.stringify(target ?? null)}`, + ) + } + } + + return applyAll(value, [patch]) +} + +/** + * Whether `target` is an object or a list and `replacement` isn't the same + * kind, which `applyAll` refuses to `set`. + */ +function isOfOtherKind(target: unknown, replacement: unknown): boolean { + if (typeof target !== 'object' || target === null) { + return false + } + + return Array.isArray(target) + ? !Array.isArray(replacement) + : typeof replacement !== 'object' || + replacement === null || + Array.isArray(replacement) +} + +/** + * Replaces the item or field at `path` in its parent, and sets the parent, + * which keeps its kind, in its place. + */ +function replaceAt( + value: Array | undefined, + path: Path, + replacement: unknown, +): Array | undefined { + const parentPath = path.slice(0, -1) + const parent = resolvePath(value, parentPath) + const last = path[path.length - 1] + let nextParent: unknown + + if (Array.isArray(parent)) { + const index = + typeof last === 'number' + ? last + : parent.findIndex( + (item: unknown) => + typeof last === 'object' && + !Array.isArray(last) && + typeof item === 'object' && + item !== null && + Reflect.get(item, '_key') === last._key, + ) + nextParent = parent.map((item: unknown, itemIndex) => + itemIndex === index ? replacement : item, + ) + } else if (typeof parent === 'object' && parent !== null) { + nextParent = {...parent, [String(last)]: replacement} + } + + return parentPath.length === 0 + ? applyAll(value, [set(nextParent, [])]) + : applyPatch(value, set(nextParent, parentPath)) +} + +/** + * Follows a path into a value. Returns `undefined` when any segment is + * missing. + */ +export function resolvePath(value: unknown, path: Path): unknown { + let current = value + + for (const segment of path) { + current = resolveSegment(current, segment) + } + + return current +} + +/** + * Whether a patch has something to act on: the item it names, or a field + * inside an existing object. An `insert` names the item it goes next to. A + * `set` through a primitive acts on it, and any other patch through one + * selects nothing. + */ +export function hasTarget( + value: Array | undefined, + patch: Patch, +): boolean { + const location = locate(value, patch.path) + + if (location.type !== 'reachable') { + return location.type === 'primitive' && patch.type === 'set' + } + + const last = patch.path.at(-1) + + if (last === undefined || typeof last === 'string') { + return true + } + + return resolvePath(value, patch.path) !== undefined +} + +/** + * Walks the containers a path runs through: `reachable` when each one is an + * object or a list, `missing` when one isn't there, and `primitive` when the + * value at `path.slice(0, depth)` is a string, number or boolean the rest of + * the path runs into. + */ +function locate( + value: unknown, + path: Path, +): + | {type: 'reachable'} + | {type: 'missing'} + | {type: 'primitive'; depth: number; primitiveType: string} { + let container = value + + for (const [depth, segment] of path.entries()) { + if (container === undefined || container === null) { + return {type: 'missing'} + } + + if (typeof container !== 'object') { + return {type: 'primitive', depth, primitiveType: typeof container} + } + + container = resolveSegment(container, segment) + } + + return {type: 'reachable'} +} + +/** + * The object a path of field names builds around a value, or `undefined` + * when the path has a keyed or index segment, which selects nothing. + */ +function nestUnder(path: Path, value: unknown): {value: unknown} | undefined { + let nested: unknown = value + + for (const segment of [...path].reverse()) { + if (typeof segment !== 'string') { + return undefined + } + + nested = {[segment]: nested} + } + + return {value: nested} +} + +function isKeyedSegment(segment: PathSegment | undefined): boolean { + return typeof segment === 'object' && !Array.isArray(segment) +} + +function resolveSegment(container: unknown, segment: PathSegment): unknown { + if (Array.isArray(container)) { + if (typeof segment === 'number') { + return container[segment] + } + + if (typeof segment === 'object' && '_key' in segment) { + return container.find( + (item: unknown) => + typeof item === 'object' && + item !== null && + '_key' in item && + item._key === segment._key, + ) + } + + return undefined + } + + if ( + typeof container === 'object' && + container !== null && + typeof segment === 'string' + ) { + return Reflect.get(container, segment) + } + + return undefined +} diff --git a/packages/io/src/protocol/floor.ts b/packages/io/src/protocol/floor.ts new file mode 100644 index 0000000000..6cd445f8ba --- /dev/null +++ b/packages/io/src/protocol/floor.ts @@ -0,0 +1,265 @@ +import {set, unset, type Patch} from '@portabletext/patches' +import type {Load} from './types' + +/** + * The floor: the shapes the editor cannot hold at all. A block is below it + * when it isn't an object, has no `_key` or `_type`, is a text block + * (`_type: 'block'`) whose `children` isn't a non-empty array of objects, or + * is a text block with a child that has no `_key` or `_type`, or a span + * (`_type: 'span'`) whose `text` isn't a string. Content Lake stores all of + * these. + */ +export function isBelowFloor(block: unknown): boolean { + if (!isObject(block) || !hasName(block, '_key') || !hasName(block, '_type')) { + return true + } + + if (block['_type'] !== 'block') { + return false + } + + const children = block['children'] + + return ( + !isNonEmptyArrayOfObjects(children) || + children.some( + (child) => + !hasName(child, '_key') || + !hasName(child, '_type') || + (child['_type'] === 'span' && typeof child['text'] !== 'string'), + ) + ) +} + +/** + * What the editor gets of a stored value: its blocks that are objects, and + * for each of them, by the editor's position, its index in the stored + * array. + */ +export function blocksForEditor(value: Array): { + blocks: Array + storedIndexes: Array +} { + const blocks: Array = [] + const storedIndexes: Array = [] + + for (const [storedIndex, block] of value.entries()) { + if (isObject(block)) { + blocks.push(block) + storedIndexes.push(storedIndex) + } + } + + return {blocks, storedIndexes} +} + +/** + * The patches that bring a received whole value up to the floor and repair + * its missing and duplicate keys, against the stored array, in block order. + * A block that isn't an object gets none: the editor leaves it out, and + * each other block is addressed by its index in the stored array when its + * key is missing or repeats an earlier one, and by key otherwise, and so is + * each child. The first of each duplicate key keeps it. + * + * A missing `_key` or one that repeats a sibling's gets a repair key (see + * `mintRepairKey`), a missing `_type` becomes `'block'`, and a missing + * `_type` on a text block's child `'span'`. A text block's child that + * isn't an object is removed, by index, last first. `children` that isn't an + * array, or holds no object, becomes one empty span with a repair key. A + * span `text` that isn't a string becomes `''`. + */ +export function repairToFloor({value, rev}: Load): Array { + const {blocks: storedBlocks, storedIndexes} = blocksForEditor(value ?? []) + const blocks = storedBlocks.map( + (block): Record => ({...block}), + ) + const patches: Array = [] + const takenKeys = new Set( + blocks.flatMap((block) => [ + ...nameOf(block, '_key'), + ...objectsIn(block['children']).flatMap((child) => nameOf(child, '_key')), + ]), + ) + const blockKeys = new Set() + + for (const [position, block] of blocks.entries()) { + const storedIndex = storedIndexes[position] + const [blockKey] = nameOf(block, '_key') + let blockPath: Patch['path'] = [{_key: blockKey ?? ''}] + + if (blockKey === undefined || blockKeys.has(blockKey)) { + blockPath = [storedIndex] + patches.push( + set(mintRepairKey(rev, [storedIndex], takenKeys), [ + storedIndex, + '_key', + ]), + ) + } else { + blockKeys.add(blockKey) + } + + if (!hasName(block, '_type')) { + patches.push(set('block', [...blockPath, '_type'])) + } + + if ((nameOf(block, '_type')[0] ?? 'block') !== 'block') { + continue + } + + const storedChildren = block['children'] + const children: Array = Array.isArray(storedChildren) + ? storedChildren + : [] + const objectChildren = children.flatMap((child, storedChildIndex) => + isObject(child) ? [{child, storedChildIndex}] : [], + ) + + if (objectChildren.length === 0) { + patches.push( + set( + [ + { + _type: 'span', + _key: mintRepairKey(rev, [storedIndex, 'children', 0], takenKeys), + text: '', + marks: [], + }, + ], + [...blockPath, 'children'], + ), + ) + continue + } + + for (const [storedChildIndex, child] of [...children.entries()].reverse()) { + if (!isObject(child)) { + patches.push(unset([...blockPath, 'children', storedChildIndex])) + } + } + + patches.push( + ...repairChildren({ + children: objectChildren, + rev, + storedPath: [storedIndex, 'children'], + childrenPath: [...blockPath, 'children'], + takenKeys, + }), + ) + } + + return patches +} + +function repairChildren({ + children, + rev, + storedPath, + childrenPath, + takenKeys, +}: { + children: Array<{child: Record; storedChildIndex: number}> + rev: string | undefined + storedPath: Array + childrenPath: Patch['path'] + takenKeys: Set +}): Array { + const patches: Array = [] + const childKeys = new Set() + + for (const [childIndex, {child, storedChildIndex}] of children.entries()) { + const [childKey] = nameOf(child, '_key') + let childPath: Patch['path'] = [...childrenPath, {_key: childKey ?? ''}] + + if (childKey === undefined || childKeys.has(childKey)) { + childPath = [...childrenPath, childIndex] + patches.push( + set(mintRepairKey(rev, [...storedPath, storedChildIndex], takenKeys), [ + ...childPath, + '_key', + ]), + ) + } else { + childKeys.add(childKey) + } + + if (!hasName(child, '_type')) { + patches.push(set('span', [...childPath, '_type'])) + } + + if ( + (nameOf(child, '_type')[0] ?? 'span') === 'span' && + typeof child['text'] !== 'string' + ) { + patches.push(set('', [...childPath, 'text'])) + } + } + + return patches +} + +/** + * The key a repair gives the node at `path` in the value received at + * `rev`: the 32-bit FNV-1a hash of `/`, + * as eight hex digits, with `#` appended to the input for each + * attempt whose key is taken. An `undefined` revision hashes as the empty + * string. Every editor that repairs the same revision received the same + * value, so it hashes the same inputs and the taken-key suffix resolves the + * same way: the editors mint the same keys, and their repairs agree instead + * of racing. Marks the key as taken. + */ +function mintRepairKey( + rev: string | undefined, + path: Array, + takenKeys: Set, +): string { + const input = [rev ?? '', ...path].join('/') + let attempt = 0 + let key = fnv1a(input) + + while (takenKeys.has(key)) { + attempt++ + key = fnv1a(`${input}#${attempt}`) + } + + takenKeys.add(key) + + return key +} + +function fnv1a(input: string): string { + let hash = 0x811c9dc5 + + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + + return hash.toString(16).padStart(8, '0') +} + +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNonEmptyArrayOfObjects( + value: unknown, +): value is Array> { + return Array.isArray(value) && value.length > 0 && value.every(isObject) +} + +function objectsIn(value: unknown): Array> { + return Array.isArray(value) ? value.filter(isObject) : [] +} + +/** A non-empty string at `field`, as a list of zero or one. */ +function nameOf(item: Record, field: string): Array { + const name = item[field] + + return typeof name === 'string' && name !== '' ? [name] : [] +} + +function hasName(item: Record, field: string): boolean { + return nameOf(item, field).length > 0 +} diff --git a/packages/io/src/protocol/host.test.ts b/packages/io/src/protocol/host.test.ts new file mode 100644 index 0000000000..a391fed2f7 --- /dev/null +++ b/packages/io/src/protocol/host.test.ts @@ -0,0 +1,645 @@ +import {diffMatchPatch, set} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from '../fakes/document' +import {createFakeNetwork} from '../fakes/network' +import {createEditorWithIo, createWorld} from '../scenario/world' +import {createPassThroughHost, type RequestFailure} from './host' +import {getIoInternals} from './io' +import type {Load, Mutation, MutationSent, Transaction} from './types' + +describe(createPassThroughHost.name, () => { + test('a transaction the resync copy covers is dropped, and the next one is forwarded', () => { + const {editor, document, host, heard, clock, feed, serverCopy} = + createHostedEditor('B: foo|') + const coveredTransaction: Transaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + } + const nextTransaction: Transaction = { + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h2', [{_key: 'd-k0'}, 'style'])], + } + + feed.push(coveredTransaction) + serverCopy.current = { + value: parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'H1: foo', + ).value, + rev: 'r2', + } + host.resync({discardUnsent: false}) + host.forward(coveredTransaction) + clock.advance(10_000) + + expect(heard.errors).toEqual([]) + expect(getIoInternals(editor).getBase().rev).toEqual('r2') + expect(document.toTextspec()).toEqual('H1: foo|') + + host.forward(nextTransaction) + + expect(getIoInternals(editor).getBase().rev).toEqual('r3') + expect(document.toTextspec()).toEqual('H2: foo|') + }) + + test('a transaction delivered late is dropped when a transaction the editor held links it to the resync copy', () => { + const {editor, document, host, heard, clock, feed, serverCopy} = + createHostedEditor('B: foo|') + const firstTransaction: Transaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + } + const secondTransaction: Transaction = { + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h2', [{_key: 'd-k0'}, 'style'])], + } + + feed.push(firstTransaction) + host.forward(secondTransaction) + serverCopy.current = { + value: parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'H2: foo', + ).value, + rev: 'r3', + } + host.resync({discardUnsent: false}) + host.forward(firstTransaction) + clock.advance(10_000) + + expect(heard.errors).toEqual([]) + expect(getIoInternals(editor).getBase().rev).toEqual('r3') + expect(document.toTextspec()).toEqual('H2: foo|') + }) + + test('a transaction that skips ahead after the load reaches the editor', () => { + const {editor, document, host, heard} = createHostedEditor('B: foo|') + + host.forward({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h2', [{_key: 'd-k0'}, 'style'])], + }) + host.forward({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + + expect(heard.errors).toEqual([]) + expect(getIoInternals(editor).getBase().rev).toEqual('r3') + expect(document.toTextspec()).toEqual('H2: foo|') + }) + + test('a plain host saves each mutation under the transaction ID it proposes and sends no `mutation sent`', () => { + const {editor, document, host, heard, mutationsSent} = + createHostedEditor('B: foo|') + + document.type('x') + host.reportSaveTaken('A-1') + + expect({ + transactionId: host.getTransactionId('A-1'), + inFlight: getIoInternals(editor).inspect().inFlight, + mutationsSent, + warnings: heard.warnings, + }).toEqual({ + transactionId: 'A-tk0', + inFlight: {id: 'A-1', transactionIds: ['A-tk0'], patchCount: 1}, + mutationsSent: [], + warnings: [], + }) + expect(() => + host.foldIntoRequest('A-1', { + transactionId: 'A-1+B-1', + mutations: heard.mutations, + }), + ).toThrow( + 'A host that saves each mutation under its proposed transaction ID never folds mutations into one request', + ) + }) + + test('a folding host names the request a mutation went out in once, when the request is formed', () => { + const results = [true, false].map((folded) => { + const {editor, document, host, heard, mutationsSent} = createHostedEditor( + 'B: foo|', + {foldMutations: true}, + ) + + document.type('x') + const transactionIdsBefore = + getIoInternals(editor).inspect().inFlight?.transactionIds + + if (folded) { + host.foldIntoRequest('A-1', { + transactionId: 'A-1+B-1', + mutations: heard.mutations, + }) + } + + host.reportSaveTaken('A-1') + + return { + transactionIdsBefore, + transactionIdsAfter: + getIoInternals(editor).inspect().inFlight?.transactionIds, + savedAs: host.getTransactionId('A-1'), + mutationsSent, + warnings: heard.warnings, + } + }) + + expect(results).toEqual([ + { + transactionIdsBefore: ['A-tk0'], + transactionIdsAfter: ['A-1+B-1'], + savedAs: 'A-1+B-1', + mutationsSent: [{id: 'A-1', transactionId: 'A-1+B-1'}], + warnings: [], + }, + { + transactionIdsBefore: ['A-tk0'], + transactionIdsAfter: ['A-1'], + savedAs: 'A-1', + mutationsSent: [{id: 'A-1', transactionId: 'A-1'}], + warnings: [], + }, + ]) + }) + + test('a retry re-sends the save request with the transaction ID the mutation was first sent as', () => { + const results = [true, false].map((landed) => { + const {editor, document, host, heard, resubmitted, transactionHistory} = + createHostedEditor('B: foo|') + + document.type('x') + host.reportSaveTaken('A-1') + + if (landed) { + transactionHistory.add('A-tk0') + } + + return { + answer: host.retry('A-1'), + resubmitted, + warnings: heard.warnings, + transactionIds: + getIoInternals(editor).inspect().inFlight?.transactionIds, + } + }) + + expect(results).toEqual([ + { + answer: {type: 'duplicate'}, + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + warnings: [], + transactionIds: ['A-tk0'], + }, + { + answer: {type: 'saved'}, + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + warnings: [], + transactionIds: ['A-tk0'], + }, + ]) + }) + + test('a permanent failure is reported as `mutation rejected`, and a transient one is retried with the same request until it is answered', () => { + const results = ( + [[400], [403], [404], [500], [503], ['network error', 503]] as const + ).map(([failure, ...retryFailures]) => { + const {editor, document, host, heard, resubmitted, failures} = + createHostedEditor('B: foo|') + + document.type('x') + host.reportSaveTaken('A-1') + failures.push(...retryFailures) + host.reportFailure('A-1', failure) + + return { + failure, + sync: editor.getSnapshot().context.sync, + rejected: getIoInternals(editor).inspect().rejected, + resubmitted, + warnings: heard.warnings, + } + }) + const rejected = { + sync: 'blocked', + rejected: {id: 'A-1', transactionIds: ['A-tk0'], patchCount: 1}, + resubmitted: [], + warnings: [], + } + const retried = { + sync: 'saving', + rejected: undefined, + warnings: [], + } + + expect(results).toEqual([ + {failure: 400, ...rejected}, + {failure: 403, ...rejected}, + {failure: 404, ...rejected}, + { + failure: 500, + ...retried, + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + }, + { + failure: 503, + ...retried, + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + }, + { + failure: 'network error', + ...retried, + resubmitted: [ + {mutationIds: ['A-1'], transactionId: 'A-tk0'}, + {mutationIds: ['A-1'], transactionId: 'A-tk0'}, + ], + }, + ]) + }) + + test('a folding host that re-submits a mutation before its request arrives saves the mutation once, under one transaction ID', () => { + const world = createWorld() + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const mutation = {name: 'Editor A', mutationNumber: 1} as const + + world.setHostShape('folding') + world.documentIs('B: foo|') + world.type('Editor A', 'x') + world.feedLost('Editor A') + world.resync('Editor A', {discardUnsent: false, outcomeOf: 1}) + world.receive('Editor A', 1) + + expect(world.getEditor('Editor A').mutationsSent).toEqual([ + {id: 'A-1', transactionId: 'A-1'}, + ]) + expect(world.snapshot().server).toEqual({ + value: 'B: foox', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foox', marks: []}], + style: 'normal', + }, + ], + rev: 'r2', + transactions: [ + { + id: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + mutationIds: ['A-1'], + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + noop: false, + source: {type: 'mutations', mutations: [mutation]}, + }, + ], + duplicates: [{transactionId: 'A-1', mutations: [mutation]}], + nextFailure: null, + }) + }) + + test('a shared request retried before it lands saves both mutations once', () => { + const world = createWorld() + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const mutationA = {name: 'Editor A', mutationNumber: 1} as const + const mutationB = {name: 'Editor B', mutationNumber: 1} as const + + world.setHostShape('folding') + world.documentIs('B: foo|') + world.type('Editor A', 'x') + world.putCaretAfter('Editor B', 'f') + world.type('Editor B', 'y') + world.foldAsOne(mutationA, mutationB) + world.getEditor('Editor A').host.retry('A-1') + world.receiveAsOne(mutationA, mutationB) + + expect(world.snapshot().server).toEqual({ + value: 'B: fyoox', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'fyoox', marks: []}], + style: 'normal', + }, + ], + rev: 'r2', + transactions: [ + { + id: 'A-1+B-1', + previousRev: 'r1', + resultRev: 'r2', + mutationIds: ['A-1', 'B-1'], + patchCount: 2, + patches: [ + diffMatchPatch('foo', 'foox', textPath), + diffMatchPatch('foo', 'fyoo', textPath), + ], + noop: false, + source: {type: 'mutations', mutations: [mutationA, mutationB]}, + }, + ], + duplicates: [ + {transactionId: 'A-1+B-1', mutations: [mutationA, mutationB]}, + ], + nextFailure: null, + }) + }) + + test('a resync naming the mutation in flight finds its outcome by re-submitting it, or from the transaction history', () => { + const cases = [ + {outcomeMethod: 'resubmit', server: 'landed'}, + {outcomeMethod: 'resubmit', server: 'never arrived'}, + {outcomeMethod: 'resubmit', server: 'fails with 404'}, + {outcomeMethod: 'history', server: 'landed'}, + {outcomeMethod: 'history', server: 'never arrived'}, + ] as const + const results = cases.map(({outcomeMethod, server}) => { + const { + editor, + document, + host, + heard, + transactionHistory, + failures, + resubmitted, + } = createHostedEditor('B: foo|', {outcomeMethod}) + const outcomes: Array = [] + + document.type('x') + host.reportSaveTaken('A-1') + document.type('y') + + if (server === 'landed') { + transactionHistory.add('A-tk0') + } + + if (server === 'fails with 404') { + failures.push(404) + } + + const {send} = editor + editor.send = (message) => { + if (message.type === 'resync') { + outcomes.push(message.outcomes) + } + + send(message) + } + host.resync({discardUnsent: false, outcomeOf: 'A-1'}) + + return {outcomes, resubmitted, warnings: heard.warnings} + }) + + expect(results).toEqual([ + { + outcomes: [{'A-1': 'applied'}], + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + warnings: [], + }, + { + outcomes: [{'A-1': 'applied'}], + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + warnings: [], + }, + { + outcomes: [{'A-1': 'not applied'}], + resubmitted: [{mutationIds: ['A-1'], transactionId: 'A-tk0'}], + warnings: [], + }, + { + outcomes: [{'A-1': 'applied'}], + resubmitted: [], + warnings: [], + }, + { + outcomes: [{'A-1': 'not applied'}], + resubmitted: [], + warnings: [], + }, + ]) + }) + + test('a self-confirming host forwards the transaction its save answers with, and a plain host waits for the feed', () => { + const results = [true, false].map((selfConfirming) => { + const {editor, document, host, heard} = createHostedEditor('B: foo|', { + selfConfirming, + }) + + document.type('x') + host.reportSaveTaken('A-1') + host.reportSaved('A-1', { + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + + return { + sync: editor.getSnapshot().context.sync, + rev: getIoInternals(editor).getBase().rev, + inFlight: getIoInternals(editor).inspect().inFlight?.id, + } + }) + + expect(results).toEqual([ + {sync: 'synced', rev: 'r2', inFlight: undefined}, + {sync: 'saving', rev: 'r1', inFlight: 'A-1'}, + ]) + }) + + test('a self-confirming host passes on no answer for the final mutation', () => { + const {editor, document, host, heard} = createHostedEditor('B: foo|', { + selfConfirming: true, + }) + + document.type('x') + host.reportSaveTaken('A-1') + document.type('y') + document.close() + host.reportSaved('A-2', { + transactionId: 'A-tk1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[1].patches, + }) + + expect({ + final: heard.mutations[1].final, + warnings: heard.warnings, + rev: getIoInternals(editor).getBase().rev, + }).toEqual({final: true, warnings: [], rev: 'r1'}) + }) + + test('the final mutation is saved once the save request of the mutation in flight is taken', () => { + const {document, host, saved} = createHostedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + document.type('y') + document.close() + + expect(saved).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + ]) + + host.reportSaveTaken('A-1') + + expect(saved).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + final: true, + }, + ]) + }) + + test('the final mutation is saved at once when no save request is waiting', () => { + const {document, host, saved} = createHostedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + host.reportSaveTaken('A-1') + document.type('y') + document.close() + + expect(saved).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + final: true, + }, + ]) + }) +}) + +function createHostedEditor( + textspec: string, + { + foldMutations = false, + selfConfirming = false, + outcomeMethod, + }: { + foldMutations?: boolean + selfConfirming?: boolean + outcomeMethod?: 'resubmit' | 'history' + } = {}, +) { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const {value, caret} = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + textspec, + ) + const serverCopy: {current: Load} = {current: {value, rev: 'r1'}} + const feed: Array = [] + const saved: Array = [] + const resubmitted: Array<{ + mutationIds: Array + transactionId: string + }> = [] + const transactionHistory = new Set() + const failures: Array = [] + const mutationsSent: Array = [] + const {send} = editor + editor.send = (message) => { + if (message.type === 'mutation sent') { + const {type: _type, ...mutationSent} = message + mutationsSent.push(mutationSent) + } + + send(message) + } + const host = createPassThroughHost({ + io: editor, + save: (mutation) => saved.push(mutation), + resubmit: ({transactionId, mutations}) => { + resubmitted.push({ + mutationIds: mutations.map((mutation) => mutation.id), + transactionId, + }) + + const failure = failures.shift() + + if (failure !== undefined) { + return {type: 'failed', status: failure} + } + + if (transactionHistory.has(transactionId)) { + return {type: 'duplicate'} + } + + transactionHistory.add(transactionId) + return {type: 'saved'} + }, + hasTransaction: (transactionId) => transactionHistory.has(transactionId), + fetchCopy: () => serverCopy.current, + subscription: () => feed, + foldMutations, + selfConfirming, + outcomeMethod, + }) + + host.load() + document.mount() + + if (caret) { + document.setCaret(caret) + } + + return { + editor, + document, + host, + heard, + clock, + feed, + saved, + serverCopy, + resubmitted, + transactionHistory, + failures, + mutationsSent, + } +} diff --git a/packages/io/src/protocol/host.ts b/packages/io/src/protocol/host.ts new file mode 100644 index 0000000000..c4ba0628c5 --- /dev/null +++ b/packages/io/src/protocol/host.ts @@ -0,0 +1,461 @@ +import type {Io} from './io' +import type {Load, Mutation, Transaction} from './types' + +/** + * A save request as the host formed it: the transaction ID and every mutation + * it carries. It never changes once formed: every retry and every re-submit + * sends it as it is. + */ +export type FrozenRequest = { + readonly transactionId: string + readonly mutations: ReadonlyArray +} + +/** + * Why a save request failed: an HTTP status, or a network error before any + * answer. A 400, 403 or 404 means the server will never save the request, + * and the others that a later attempt may. + */ +export type RequestFailure = 400 | 403 | 404 | 500 | 503 | 'network error' + +/** + * The answer to a save request: saved, refused with a 409 + * `transactionAlreadyExistsError` because the transaction ID is taken, or + * failed. + */ +export type SaveAnswer = + | {type: 'saved'} + | {type: 'duplicate'} + | {type: 'failed'; status: RequestFailure} + +export type PassThroughHost = { + /** + * The transaction a mutation is saved as, or the one it proposes while a + * folding host hasn't formed its request yet. + */ + getTransactionId: (mutationId: string) => string + /** The request a mutation is saved in, formed now if it wasn't yet. */ + getRequest: (mutationId: string) => FrozenRequest + /** + * Forms the request a mutation goes out in, with other mutations, saved as one + * transaction. Only a host that folds mutations does this, and only once per + * mutation: folding a mutation again into the same request does nothing. + */ + foldIntoRequest: (mutationId: string, request: FrozenRequest) => void + forward: (transaction: Transaction) => void + /** The server has taken the save request for a mutation. */ + reportSaveTaken: (mutationId: string) => void + /** + * The server saved a mutation as this transaction. Only a self-confirming host + * passes it on: any other host waits for it on the feed. + */ + reportSaved: (mutationId: string, transaction: Transaction) => void + /** + * A mutation's save request failed. A permanent failure is reported to the + * editor as `mutation rejected`, and a transient one is retried. + */ + reportFailure: (mutationId: string, status: RequestFailure) => void + /** + * Re-sends the request a mutation was saved in, as it was formed, again after + * each transient failure. A 409 means the earlier attempt landed, and a + * save means it hadn't: either way the echo confirms the mutation, so the host + * does nothing more. A permanent failure is reported as + * `mutation rejected`. + */ + retry: (mutationId: string) => SaveAnswer + /** The listener reconnected or may have missed transactions. */ + feedLost: () => void + load: () => void + /** + * `outcomeOf` names the mutation in flight, whose outcome the host finds out + * and passes along. + */ + resync: (options: {discardUnsent: boolean; outcomeOf?: string}) => void +} + +/** + * Forwards the feed to the editor as it arrives, saves each mutation, and + * fetches the server's copy for `load` and `resync`. Waiting for the mutation in + * flight before a resync, or naming it so the host looks up its outcome, is + * the caller's job. + * + * By default the host saves each mutation as its own request, under the + * transaction ID the mutation proposes, and never sends `mutation sent`, so + * each transaction carries one mutation. With `foldMutations`, the host is + * shaped like Studio's committer: it chooses one transaction ID per request, + * and sends `mutation sent` for each mutation in it. Its transaction can + * carry several mutations. A mutation that goes out alone is saved under its + * mutation ID. The host forms a mutation's request, and sends + * `mutation sent`, at the first of: a step folding it with other mutations, + * the server taking it, or the host re-sending it. Until then a step can + * still fold two waiting mutations into one request. A real host forms the + * request before it leaves. The `final` mutation is always saved under its + * proposed ID, with no `mutation sent`. + * + * With `selfConfirming`, the host is shaped like a host with no listener and + * one writer: it forwards the transaction each save answers with, which + * confirms the mutation, and sees no other transactions. + * + * The host keeps every request it formed, so it can send it again, whole and + * under the same transaction ID: to retry after a lost reply or a transient + * failure, and to find out what became of the mutation in flight before a + * resync. By default (`outcomeMethod: 'resubmit'`) it finds the outcome by + * re-submitting the request: a 409 means the mutation had landed, a save means + * it hadn't and now has, both `'applied'`, and a permanent failure means + * `'not applied'`. With `outcomeMethod: 'history'` it asks the document's transaction history + * whether the transaction ID is there instead. A lookup while the request is + * still in transit can answer `'not applied'` for a mutation that lands a moment + * later, which re-submitting can't. + * + * `subscription` returns what the host's feed subscription holds but hasn't + * delivered yet, in server order. The host subscribes before it fetches a + * copy, so at that moment those transactions run up to the one whose + * `resultRev` is the copy's revision, and the host drops them when they + * arrive. The model's feed can deliver out of order, so arrival order can't + * tell a covered transaction from one that skipped ahead. + * + * The host also remembers every transaction it has seen, forwarded or + * dropped, as a link from its `previousRev` to its `resultRev`. A copy at + * revision `R` covers `R` and every revision reachable backwards from it + * through known links. A transaction arriving later whose `resultRev` is + * covered is dropped, and its `previousRev` becomes covered too. A late + * covered transaction that neither the subscription nor a known link ties to + * the copy can't be told from one that skipped ahead, so it is forwarded, and + * the editor holds it for 10 s before it reports `out of order`. + */ +export function createPassThroughHost({ + io, + save, + resubmit, + hasTransaction, + fetchCopy, + subscription, + foldMutations = false, + selfConfirming = false, + outcomeMethod = 'resubmit', +}: { + io: Io + save: (mutation: Mutation) => void + /** Sends a save request again and answers whether it saved. */ + resubmit: (request: FrozenRequest) => SaveAnswer + /** Whether the document's transaction history lists the ID. */ + hasTransaction: (transactionId: string) => boolean + fetchCopy: () => Load + subscription: () => Array> + foldMutations?: boolean + selfConfirming?: boolean + outcomeMethod?: 'resubmit' | 'history' +}): PassThroughHost { + const mutations = new Map() + const requests = new Map() + let inFlightMutationId: string | undefined + let untakenMutationId: string | undefined + let heldFinalMutation: Mutation | undefined + let coveredTransactionIds = new Set() + let coveredRevs = new Set() + const previousRevs = new Map() + + io.on('mutation', (event) => { + const {type: _type, ...mutation} = event + mutations.set(mutation.id, mutation) + + if (mutation.final || !foldMutations) { + requests.set( + mutation.id, + freezeRequest({ + transactionId: mutation.transactionId, + mutations: [mutation], + }), + ) + } + + if (mutation.final) { + if (untakenMutationId === undefined) { + save(mutation) + } else { + heldFinalMutation = mutation + } + + return + } + + inFlightMutationId = mutation.id + untakenMutationId = mutation.id + save(mutation) + }) + + function fetchCoveredCopy(): Load { + const copy = fetchCopy() + + if (inFlightMutationId !== undefined) { + return copy + } + + coveredRevs = revsUpTo(copy.rev) + + const buffered = subscription() + const lastCoveredIndex = buffered.findLastIndex( + (transaction) => transaction.resultRev === copy.rev, + ) + coveredTransactionIds = new Set( + buffered + .slice(0, lastCoveredIndex + 1) + .map((transaction) => transaction.transactionId), + ) + + return copy + } + + function revsUpTo(rev: string | undefined): Set { + const revs = new Set() + let current = rev + + while (current !== undefined && !revs.has(current)) { + revs.add(current) + current = previousRevs.get(current) + } + + return revs + } + + function isCovered(transaction: Transaction): boolean { + const coveredById = coveredTransactionIds.delete(transaction.transactionId) + + return ( + coveredById || + (transaction.resultRev !== undefined && + coveredRevs.has(transaction.resultRev)) + ) + } + + function forward(transaction: Transaction) { + if (transaction.resultRev !== undefined) { + previousRevs.set(transaction.resultRev, transaction.previousRev) + } + + if (isCovered(transaction)) { + if (transaction.previousRev !== undefined) { + coveredRevs.add(transaction.previousRev) + } + + return + } + + if ( + inFlightMutationId !== undefined && + requests.get(inFlightMutationId)?.transactionId === + transaction.transactionId + ) { + inFlightMutationId = undefined + } + + io.send({ + type: 'transaction', + transactionId: transaction.transactionId, + previousRev: transaction.previousRev, + resultRev: transaction.resultRev, + patches: transaction.patches, + ...('value' in transaction ? {value: transaction.value} : {}), + }) + } + + function resubmitMutation(mutationId: string): SaveAnswer { + if (getMutation(mutationId).final) { + throw new Error(`No mutation "${mutationId}" to send again`) + } + + return resubmitUntilAnswered(mutationId) + } + + function resubmitUntilAnswered(mutationId: string): SaveAnswer { + let answer = resubmit(getRequest(mutationId)) + + while (answer.type === 'failed' && !isPermanent(answer.status)) { + answer = resubmit(getRequest(mutationId)) + } + + return answer + } + + function reject(mutationId: string) { + if (getMutation(mutationId).final) { + return + } + + if (mutationId === inFlightMutationId) { + inFlightMutationId = undefined + } + + io.send({type: 'mutation rejected', id: mutationId}) + } + + function findOutcome(mutationId: string): 'applied' | 'not applied' { + if (outcomeMethod === 'history') { + return hasTransaction(getRequest(mutationId).transactionId) + ? 'applied' + : 'not applied' + } + + return resubmitMutation(mutationId).type === 'failed' + ? 'not applied' + : 'applied' + } + + function getMutation(mutationId: string): Mutation { + const mutation = mutations.get(mutationId) + + if (!mutation) { + throw new Error(`No mutation "${mutationId}" was saved`) + } + + return mutation + } + + function getTransactionId(mutationId: string): string { + return ( + requests.get(mutationId)?.transactionId ?? + getMutation(mutationId).transactionId + ) + } + + function getRequest(mutationId: string): FrozenRequest { + const request = requests.get(mutationId) + + if (request) { + return request + } + + const mutation = getMutation(mutationId) + + return formRequest(mutationId, { + transactionId: mutation.id, + mutations: [mutation], + }) + } + + function formRequest( + mutationId: string, + request: FrozenRequest, + ): FrozenRequest { + const frozen = freezeRequest(request) + requests.set(mutationId, frozen) + io.send({ + type: 'mutation sent', + id: mutationId, + transactionId: frozen.transactionId, + }) + + return frozen + } + + return { + getTransactionId, + getRequest, + foldIntoRequest: (mutationId, request) => { + if (!foldMutations) { + throw new Error( + 'A host that saves each mutation under its proposed transaction ID never folds mutations into one request', + ) + } + + if (!request.mutations.some((mutation) => mutation.id === mutationId)) { + throw new Error(`The request does not carry mutation "${mutationId}"`) + } + + const formed = requests.get(mutationId) + + if (formed === undefined) { + formRequest(mutationId, request) + return + } + + if (!isSameRequest(formed, request)) { + throw new Error( + `Mutation "${mutationId}" went out in request "${formed.transactionId}" already`, + ) + } + }, + forward, + reportSaved: (mutationId, transaction) => { + if (selfConfirming && !getMutation(mutationId).final) { + forward(transaction) + } + }, + reportSaveTaken: (mutationId) => { + getRequest(mutationId) + + if (mutationId !== untakenMutationId) { + return + } + + untakenMutationId = undefined + + if (heldFinalMutation) { + const finalMutation = heldFinalMutation + heldFinalMutation = undefined + save(finalMutation) + } + }, + reportFailure: (mutationId, status) => { + if ( + isPermanent(status) || + resubmitUntilAnswered(mutationId).type === 'failed' + ) { + reject(mutationId) + } + }, + retry: (mutationId) => { + const answer = resubmitMutation(mutationId) + + if (answer.type === 'failed') { + reject(mutationId) + } + + return answer + }, + feedLost: () => { + io.send({type: 'feed lost'}) + }, + load: () => { + io.send({type: 'load', ...fetchCoveredCopy()}) + }, + resync: ({discardUnsent, outcomeOf}) => { + const outcomes = + outcomeOf === undefined + ? undefined + : {[outcomeOf]: findOutcome(outcomeOf)} + + if (outcomeOf !== undefined && outcomeOf === inFlightMutationId) { + inFlightMutationId = undefined + } + + io.send({ + type: 'resync', + ...fetchCoveredCopy(), + ...(discardUnsent ? {discardUnsent: true as const} : {}), + ...(outcomes ? {outcomes} : {}), + }) + }, + } +} + +function isPermanent(status: RequestFailure): boolean { + return status === 400 || status === 403 || status === 404 +} + +function freezeRequest(request: FrozenRequest): FrozenRequest { + return Object.freeze({ + transactionId: request.transactionId, + mutations: Object.freeze([...request.mutations]), + }) +} + +function isSameRequest(requestA: FrozenRequest, requestB: FrozenRequest) { + return ( + requestA.transactionId === requestB.transactionId && + requestA.mutations.length === requestB.mutations.length && + requestA.mutations.every( + (mutation, index) => mutation.id === requestB.mutations[index]?.id, + ) + ) +} diff --git a/packages/io/src/protocol/io.test.ts b/packages/io/src/protocol/io.test.ts new file mode 100644 index 0000000000..c4601df868 --- /dev/null +++ b/packages/io/src/protocol/io.test.ts @@ -0,0 +1,1633 @@ +import {diffMatchPatch, insert, set, unset} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {createFakeDocument, parseTextspec} from '../fakes/document' +import {createFakeNetwork} from '../fakes/network' +import {createEditorWithIo} from '../scenario/world' +import {applyWithContentLakeSemantics} from './content-lake' +import {createIo, getIoInternals} from './io' +import type {EditorMessageForIo} from './types' + +describe(createIo.name, () => { + test('held transactions are applied in chain order once the missing one arrives', () => { + const {editor, document, clock, heard} = createLoadedEditor('B: foo') + const path = [{_key: 'd-k0'}, 'style'] + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.send({ + type: 'transaction', + transactionId: 't3', + previousRev: 'r3', + resultRev: 'r4', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h2', path)], + }) + + expect(document.toTextspec()).toEqual('B: |foo') + expect(getIoInternals(editor).getBase().rev).toEqual('r1') + + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', path)], + }) + + expect(document.toTextspec()).toEqual('H2: |foox') + expect(getIoInternals(editor).getBase().rev).toEqual('r4') + expect(heard.changes).toEqual([ + { + origin: 'remote', + operations: [ + set( + [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foo', marks: []}, + ], + style: 'h1', + }, + ], + [], + ), + ], + }, + { + origin: 'remote', + operations: [ + set( + [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foo', marks: []}, + ], + style: 'h2', + }, + ], + [], + ), + ], + }, + { + origin: 'remote', + operations: [ + set( + [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foox', marks: []}, + ], + style: 'h2', + }, + ], + [], + ), + ], + }, + ]) + + clock.advance(10_000) + + expect(heard.errors).toEqual([]) + }) + + test('a held echo lets the next mutation go out and keeps its work on screen until it applies', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + document.type('y') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ]) + expect(document.toTextspec()).toEqual('B: fooxy|') + + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + + expect(document.toTextspec()).toEqual('H1: fooxy|') + expect(getIoInternals(editor).getBase()).toEqual({ + value: parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'H1: foox', + ).value, + rev: 'r3', + }) + }) + + test("an unsent insert whose key another writer's insert brings puts the editor out of step, and the resync gives it a new key in its later patches too", () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const [fooxBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foox', + ).value + const [barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B _key="k9": bar', + ).value + const collidingInsert = insert([barBlock], 'after', [{_key: 'd-k0'}]) + + document.type('x') + document.insertBlock('B _key="k9": baz') + document.type('q') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [collidingInsert], + }) + + expect(heard.errors).toEqual([ + {reason: 'duplicate key', transactionId: 't1', patch: collidingInsert}, + ]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="d-k0": foox;;B _key="k9": bazq|', + ) + + editor.send({ + type: 'resync', + value: [fooxBlock, barBlock], + rev: 'r3', + outcomes: {'A-1': 'applied'}, + }) + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + diffMatchPatch('foo', 'foox', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + 'text', + ]), + ], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [ + insert( + [ + { + _type: 'block', + _key: 'a-k3', + children: [ + {_key: 'a-k2', _type: 'span', text: 'baz', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k0'}], + ), + diffMatchPatch('baz', 'bazq', [ + {_key: 'a-k3'}, + 'children', + {_key: 'a-k2'}, + 'text', + ]), + ], + }, + ]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="d-k0": foox;;B _key="a-k3": bazq;;B _key="k9": bar|', + ) + }) + + test("Scenario: a transaction's `value` becomes the base, and what its patches don't say reaches the editor lined up after them", () => { + const {editor, document, received} = createLoadedEditor('B: foo|;;B: bar') + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + const styledValue = [ + {...fooBlock, style: 'h1'}, + {...barBlock, style: 'h2'}, + ] + const stylePath = [{_key: 'd-k0'}, 'style'] + + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', stylePath)], + value: styledValue, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + value: [styledValue[0]], + }) + + expect(getIoInternals(editor).getBase()).toEqual({ + value: [styledValue[0]], + rev: 'r3', + }) + expect(received).toEqual([ + {type: 'load', value: [fooBlock, barBlock]}, + { + type: 'apply', + patches: [ + set('h1', stylePath), + set({...barBlock, style: 'h2'}, [{_key: 'd-k2'}]), + ], + underneath: [set('h1', stylePath)], + }, + {type: 'apply', patches: [unset([{_key: 'd-k2'}])], underneath: []}, + ]) + expect(document.toTextspec()).toEqual('H1: foo|') + }) + + test('a remote style under a local one reaches the editor as `underneath` of an apply with no patches', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + const sent: Array = [] + const {send} = document + document.send = (message) => { + sent.push(message) + send(message) + } + + document.setStyle('h1') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h2', path)], + }) + + expect(document.toTextspec()).toEqual('H1: foo|') + expect(sent).toEqual([ + {type: 'apply', patches: [], underneath: [set('h2', path)]}, + ]) + expect(heard.changes).toEqual([ + { + origin: 'local', + operations: [set('h1', path)], + patches: [set('h1', path)], + }, + ]) + }) + + test('a rejected mutation stays on screen while the feed keeps applying', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|;;B: bar') + + document.type('x') + editor.send({type: 'mutation rejected', id: 'A-1'}) + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [unset([{_key: 'd-k2'}])], + }) + + expect(document.toTextspec()).toEqual('B: foox|') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + diffMatchPatch('foo', 'foox', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + 'text', + ]), + ], + }, + ]) + }) + + test('`inspect` reports the mutation in flight, the rejected one, pending changes and held transactions', () => { + const {editor, document, clock} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + document.type('y') + clock.advance(500) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }) + + expect(getIoInternals(editor).inspect()).toEqual({ + inFlight: {id: 'A-1', transactionIds: ['A-tk0'], patchCount: 1}, + rejected: undefined, + echoed: [], + pending: [ + { + patchCount: 1, + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], + held: [ + { + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + arrivedAt: 500, + }, + ], + outOfStep: false, + }) + + editor.send({type: 'mutation rejected', id: 'A-1'}) + clock.advance(10_000) + + expect(getIoInternals(editor).inspect()).toEqual({ + inFlight: undefined, + rejected: {id: 'A-1', transactionIds: ['A-tk0'], patchCount: 1}, + echoed: [], + pending: [ + { + patchCount: 1, + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], + held: [], + outOfStep: true, + }) + }) + + test('a rejection for a mutation that already came back is ignored with a warning', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({type: 'mutation rejected', id: 'A-1'}) + document.type('y') + + expect(heard.warnings).toEqual([ + '`mutation rejected` for mutation "A-1", not in flight', + ]) + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ]) + }) + + test('a `diffMatchPatch` on a non-string puts the editor out of step, and a patch for a missing parent does nothing', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const failingPatch = diffMatchPatch('foo', 'foox', [ + {_key: 'd-k0'}, + 'children', + ]) + + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k9'}, 'style'])], + }) + + expect(heard.errors).toEqual([]) + expect(getIoInternals(editor).getBase().rev).toEqual('r2') + + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [failingPatch], + }) + + expect(heard.errors).toEqual([ + {reason: 'patch failed', transactionId: 't2', patch: failingPatch}, + ]) + expect(getIoInternals(editor).getBase().rev).toEqual('r2') + expect(document.toTextspec()).toEqual('B: foo|') + }) + + test('a remote insert that brings the same key twice puts the editor out of step', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const keyGenerator = createTestKeyGenerator('b-') + const [firstBlock, secondBlock] = parseTextspec( + {keyGenerator}, + 'B _key="k7": bar;;B _key="k7": baz', + ).value + const duplicateInsert = insert([firstBlock, secondBlock], 'after', [ + {_key: 'd-k0'}, + ]) + + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [duplicateInsert], + }) + + expect(heard.errors).toEqual([ + {reason: 'duplicate key', transactionId: 't1', patch: duplicateInsert}, + ]) + expect(document.toTextspec()).toEqual('B: foo|') + }) + + test('a repair key comes from the revision and the path, and never one the value already has', () => { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const {value} = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B _key="51491b98": foo;;B _key="missing": bar', + ) + const keylessBlock = {...value[1]} + Reflect.deleteProperty(keylessBlock, '_key') + + editor.send({type: 'load', value: [value[0], keylessBlock], rev: 'r1'}) + document.mount() + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [set('16a962f0', [1, '_key'])], + }, + ]) + }) + + test('a load below the floor is repaired against the stored array, by key where the key holds and by index where it does not', () => { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const value = applyWithContentLakeSemantics( + parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar;;B _key="k5": baz', + ).value, + [ + set('oops', [0]), + unset([1, '_key']), + unset([1, '_type']), + set('oops', [1, 'children']), + unset([{_key: 'k5'}, 'children', 0, '_key']), + unset([{_key: 'k5'}, 'children', 0, '_type']), + set(42, [{_key: 'k5'}, 'children', 0, 'text']), + ], + ) + + editor.send({type: 'load', value, rev: 'r1'}) + document.mount() + + expect(heard.warnings).toEqual([ + 'Left out 1 blocks that are not objects', + 'Repaired 6 places below the floor or with missing or duplicate keys', + ]) + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + set('51491b98', [1, '_key']), + set('block', [1, '_type']), + set( + [{_type: 'span', _key: '9d1e3ce5', text: '', marks: []}], + [1, 'children'], + ), + set('2b73c14a', [{_key: 'k5'}, 'children', 0, '_key']), + set('span', [{_key: 'k5'}, 'children', 0, '_type']), + set('', [{_key: 'k5'}, 'children', 0, 'text']), + ], + }, + ]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="51491b98": |;;B _key="k5": ', + ) + expect(getIoInternals(editor).getWorkingCopy()).toEqual(document.getValue()) + }) + + test("Scenario: a transaction that leaves a text block's child without `_key` or `_type` is invalid content", () => { + const childPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}] + const results = [ + unset([...childPath, '_key']), + unset([...childPath, '_type']), + ].map((patch) => { + const {editor, heard} = createLoadedEditor('B: foo|') + + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [patch], + }) + + return {errors: heard.errors, sync: editor.getSnapshot().context.sync} + }) + + expect(results).toEqual([ + { + errors: [{reason: 'invalid content', transactionId: 't1'}], + sync: 'out of step', + }, + { + errors: [{reason: 'invalid content', transactionId: 't1'}], + sync: 'out of step', + }, + ]) + }) + + test('Scenario: an unsent span and a remote span keyed alike in the same block put the editor out of step, and the resync gives the unsent span a new key', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const spanPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}] + const localSpan = {_type: 'span', _key: 'k9', text: 'bar', marks: []} + const remoteSpan = {_type: 'span', _key: 'k9', text: 'baz', marks: []} + const remoteInsert = insert([remoteSpan], 'after', spanPath) + + document.type('x') + document.applyLocalEdit([insert([localSpan], 'after', spanPath)]) + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [remoteInsert], + }) + + expect(heard.errors).toEqual([ + {reason: 'duplicate key', transactionId: 't1', patch: remoteInsert}, + ]) + + editor.send({ + type: 'resync', + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foox', marks: []}, + remoteSpan, + ], + style: 'normal', + }, + ], + rev: 'r3', + outcomes: {'A-1': 'applied'}, + }) + + expect(heard.mutations.slice(1)).toEqual([ + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [insert([{...localSpan, _key: 'a-k2'}], 'after', spanPath)], + }, + ]) + expect(document.getValue()).toEqual([ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_type: 'span', _key: 'd-k1', text: 'foox', marks: []}, + {_type: 'span', _key: 'a-k2', text: 'bar', marks: []}, + remoteSpan, + ], + style: 'normal', + }, + ]) + }) + + test("Scenario: a transaction whose `value` holds a block keyed like an unsent insert puts the editor out of step, though its patches don't say so", () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B _key="k9": bar', + ).value + + document.type('x') + document.insertBlock('B _key="k9": baz') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + value: [fooBlock, barBlock], + }) + + expect(heard.errors).toEqual([ + {reason: 'duplicate key', transactionId: 't1'}, + ]) + expect(getIoInternals(editor).getBase().rev).toEqual('r1') + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="d-k0": foox;;B _key="k9": baz|', + ) + }) + + test('Scenario: a remote span keyed like an unconfirmed block is no collision', () => { + const {editor, document, heard, treeMismatches} = + createLoadedEditor('B: foo|') + const remoteSpan = {_type: 'span', _key: 'k9', text: 'baz', marks: []} + + document.insertBlock('B _key="k9": bar') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [ + insert([remoteSpan], 'after', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + ]), + ], + }) + + expect(heard.errors).toEqual([]) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="d-k0": foobaz;;B _key="k9": bar|', + ) + expect(treeMismatches).toEqual([]) + }) + + test("Scenario: a load repairs a text block's mixed `children` by removing what isn't an object, and makes one empty span only when no object is left", () => { + const [block] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: good', + ).value + const results = [ + set([42, {_type: 'span', text: 'good', marks: []}, 43], [0, 'children']), + set([42], [0, 'children']), + ].map((corruption) => { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + + editor.send({ + type: 'load', + value: applyWithContentLakeSemantics([block], [corruption]), + rev: 'r1', + }) + document.mount() + + return { + patches: heard.mutations.map((mutation) => mutation.patches), + screen: document.toTextspec({keys: true}), + } + }) + + expect(results).toEqual([ + { + patches: [ + [ + unset([{_key: 'd-k0'}, 'children', 2]), + unset([{_key: 'd-k0'}, 'children', 0]), + set('6af268c7', [{_key: 'd-k0'}, 'children', 0, '_key']), + ], + ], + screen: 'B _key="d-k0": |good', + }, + { + patches: [ + [ + set( + [{_type: 'span', _key: '69f26734', text: '', marks: []}], + [{_key: 'd-k0'}, 'children'], + ), + ], + ], + screen: 'B _key="d-k0": |', + }, + ]) + }) + + test('a mutation in flight without its echo warns after 10 seconds, then with backoff', () => { + const {editor, document, clock, heard} = createLoadedEditor('B: foo|') + + document.type('x') + clock.advance(9_999) + + expect(heard.warnings).toEqual([]) + + clock.advance(1) + clock.advance(20_000) + + expect(heard.warnings).toEqual([ + 'Mutation "A-1" has been in flight for 10000 ms without coming back', + 'Mutation "A-1" has been in flight for 30000 ms without coming back', + ]) + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + clock.advance(100_000) + + expect(heard.warnings).toEqual([ + 'Mutation "A-1" has been in flight for 10000 ms without coming back', + 'Mutation "A-1" has been in flight for 30000 ms without coming back', + ]) + }) + + test('a second load in the first commit replaces the first, repairs included, and a load after the editor is ready throws', () => { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + const keylessBlock = {...fooBlock} + Reflect.deleteProperty(keylessBlock, '_key') + + editor.send({type: 'load', value: [keylessBlock], rev: 'r1'}) + editor.send({type: 'load', value: [barBlock], rev: 'r2'}) + const statusBeforeMount = editor.getSnapshot().context.status + document.mount() + + expect({ + statusBeforeMount, + status: editor.getSnapshot().context.status, + screen: document.toTextspec({keys: true}), + rev: getIoInternals(editor).getBase().rev, + mutations: heard.mutations, + changes: heard.changes, + }).toEqual({ + statusBeforeMount: 'loading', + status: 'ready', + screen: 'B _key="d-k2": |bar', + rev: 'r2', + mutations: [], + changes: [], + }) + expect(() => + editor.send({type: 'load', value: [fooBlock], rev: 'r3'}), + ).toThrow( + '`load` is only accepted in the first commit, before the editor is ready', + ) + expect(document.toTextspec()).toEqual('B: |bar') + }) + + test('a resync reports the rejected mutation it drops and unsent changes that no longer have a target as dropped work', () => { + const {editor, document, heard} = createLoadedEditor('B: foo;;B: bar|') + const [fooBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo', + ).value + + document.type('x') + document.type('y') + editor.send({type: 'mutation rejected', id: 'A-1'}) + editor.send({type: 'resync', value: [fooBlock], rev: 'r2'}) + + expect(heard.warnings).toEqual([ + '1 unsent patches had no target after the resync and did nothing', + ]) + expect(heard.workDropped).toEqual([ + { + patches: [ + diffMatchPatch('bar', 'barx', [ + {_key: 'd-k2'}, + 'children', + {_key: 'd-k3'}, + 'text', + ]), + ], + reason: 'rejected', + }, + { + patches: [ + diffMatchPatch('barx', 'barxy', [ + {_key: 'd-k2'}, + 'children', + {_key: 'd-k3'}, + 'text', + ]), + ], + reason: 'no target', + }, + ]) + expect(document.toTextspec()).toEqual('B: |foo') + }) + + test('a transaction that takes the target of unsent changes away reports them as dropped work once', () => { + const {editor, document, heard} = createLoadedEditor('B: foo;;B: bar|') + const textPath = [{_key: 'd-k2'}, 'children', {_key: 'd-k3'}, 'text'] + + document.type('x') + document.type('y') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [unset([{_key: 'd-k2'}])], + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + + expect(heard.warnings).toEqual([ + '1 unsent patches had no target after transaction "t1" and did nothing', + ]) + expect(heard.workDropped).toEqual([ + { + patches: [diffMatchPatch('barx', 'barxy', textPath)], + reason: 'no target', + }, + ]) + expect(getIoInternals(editor).inspect().pending).toEqual([ + { + patchCount: 1, + patches: [diffMatchPatch('barx', 'barxy', textPath)], + }, + ]) + expect(document.toTextspec()).toEqual('H1: foo|') + }) + + test('Scenario: an echo whose own patches had no target in the base reports them as dropped work once, leaving out the ones reported while unsent', () => { + const {editor, document, heard} = createLoadedEditor('B: foo;;B: bar|') + const textPath = [{_key: 'd-k2'}, 'children', {_key: 'd-k3'}, 'text'] + + document.type('x') + document.type('y') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [unset([{_key: 'd-k2'}])], + }) + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 'A-tk1', + previousRev: 'r3', + resultRev: 'r4', + patches: heard.mutations[1].patches, + }) + + expect(heard.warnings).toEqual([ + '1 unsent patches had no target after transaction "t1" and did nothing', + '1 sent patches had no target when transaction "A-tk0" saved them and did nothing', + ]) + expect(heard.workDropped).toEqual([ + { + patches: [diffMatchPatch('barx', 'barxy', textPath)], + reason: 'no target', + }, + { + patches: [diffMatchPatch('bar', 'barx', textPath)], + reason: 'no target', + }, + ]) + expect(editor.getSnapshot().context.sync).toEqual('synced') + expect(document.toTextspec()).toEqual('B: foo|') + }) + + test('closing while sending is blocked reports the unsent changes as dropped work', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.type('x') + editor.send({type: 'mutation rejected', id: 'A-1'}) + document.type('y') + document.close() + + expect(heard.warnings).toEqual([ + '1 unsent change(s) dropped on close: sending was blocked by the rejection of mutation A-1', + ]) + expect(heard.workDropped).toEqual([ + { + patches: [ + diffMatchPatch('foox', 'fooxy', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + 'text', + ]), + ], + reason: 'closed while blocked', + }, + ]) + expect(heard.mutations.map((mutation) => mutation.id)).toEqual(['A-1']) + }) + + test('a `mutation sent` with another transaction ID replaces the proposed one as the ID that confirms the mutation', () => { + const results = ['A-tk0', 'commit-1'].map((transactionId) => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.type('x') + const inFlightProposed = getIoInternals(editor).inspect().inFlight + editor.send({type: 'mutation sent', id: 'A-1', transactionId: 'commit-1'}) + const inFlightNamed = getIoInternals(editor).inspect().inFlight + document.type('y') + editor.send({ + type: 'transaction', + transactionId, + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + + return { + inFlightProposed, + inFlightNamed, + inFlightAfter: getIoInternals(editor).inspect().inFlight, + warnings: heard.warnings, + } + }) + + expect(results).toEqual([ + { + inFlightProposed: {id: 'A-1', transactionIds: ['A-tk0'], patchCount: 1}, + inFlightNamed: {id: 'A-1', transactionIds: ['commit-1'], patchCount: 1}, + inFlightAfter: {id: 'A-1', transactionIds: ['commit-1'], patchCount: 1}, + warnings: [], + }, + { + inFlightProposed: {id: 'A-1', transactionIds: ['A-tk0'], patchCount: 1}, + inFlightNamed: {id: 'A-1', transactionIds: ['commit-1'], patchCount: 1}, + inFlightAfter: {id: 'A-2', transactionIds: ['A-tk1'], patchCount: 1}, + warnings: [], + }, + ]) + }) + + test('a second `mutation sent` with another transaction ID warns, and either ID confirms the mutation', () => { + const results = ['commit-1', 'retry-1'].map((transactionId) => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.type('x') + editor.send({type: 'mutation sent', id: 'A-1', transactionId: 'commit-1'}) + editor.send({type: 'mutation sent', id: 'A-1', transactionId: 'retry-1'}) + const inFlightBefore = getIoInternals(editor).inspect().inFlight + document.type('y') + editor.send({ + type: 'transaction', + transactionId, + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + + return { + inFlightBefore, + inFlightAfter: getIoInternals(editor).inspect().inFlight, + warnings: heard.warnings, + screen: document.toTextspec(), + } + }) + + expect(results).toEqual( + ['commit-1', 'retry-1'].map(() => ({ + inFlightBefore: { + id: 'A-1', + transactionIds: ['commit-1', 'retry-1'], + patchCount: 1, + }, + inFlightAfter: {id: 'A-2', transactionIds: ['A-tk1'], patchCount: 1}, + warnings: [ + '`mutation sent` names transaction "retry-1" for mutation "A-1", already sent as "commit-1": a retry must reuse the transaction ID', + ], + screen: 'B: fooxy|', + })), + ) + }) + + test('a lost feed puts the editor out of step with a warning, and it still notes its own echo', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.type('x') + editor.send({type: 'feed lost'}) + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect({ + errors: heard.errors, + warnings: heard.warnings, + sync: editor.getSnapshot().context.sync, + screen: document.toTextspec(), + rev: getIoInternals(editor).getBase().rev, + inFlight: getIoInternals(editor).inspect().inFlight, + }).toEqual({ + errors: [], + warnings: [ + 'The feed was lost: applying no more transactions until a resync', + ], + sync: 'out of step', + screen: 'B: foox|', + rev: 'r1', + inFlight: undefined, + }) + }) + + test('a resync while a mutation is in flight is refused without its outcome, and with it takes the copy, and a mutation not applied rejoins the pending changes ahead of the rest', () => { + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const results = ( + [ + ['applied', 'B: foox'], + ['not applied', 'B: foo'], + ] as const + ).map(([outcome, copy]) => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const {value} = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + copy, + ) + + document.type('x') + document.type('y') + editor.send({type: 'resync', value, rev: 'r2'}) + const screenAfterRefusal = document.toTextspec() + editor.send({ + type: 'resync', + value, + rev: 'r2', + outcomes: {'A-1': outcome}, + }) + + return { + screenAfterRefusal, + warnings: heard.warnings, + screen: document.toTextspec(), + rev: getIoInternals(editor).getBase().rev, + mutations: heard.mutations, + inFlight: getIoInternals(editor).inspect().inFlight, + } + }) + + expect(results).toEqual([ + { + screenAfterRefusal: 'B: fooxy|', + warnings: [ + 'Refused a resync while mutation "A-1" is in flight: wait until it comes back or is rejected, or say what became of it', + ], + screen: 'B: fooxy|', + rev: 'r2', + mutations: [ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], + inFlight: {id: 'A-2', transactionIds: ['A-tk1'], patchCount: 1}, + }, + { + screenAfterRefusal: 'B: fooxy|', + warnings: [ + 'Refused a resync while mutation "A-1" is in flight: wait until it comes back or is rejected, or say what became of it', + ], + screen: 'B: fooxy|', + rev: 'r2', + mutations: [ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [ + diffMatchPatch('foo', 'foox', textPath), + diffMatchPatch('foox', 'fooxy', textPath), + ], + }, + ], + inFlight: {id: 'A-2', transactionIds: ['A-tk1'], patchCount: 2}, + }, + ]) + }) + + test('an own echo with a set above a path the mutation touched is an echo mismatch, and one with a patch on another block is not', () => { + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const otherBlockPatch = set('h1', [{_key: 'd-k2'}, 'style']) + const ancestorPatch = set( + {_type: 'block', _key: 'd-k0', children: [], style: 'normal'}, + [{_key: 'd-k0'}], + ) + const results = [otherBlockPatch, ancestorPatch].map((extraPatch) => { + const {editor, document, heard} = createLoadedEditor('B: foo|;;B: bar') + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: [extraPatch, diffMatchPatch('foo', 'foox', textPath)], + }) + + return { + errors: heard.errors, + sync: editor.getSnapshot().context.sync, + screen: document.toTextspec(), + rev: getIoInternals(editor).getBase().rev, + inFlight: getIoInternals(editor).inspect().inFlight, + } + }) + + expect(results).toEqual([ + { + errors: [], + sync: 'synced', + screen: 'B: foox|;;H1: bar', + rev: 'r2', + inFlight: undefined, + }, + { + errors: [ + { + reason: 'echo mismatch', + transactionId: 'A-tk0', + patch: ancestorPatch, + }, + ], + sync: 'out of step', + screen: 'B: foox|;;B: bar', + rev: 'r1', + inFlight: undefined, + }, + ]) + }) + + test('sync is saving while work is unsaved, blocked after a rejection and out of step after an error, each until a resync', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const syncs = [editor.getSnapshot().context.sync] + + document.type('x') + syncs.push(editor.getSnapshot().context.sync) + document.type('y') + syncs.push(editor.getSnapshot().context.sync) + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + syncs.push(editor.getSnapshot().context.sync) + editor.send({ + type: 'transaction', + transactionId: 'A-tk1', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[1].patches, + }) + syncs.push(editor.getSnapshot().context.sync) + document.type('z') + document.type('w') + editor.send({type: 'mutation rejected', id: 'A-3'}) + syncs.push(editor.getSnapshot().context.sync) + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r3', + resultRev: 'r4', + patches: [diffMatchPatch('foo', 'foox', [{_key: 'd-k0'}, 'children'])], + }) + syncs.push(editor.getSnapshot().context.sync) + editor.send({ + type: 'resync', + value: getIoInternals(editor).getBase().value, + rev: 'r3', + }) + syncs.push(editor.getSnapshot().context.sync) + editor.send({ + type: 'transaction', + transactionId: 'A-tk3', + previousRev: 'r3', + resultRev: 'r4', + patches: heard.mutations[3].patches, + }) + syncs.push(editor.getSnapshot().context.sync) + + expect(syncs).toEqual([ + 'synced', + 'saving', + 'saving', + 'saving', + 'synced', + 'blocked', + 'out of step', + 'saving', + 'synced', + ]) + }) + + test('Scenario: `getSnapshot` returns the same object until the state changes, and `subscribe` calls `next` once per change until unsubscribed', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const observed: Array = [] + const calledBack: Array = [] + const subscription = editor.subscribe({ + next: (snapshot) => observed.push(snapshot), + }) + editor.subscribe((snapshot) => calledBack.push(snapshot)) + const before = editor.getSnapshot() + + document.putCaretAfter('f') + const afterCaret = editor.getSnapshot() + document.type('x') + document.type('y') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + subscription.unsubscribe() + editor.send({ + type: 'transaction', + transactionId: 'A-tk1', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[1].patches, + }) + + expect(afterCaret).toBe(before) + expect(editor.getSnapshot()).toBe(editor.getSnapshot()) + expect(before).toEqual({ + context: { + status: 'ready', + sync: 'synced', + rev: 'r1', + inFlight: undefined, + pending: 0, + }, + }) + expect(observed).toEqual([ + { + context: { + status: 'ready', + sync: 'saving', + rev: 'r1', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + pending: 0, + }, + }, + { + context: { + status: 'ready', + sync: 'saving', + rev: 'r1', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + pending: 1, + }, + }, + { + context: { + status: 'ready', + sync: 'saving', + rev: 'r2', + inFlight: {id: 'A-2', transactionId: 'A-tk1'}, + pending: 0, + }, + }, + ]) + expect(calledBack).toEqual([ + ...observed, + { + context: { + status: 'ready', + sync: 'synced', + rev: 'r3', + inFlight: undefined, + pending: 0, + }, + }, + ]) + expect(calledBack[0]).toBe(observed[0]) + }) + + test('Scenario: `on` hears only the events of its type, every event with `*`, until unsubscribed', () => { + const {editor, document} = createLoadedEditor('B: foo|') + const errors: Array = [] + const everything: Array = [] + const subscription = editor.on('error', (event) => errors.push(event)) + editor.on('*', (event) => everything.push(event)) + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [diffMatchPatch('foo', 'foox', [{_key: 'd-k0'}, 'children'])], + }) + subscription.unsubscribe() + editor.send({type: 'feed lost'}) + + expect(errors).toEqual([ + { + type: 'error', + reason: 'patch failed', + transactionId: 't1', + patch: diffMatchPatch('foo', 'foox', [{_key: 'd-k0'}, 'children']), + }, + ]) + expect(everything).toEqual([ + { + type: 'mutation', + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + ...errors, + { + type: 'warning', + message: + 'The feed was lost: applying no more transactions until a resync', + }, + ]) + }) + + test('Scenario: `close` from the host sends the final mutation, unmounts io and leaves later local changes unbooked', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + document.type('y') + editor.send({type: 'close'}) + document.type('z') + document.close() + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + final: true, + }, + ]) + expect(editor.getSnapshot()).toEqual({ + context: { + status: 'unmounted', + sync: 'saving', + rev: 'r1', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + pending: 0, + }, + }) + }) + + test('Scenario: without a `transactionIdGenerator`, each mutation proposes a UUID of its own, with `crypto.randomUUID` or without it', () => { + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + const proposedIds = (cryptoGlobal: 'present' | 'missing') => { + if (cryptoGlobal === 'missing') { + vi.stubGlobal('crypto', undefined) + } + + try { + const keyGenerator = createTestKeyGenerator('a-') + const document = createFakeDocument({keyGenerator}, {value: undefined}) + const io = createIo({ + id: 'A', + editor: document, + keyGenerator, + clock: createFakeNetwork().clock, + }) + const transactionIds: Array = [] + + io.on('mutation', (mutation) => { + transactionIds.push(mutation.transactionId) + }) + document.mount() + document.type('x') + io.send({ + type: 'mutation rejected', + id: 'A-1', + }) + io.send({type: 'resync', value: undefined, rev: undefined}) + document.type('y') + + return transactionIds + } finally { + vi.unstubAllGlobals() + } + } + const results = [proposedIds('present'), proposedIds('missing')] + + expect(results).toEqual([ + [expect.stringMatching(uuid), expect.stringMatching(uuid)], + [expect.stringMatching(uuid), expect.stringMatching(uuid)], + ]) + expect(results.map((ids) => new Set(ids).size)).toEqual([2, 2]) + }) + + test('inputs after the editor closes are ignored, with a warning for each the editor side gets', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.close() + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + editor.send({type: 'resync', value: undefined, rev: 'r2'}) + editor.send({type: 'load', value: undefined, rev: 'r2'}) + document.type('x') + document.setStyle('h1') + document.insertBlock('B: bar') + document.deleteBlock('foo') + editor.undo() + document.putCaretAfter('f') + + expect(heard.warnings).toEqual([ + 'Ignored transaction "t1" after the editor unmounted', + 'Ignored a resync after the editor unmounted', + 'Ignored a load after the editor unmounted', + 'Ignored an undo after the editor unmounted', + ]) + expect(editor.getSnapshot().context.status).toEqual('unmounted') + expect(document.toTextspec()).toEqual('B: foo|') + expect(getIoInternals(editor).getBase().rev).toEqual('r1') + expect(heard.mutations).toEqual([]) + expect(heard.changes).toEqual([]) + }) + + test("the editor side is ready once the editor's first commit ends, with a load or without one", () => { + const {clock} = createFakeNetwork() + const results = [false, true].map((loaded) => { + const {document, io: editor} = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + + if (loaded) { + editor.send({ + type: 'load', + value: parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo', + ).value, + rev: 'r1', + }) + } + + const statusBeforeMount = editor.getSnapshot().context.status + document.mount() + + return { + statusBeforeMount, + status: editor.getSnapshot().context.status, + screen: document.toTextspec(), + } + }) + + expect(results).toEqual([ + {statusBeforeMount: 'loading', status: 'ready', screen: 'B: |'}, + {statusBeforeMount: 'loading', status: 'ready', screen: 'B: |foo'}, + ]) + }) +}) + +function createLoadedEditor(textspec: string | undefined) { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + received, + treeMismatches, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const {value, caret} = + textspec === undefined + ? {value: undefined, caret: undefined} + : parseTextspec({keyGenerator: createTestKeyGenerator('d-')}, textspec) + + editor.send({type: 'load', value, rev: 'r1'}) + document.mount() + + if (caret) { + document.setCaret(caret) + } + + return {editor, document, clock, heard, received, treeMismatches} +} diff --git a/packages/io/src/protocol/io.ts b/packages/io/src/protocol/io.ts new file mode 100644 index 0000000000..5afab868f6 --- /dev/null +++ b/packages/io/src/protocol/io.ts @@ -0,0 +1,1488 @@ +import {unset, type Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {authorInstructions, lineUpList} from './apply' +import { + applyWithContentLakeSemantics, + hasTarget, + resolvePath, +} from './content-lake' +import {blocksForEditor, isBelowFloor, isObject, repairToFloor} from './floor' +import {childrenOf, isEqual, itemKey, keyOf} from './nodes' +import type { + EditorForIo, + ErrorEvent, + Load, + Mutation, + MutationRejected, + MutationSent, + Resync, + Transaction, + WorkDropped, +} from './types' + +const heldTransactionTimeout = 10_000 +const livenessTimeout = 10_000 + +export type Clock = { + now: () => number + /** Returns a function that cancels the callback. */ + schedule: (delay: number, callback: () => void) => () => void +} + +/** `'loading'` until the editor's `ready`, `'unmounted'` after its `closing`. */ +export type IoStatus = 'loading' | 'ready' | 'unmounted' + +/** + * Whether the user's work is saved: `'saving'` while a mutation is in flight or + * changes are pending, `'blocked'` after a rejection and `'out of step'` after + * an error or a lost feed, both until the next resync. + */ +export type IoSync = 'synced' | 'saving' | 'blocked' | 'out of step' + +export type IoEvent = + | ({type: 'mutation'} & Mutation) + | ({type: 'error'} & ErrorEvent) + | ({type: 'work dropped'} & WorkDropped) + | {type: 'warning'; message: string} + +/** + * What the host tells io: the first content, a transaction from the feed, + * what became of a mutation, a lost feed, a fresh copy, and that io is done. + * `load` is accepted only before the editor's `ready`, and a second `load` + * replaces the first. `close` does what the editor's `closing` does. + */ +export type IoMessage = + | ({type: 'load'} & Load) + | ({type: 'transaction'} & Transaction) + | ({type: 'mutation sent'} & MutationSent) + | ({type: 'mutation rejected'} & MutationRejected) + | {type: 'feed lost'} + | ({type: 'resync'} & Resync) + | {type: 'close'} + +/** + * io's state, shaped like the editor's snapshot. `rev` is the base's + * revision, `inFlight` the mutation in flight with the transaction ID it is + * saved under, and `pending` how many local changes wait to be sent. + */ +export type IoSnapshot = { + context: { + status: IoStatus + sync: IoSync + rev: string | undefined + inFlight: {id: string; transactionId: string} | undefined + pending: number + } +} + +/** + * io as a store, shaped like the editor: `getSnapshot` returns the same + * object until something in it changes, `subscribe` calls `next` after + * every change, `on` listens to what io tells the host, and `send` takes + * what the host tells io. + */ +export type Io = { + getSnapshot: () => IoSnapshot + subscribe: ( + observer: + | { + next?: (snapshot: IoSnapshot) => void + error?: (error: unknown) => void + complete?: () => void + } + | ((snapshot: IoSnapshot) => void), + ) => {unsubscribe: () => void} + on: ( + type: TType, + listener: ( + event: IoEvent & (TType extends '*' ? unknown : {type: TType}), + ) => void, + ) => {unsubscribe: () => void} + send: (message: IoMessage) => void +} + +/** + * A mutation the editor has sent and the base doesn't hold yet. + * `transactionIds` holds the proposed transaction ID until the host names + * another with `mutation sent`. + */ +export type IoSentMutation = { + id: string + transactionIds: Array + patchCount: number +} + +/** + * The editor's protocol state, for display. `echoed` are mutations whose + * transaction came back but waits in `held` behind a missing one, and + * `pending` holds one entry per local change not sent yet. + */ +export type IoLedger = { + inFlight: IoSentMutation | undefined + rejected: IoSentMutation | undefined + echoed: Array + pending: Array<{patchCount: number; patches: Array}> + held: Array< + Pick & { + arrivedAt: number + } + > + outOfStep: boolean +} + +/** + * What io holds beyond its snapshot, for the model's tests, its world and + * its stand-in for the editor's history. The working copy is the base with + * the unconfirmed mutations and the pending changes applied, without the + * blocks that aren't objects: what the editor shows, the placeholder aside. + * `getLayers` returns the base and what lies on it, in order. `warn` emits + * a `warning`, and `tap` hears what io is about to do (see `IoTap`). + */ +export type IoInternals = { + getBase: () => Load + getWorkingCopy: () => Array | undefined + inspect: () => IoLedger + getLayers: () => { + base: Array | undefined + unconfirmed: Array<{id: string; patches: Array}> + pending: Array + } + getStatus: () => IoStatus + warn: (message: string) => void + tap: (tap: IoTap) => {unsubscribe: () => void} +} + +/** + * Moments io reports to a tap, synchronously, before it acts on them: + * `localChange` before it books a local change as pending, with the working + * copy before the change and how many pending patches come before it, + * `mutation` before it emits a mutation, `transaction` before a transaction + * moves the base, with the mutations it confirms and the editor's own + * patches in it, and `resync` once a resync has taken its copy. + */ +export type IoTap = { + localChange?: (change: { + operations: Array + workingCopyBefore: Array | undefined + patchOffset: number + }) => void + mutation?: (id: string) => void + transaction?: (transaction: { + confirmedMutationIds: Set + patches: Array + valueBefore: Array | undefined + ownPatches: Array + }) => void + resync?: () => void +} + +const internalsByIo = new WeakMap() + +export function getIoInternals(io: Io): IoInternals { + const internals = internalsByIo.get(io) + + if (!internals) { + throw new Error('Not an io made by `createIo`') + } + + return internals +} + +/** `io` with `extension` on top, sharing its internals. */ +export function extendIo( + io: Io, + extension: TExtension, +): Io & TExtension { + const extended = {...io, ...extension} + + internalsByIo.set(extended, getIoInternals(io)) + + return extended +} + +type SentMutation = { + id: string + patches: Array + /** + * The transaction IDs the editor takes for its own: the proposed one until + * the host names another, then every one the host named. + */ + transactionIds: Set + /** Whether the host has sent `mutation sent` for the mutation. */ + named: boolean +} + +type HeldTransaction = {transaction: Transaction; arrivedAt: number} + +/** + * The editor side of the pass-through protocol, speaking to the editor only + * through `EditorForIo`. It is created during the editor's first commit. + * Mutation IDs are the editor's `id` plus a counter, so editors with different + * IDs never share one. The transaction ID proposed with each mutation comes + * from `transactionIdGenerator`, a random UUID by default, since a + * transaction ID must be unique across every writer of the document, not + * only among this editor's mutations. `keyGenerator` mints the new keys a resync gives pending + * inserts whose keys the copy has. Keys io mints to repair a received value + * come from the value's revision and the repaired node's path instead (see + * `repairToFloor`). + */ +export function createIo(options: { + id: string + editor: EditorForIo + keyGenerator: () => string + transactionIdGenerator?: () => string + clock: Clock +}): Io { + const { + editor, + keyGenerator, + transactionIdGenerator = randomTransactionId, + clock, + } = options + const listeners = new Set<(event: IoEvent) => void>() + const observers = new Set<(snapshot: IoSnapshot) => void>() + const taps = new Set() + const emittedMutationIds = new Set() + + let status: IoStatus = 'loading' + let base: Load = {value: undefined, rev: undefined} + let outOfStep = false + let mutationCounter = 0 + let inFlight: SentMutation | undefined + let rejected: SentMutation | undefined + let echoedAwaitingBase: Array = [] + let pending: Array> = [] + let held: Array = [] + let cancelHeldTimeout: (() => void) | undefined + let cancelInFlightWarning: (() => void) | undefined + const droppedPatches = new WeakSet() + let snapshot: IoSnapshot = {context: readContext()} + let publishedSnapshot = snapshot + + const editorSubscriptions = [ + editor.on('ready', () => { + becomeReady() + publish() + }), + editor.on('closing', () => { + close() + publish() + }), + editor.on('change', (event) => { + if (event.origin === 'local') { + takeLocalChange(event) + publish() + } + }), + ] + + function readContext(): IoSnapshot['context'] { + const transactionId = inFlight + ? [...inFlight.transactionIds].at(-1) + : undefined + + return { + status, + sync: getSync(), + rev: base.rev, + inFlight: + inFlight && transactionId !== undefined + ? {id: inFlight.id, transactionId} + : undefined, + pending: pending.length, + } + } + + function getSnapshot(): IoSnapshot { + const context = readContext() + + if (!isEqual(context, snapshot.context)) { + snapshot = {context} + } + + return snapshot + } + + function publish() { + const current = getSnapshot() + + if (current === publishedSnapshot) { + return + } + + publishedSnapshot = current + + for (const observer of observers) { + observer(current) + } + } + + function receive(message: IoMessage) { + switch (message.type) { + case 'load': { + const {type: _type, ...incoming} = message + load(incoming) + break + } + case 'transaction': { + const {type: _type, ...incoming} = message + transaction(incoming) + break + } + case 'mutation sent': { + const {type: _type, ...incoming} = message + mutationSent(incoming) + break + } + case 'mutation rejected': { + const {type: _type, ...incoming} = message + mutationRejected(incoming) + break + } + case 'feed lost': + feedLost() + break + case 'resync': { + const {type: _type, ...incoming} = message + resync(incoming) + break + } + case 'close': + close() + break + } + + publish() + } + + function emit(event: IoEvent) { + for (const listener of listeners) { + listener(event) + } + } + + function warn(message: string) { + emit({type: 'warning', message}) + } + + function becomeReady() { + if (status !== 'loading') { + return + } + + status = 'ready' + flush() + } + + function load(incoming: Load) { + if (status === 'unmounted') { + warn('Ignored a load after the editor unmounted') + return + } + + if (status !== 'loading') { + throw new Error( + '`load` is only accepted in the first commit, before the editor is ready', + ) + } + + base = {value: incoming.value, rev: incoming.rev} + pending = [] + queueFloorRepair(incoming) + editor.send({type: 'load', value: deriveScreen()}) + } + + function resync(incoming: Resync) { + if (status === 'unmounted') { + warn('Ignored a resync after the editor unmounted') + return + } + + if (status === 'loading') { + throw new Error('`resync` is not accepted before the editor is ready') + } + + if (inFlight && incoming.outcomes?.[inFlight.id] === undefined) { + warn( + `Refused a resync while mutation "${inFlight.id}" is in flight: wait until it comes back or is rejected, or say what became of it`, + ) + return + } + + const notApplied = + inFlight && incoming.outcomes?.[inFlight.id] === 'not applied' + ? inFlight + : undefined + const droppedRejected = rejected + const screenBefore = deriveScreen() + + base = {value: incoming.value, rev: incoming.rev} + inFlight = undefined + stopInFlightWarning() + rejected = undefined + echoedAwaitingBase = [] + outOfStep = false + releaseHeld() + + for (const tap of taps) { + tap.resync?.() + } + + if (notApplied) { + pending = [notApplied.patches, ...pending] + } + + if (incoming.discardUnsent) { + pending = [] + } + + queueFloorRepair(incoming) + rekeyPendingInserts(screenBefore) + editor.send({type: 'resync', value: deriveScreen()}) + + if (droppedRejected) { + emit({ + type: 'work dropped', + patches: droppedRejected.patches, + reason: 'rejected', + }) + } + + reportDroppedPending('the resync') + flush() + } + + /** + * Reports each pending patch that has no target on the screen being built, + * once. The patches stay pending and go out, as no-ops, with the next mutation. + */ + function reportDroppedPending(after: string) { + let value = applyWithContentLakeSemantics( + base.value, + unconfirmedMutations().flatMap((mutation) => mutation.patches), + ) + const dropped: Array = [] + + for (const patch of pending.flat()) { + if (!hasTarget(value, patch) && !droppedPatches.has(patch)) { + droppedPatches.add(patch) + dropped.push(patch) + } + + value = applyWithContentLakeSemantics(value, [patch]) + } + + if (dropped.length > 0) { + warn( + `${dropped.length} unsent patches had no target after ${after} and did nothing`, + ) + emit({type: 'work dropped', patches: dropped, reason: 'no target'}) + } + } + + function transaction(incoming: Transaction) { + if (status === 'unmounted') { + warn( + `Ignored transaction "${incoming.transactionId}" after the editor unmounted`, + ) + return + } + + if (status === 'loading') { + throw new Error( + '`transaction` is not accepted before the editor is ready', + ) + } + + const mismatch = outOfStep ? undefined : echoMismatch(incoming) + + noteOwnTransaction(incoming.transactionId) + + if (mismatch) { + fail({ + reason: 'echo mismatch', + transactionId: incoming.transactionId, + patch: mismatch, + }) + } + + if (outOfStep) { + flush() + return + } + + if (incoming.previousRev !== base.rev) { + held = [...held, {transaction: incoming, arrivedAt: clock.now()}] + scheduleHeldTimeout() + flush() + return + } + + let next: Transaction | undefined = incoming + + while (next && applyTransaction(next)) { + next = takeConnectedHeldTransaction() + } + + scheduleHeldTimeout() + flush() + } + + /** + * A `set` or `unset` in the editor's own echo that the editor didn't send, + * above a path its mutation touched: the host widened the mutation. Other patches + * can't overwrite the mutation's work, and patches at the same path or + * elsewhere can be other writers' mutations folded into the same transaction. + */ + function echoMismatch(incoming: Transaction): Patch | undefined { + if (!inFlight?.transactionIds.has(incoming.transactionId)) { + return undefined + } + + const sentPatches = inFlight.patches + + return incoming.patches.find( + (patch) => + (patch.type === 'set' || patch.type === 'unset') && + !sentPatches.some((sentPatch) => isEqual(sentPatch, patch)) && + sentPatches.some((sentPatch) => + isAncestorPath(patch.path, sentPatch.path), + ), + ) + } + + function noteOwnTransaction(transactionId: string) { + if (inFlight?.transactionIds.has(transactionId)) { + echoedAwaitingBase = [...echoedAwaitingBase, inFlight] + inFlight = undefined + stopInFlightWarning() + } + } + + function takeConnectedHeldTransaction(): Transaction | undefined { + const connected = held.find( + (candidate) => candidate.transaction.previousRev === base.rev, + ) + + if (!connected) { + return undefined + } + + held = held.filter((candidate) => candidate !== connected) + + return connected.transaction + } + + function applyTransaction(incoming: Transaction): boolean { + const confirmedMutationIds = new Set( + echoedAwaitingBase + .filter((mutation) => + mutation.transactionIds.has(incoming.transactionId), + ) + .map((mutation) => mutation.id), + ) + const unmatchedOwnPatches = echoedAwaitingBase + .filter((mutation) => confirmedMutationIds.has(mutation.id)) + .flatMap((mutation) => mutation.patches) + const ownPatchesWithoutTarget: Array = [] + let nextValue = base.value + + for (const patch of incoming.patches) { + const ownIndex = unmatchedOwnPatches.findIndex((ownPatch) => + isEqual(ownPatch, patch), + ) + + if (ownIndex !== -1) { + const [ownPatch] = unmatchedOwnPatches.splice(ownIndex, 1) + + if (!hasTarget(nextValue, patch)) { + ownPatchesWithoutTarget.push(ownPatch) + } + } + + if ( + patch.type === 'insert' && + insertCollides(patch, keysAmongSiblings(nextValue, patch.path)) + ) { + return fail({ + reason: 'duplicate key', + transactionId: incoming.transactionId, + patch, + }) + } + + try { + nextValue = applyWithContentLakeSemantics(nextValue, [patch]) + } catch { + return fail({ + reason: 'patch failed', + transactionId: incoming.transactionId, + patch, + }) + } + } + + const valueFromPatches = nextValue + + if ('value' in incoming) { + nextValue = incoming.value + } + + if (touchedBlocks(base.value, nextValue).some(isBelowFloor)) { + return fail({ + reason: 'invalid content', + transactionId: incoming.transactionId, + }) + } + + const unconfirmedLists = insertedKeysByList( + [ + ...echoedAwaitingBase.filter( + (mutation) => !confirmedMutationIds.has(mutation.id), + ), + ...(inFlight ? [inFlight] : []), + ...(rejected ? [rejected] : []), + ].flatMap((mutation) => mutation.patches), + ) + const unconfirmedCollision = incoming.patches.find( + (patch) => + patch.type === 'insert' && + insertCollides( + patch, + unconfirmedLists.get(listId(patch.path.slice(0, -1)))?.keys ?? + new Set(), + ), + ) + const collision = unconfirmedCollision + ? {patch: unconfirmedCollision} + : pendingKeyCollision(incoming.patches, nextValue) + + if (collision) { + return fail({ + reason: 'duplicate key', + transactionId: incoming.transactionId, + ...collision, + }) + } + + const screenBefore = deriveScreen() + const valueBefore = base.value + const ownPatches = echoedAwaitingBase + .filter((mutation) => confirmedMutationIds.has(mutation.id)) + .flatMap((mutation) => mutation.patches) + const unconfirmedPatches = unconfirmedMutations().flatMap( + (mutation) => mutation.patches, + ) + + for (const tap of taps) { + tap.transaction?.({ + confirmedMutationIds, + patches: incoming.patches, + valueBefore, + ownPatches, + }) + } + + base = {value: nextValue, rev: incoming.resultRev} + echoedAwaitingBase = echoedAwaitingBase.filter( + (mutation) => !confirmedMutationIds.has(mutation.id), + ) + + if (incoming.patches.length > 0 || !isEqual(valueBefore, nextValue)) { + applyToEditor({ + screenBefore, + underneath: incoming.patches, + ownPatches, + unconfirmedPatches, + valueBefore, + valueFromPatches, + }) + reportDroppedPending(`transaction "${incoming.transactionId}"`) + } + + reportDroppedOwn(ownPatchesWithoutTarget, incoming.transactionId) + + return true + } + + /** + * Reports the editor's own patches that came back in its echo with no + * target in the base right before they applied, once: the server applied + * them as no-ops, so the work in them is gone. + */ + function reportDroppedOwn(patches: Array, transactionId: string) { + const dropped = patches.filter((patch) => !droppedPatches.has(patch)) + + if (dropped.length === 0) { + return + } + + for (const patch of dropped) { + droppedPatches.add(patch) + } + + warn( + `${dropped.length} sent patches had no target when transaction "${transactionId}" saved them and did nothing`, + ) + emit({type: 'work dropped', patches: dropped, reason: 'no target'}) + } + + /** + * Whether `nextBase`, the base the transaction selects (from its `value` + * or its patches), has a key in a list (the block list or a block's + * `children`) that a pending insert into that list also inserts and the + * list didn't have before, with the first of `patches` after which it + * does, if any does. Applied work is never re-keyed in place: the resync + * re-keys the pending insert. + */ + function pendingKeyCollision( + patches: Array, + nextBase: Array | undefined, + ): {patch?: Patch} | undefined { + const pendingLists = [...insertedKeysByList(pending.flat()).values()] + const keysBefore = pendingLists.map( + ({listPath}) => new Set(keysInList(base.value, listPath)), + ) + const takesPendingKey = (value: Array | undefined) => + pendingLists.some(({listPath, keys}, index) => + keysInList(value, listPath).some( + (key) => keys.has(key) && !keysBefore[index].has(key), + ), + ) + + if (!takesPendingKey(nextBase)) { + return undefined + } + + let value = base.value + + for (const patch of patches) { + value = applyWithContentLakeSemantics(value, [patch]) + + if (takesPendingKey(value)) { + return {patch} + } + } + + return {} + } + + function fail(error: ErrorEvent): false { + outOfStep = true + releaseHeld() + emit({type: 'error', ...error}) + return false + } + + function scheduleHeldTimeout() { + cancelHeldTimeout?.() + cancelHeldTimeout = undefined + + const [oldest] = held + + if (!oldest) { + return + } + + cancelHeldTimeout = clock.schedule( + oldest.arrivedAt + heldTransactionTimeout - clock.now(), + () => { + cancelHeldTimeout = undefined + fail({ + reason: 'out of order', + transactionId: oldest.transaction.transactionId, + }) + publish() + }, + ) + } + + function releaseHeld() { + held = [] + scheduleHeldTimeout() + } + + function mutationSent(incoming: MutationSent) { + if (!emittedMutationIds.has(incoming.id)) { + warn(`\`mutation sent\` for unknown mutation "${incoming.id}"`) + return + } + + if (inFlight?.id !== incoming.id) { + return + } + + if (!inFlight.named) { + inFlight = { + ...inFlight, + transactionIds: new Set([incoming.transactionId]), + named: true, + } + return + } + + if (!inFlight.transactionIds.has(incoming.transactionId)) { + warn( + `\`mutation sent\` names transaction "${incoming.transactionId}" for mutation "${incoming.id}", already sent as ${[ + ...inFlight.transactionIds, + ] + .map((transactionId) => `"${transactionId}"`) + .join(', ')}: a retry must reuse the transaction ID`, + ) + inFlight = { + ...inFlight, + transactionIds: new Set([ + ...inFlight.transactionIds, + incoming.transactionId, + ]), + } + } + } + + function feedLost() { + if (status === 'unmounted') { + warn('Ignored `feed lost` after the editor unmounted') + return + } + + if (status === 'loading') { + throw new Error('`feed lost` is not accepted before the editor is ready') + } + + outOfStep = true + releaseHeld() + warn('The feed was lost: applying no more transactions until a resync') + } + + function mutationRejected(incoming: MutationRejected) { + if (!emittedMutationIds.has(incoming.id)) { + warn(`\`mutation rejected\` for unknown mutation "${incoming.id}"`) + return + } + + if (inFlight?.id !== incoming.id) { + warn(`\`mutation rejected\` for mutation "${incoming.id}", not in flight`) + return + } + + rejected = inFlight + inFlight = undefined + stopInFlightWarning() + } + + /** Books a local change as pending. */ + function takeLocalChange({ + operations, + patches, + }: { + operations: Array + patches: Array + }) { + if (status !== 'ready' || patches.length === 0) { + return + } + + for (const tap of taps) { + tap.localChange?.({ + operations, + workingCopyBefore: deriveScreen(), + patchOffset: pending.flat().length, + }) + } + + pending = [...pending, keepingStoredNonObjects(patches)] + flush() + } + + /** + * The editor empties its field with a whole-field `unset`, which would + * also remove the stored blocks that aren't objects, though the editor + * never had them. While the working copy holds any, each whole-field + * `unset` becomes keyed `unset`s of the blocks that are objects there. + */ + function keepingStoredNonObjects(patches: Array): Array { + let value = applyWithContentLakeSemantics(base.value, [ + ...unconfirmedMutations().flatMap((mutation) => mutation.patches), + ...pending.flat(), + ]) + + if (!(value ?? []).some((block) => !isObject(block))) { + return patches + } + + return patches.flatMap((patch) => { + const kept = + patch.type === 'unset' && patch.path.length === 0 + ? blocksForEditor(value ?? []).blocks.flatMap((block) => + itemKey(block).map((key) => unset([{_key: key}])), + ) + : [patch] + + value = applyWithContentLakeSemantics(value, kept) + + return kept + }) + } + + function close() { + if (status === 'unmounted') { + return + } + + if (pending.length > 0) { + if (outOfStep) { + warn( + `${pending.length} unsent change(s) dropped on close: the editor is out of step`, + ) + emit({ + type: 'work dropped', + patches: pending.flat(), + reason: 'closed out of step', + }) + pending = [] + } else if (rejected) { + warn( + `${pending.length} unsent change(s) dropped on close: sending was blocked by the rejection of mutation ${rejected.id}`, + ) + emit({ + type: 'work dropped', + patches: pending.flat(), + reason: 'closed while blocked', + }) + pending = [] + } else { + emitMutation({final: true}) + } + } + + status = 'unmounted' + releaseHeld() + stopInFlightWarning() + + for (const subscription of editorSubscriptions) { + subscription.unsubscribe() + } + } + + function flush() { + if ( + status !== 'ready' || + outOfStep || + inFlight || + rejected || + pending.length === 0 + ) { + return + } + + emitMutation({final: false}) + } + + function emitMutation({final}: {final: boolean}) { + mutationCounter++ + + const mutation: Mutation = { + id: `${options.id}-${mutationCounter}`, + transactionId: transactionIdGenerator(), + patches: pending.flat(), + ...(final ? {final: true as const} : {}), + } + + pending = [] + emittedMutationIds.add(mutation.id) + + for (const tap of taps) { + tap.mutation?.(mutation.id) + } + + if (!final) { + inFlight = { + id: mutation.id, + patches: mutation.patches, + transactionIds: new Set([mutation.transactionId]), + named: false, + } + startInFlightWarning(mutation.id, livenessTimeout, clock.now()) + } + + emit({type: 'mutation', ...mutation}) + } + + function startInFlightWarning( + mutationId: string, + delay: number, + since: number, + ) { + cancelInFlightWarning = clock.schedule(delay, () => { + warn( + `Mutation "${mutationId}" has been in flight for ${clock.now() - since} ms without coming back`, + ) + startInFlightWarning(mutationId, delay * 2, since) + }) + } + + function stopInFlightWarning() { + cancelInFlightWarning?.() + cancelInFlightWarning = undefined + } + + /** + * Sends the editor a transaction's effect as keyed instructions for its + * tree, authored from the working copy before and after the transaction. + * The editor's own patches in the transaction are already on screen. Work + * that was unconfirmed when the transaction arrived, `unconfirmedPatches` + * and the pending changes, decides how the rest arrive (see + * `authorInstructions`). A transaction that left the working copy as it + * was goes out with no `patches`: its `underneath` is what the editor's + * history needs, since a remote change under a local one shows nowhere on + * screen. The instructions are authored against the working copy over + * `valueFromPatches`, the base the patches alone make, so a transaction + * with `value` gets the same instructions as one without. A base taken + * from the transaction's `value` can differ from that in ways the patches + * don't say, and that difference is lined up after them. + */ + function applyToEditor({ + screenBefore, + underneath, + ownPatches, + unconfirmedPatches, + valueBefore, + valueFromPatches, + }: { + screenBefore: Array | undefined + underneath: Array + ownPatches: Array + unconfirmedPatches: Array + valueBefore: Array | undefined + valueFromPatches: Array | undefined + }) { + const screen = deriveScreen() + + if (isEqual(screenBefore, screen)) { + editor.send({type: 'apply', patches: [], underneath}) + return + } + + const screenFromPatches = deriveScreen(valueFromPatches) + const instructions = authorInstructions({ + stored: valueBefore, + shown: screenBefore, + wanted: screenFromPatches, + patches: withoutPatches(underneath, ownPatches), + unlanded: [...unconfirmedPatches, ...pending.flat()], + }) + + editor.send({ + type: 'apply', + patches: isEqual(screenFromPatches, screen) + ? instructions + : [ + ...instructions, + ...lineUpList(screenFromPatches ?? [], screen ?? [], []), + ], + underneath, + }) + } + + function deriveScreen( + baseValue: Array | undefined = base.value, + ): Array | undefined { + const workingCopy = applyWithContentLakeSemantics(baseValue, [ + ...unconfirmedMutations().flatMap((mutation) => mutation.patches), + ...pending.flat(), + ]) + + return workingCopy === undefined + ? undefined + : blocksForEditor(workingCopy).blocks + } + + /** Sent mutations the base doesn't hold yet, in the order they were sent. */ + function unconfirmedMutations(): Array { + return [ + ...echoedAwaitingBase, + ...(inFlight ? [inFlight] : []), + ...(rejected ? [rejected] : []), + ] + } + + /** + * Gives each pending insert whose key its list in the base has a new key, + * in its later patches too. Only a resync does this. + */ + function rekeyPendingInserts( + screenBefore: Array | undefined, + ) { + const pendingLists = [...insertedKeysByList(pending.flat()).values()] + const takenKeys = new Set([ + ...keysInValue(base.value), + ...keysInValue(screenBefore), + ...pendingLists.flatMap(({keys}) => [...keys]), + ]) + const renames = pendingLists.flatMap(({listPath, keys}) => { + const keysThere = new Set(keysInList(base.value, listPath)) + const newKeys = new Map() + + for (const key of keys) { + if (keysThere.has(key)) { + newKeys.set(key, generateUniqueKey(keyGenerator, takenKeys)) + } + } + + return newKeys.size > 0 ? [{listPath, newKeys}] : [] + }) + + if (renames.length === 0) { + return + } + + const childListsFirst = renames.toSorted( + (renameA, renameB) => renameB.listPath.length - renameA.listPath.length, + ) + + pending = pending.map((patches) => + patches.map((patch) => { + const renamed = childListsFirst.reduce(renameKeys, patch) + + if (droppedPatches.has(patch)) { + droppedPatches.add(renamed) + } + + return renamed + }), + ) + } + + function queueFloorRepair(incoming: Load) { + const repairPatches = repairToFloor(incoming) + const leftOutCount = + (incoming.value ?? []).length - + blocksForEditor(incoming.value ?? []).blocks.length + + if (leftOutCount > 0) { + warn(`Left out ${leftOutCount} blocks that are not objects`) + } + + if (repairPatches.length > 0) { + warn( + `Repaired ${repairPatches.length} places below the floor or with missing or duplicate keys`, + ) + pending = [repairPatches, ...pending] + } + } + + function getSync(): IoSync { + if (outOfStep) { + return 'out of step' + } + + if (rejected) { + return 'blocked' + } + + return inFlight || pending.length > 0 ? 'saving' : 'synced' + } + + const io: Io = { + getSnapshot, + subscribe: (observer) => { + const next = + typeof observer === 'function' + ? observer + : observer.next?.bind(observer) + const callNext = (current: IoSnapshot) => next?.(current) + + observers.add(callNext) + + return { + unsubscribe: () => { + observers.delete(callNext) + }, + } + }, + on: (type, listener) => { + const listenToType = (event: IoEvent) => { + if (isOfType(event, type)) { + listener(event) + } + } + + listeners.add(listenToType) + + return { + unsubscribe: () => { + listeners.delete(listenToType) + }, + } + }, + send: receive, + } + + internalsByIo.set(io, { + getBase: () => base, + getWorkingCopy: () => deriveScreen(), + inspect: () => ({ + inFlight: inFlight ? describeSentMutation(inFlight) : undefined, + rejected: rejected ? describeSentMutation(rejected) : undefined, + echoed: echoedAwaitingBase.map(describeSentMutation), + pending: pending.map((patches) => ({ + patchCount: patches.length, + patches, + })), + held: held.map(({transaction, arrivedAt}) => ({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev, + resultRev: transaction.resultRev, + arrivedAt, + })), + outOfStep, + }), + getLayers: () => ({ + base: base.value, + unconfirmed: unconfirmedMutations().map(({id, patches}) => ({ + id, + patches, + })), + pending: pending.flat(), + }), + getStatus: () => status, + warn, + tap: (tap) => { + taps.add(tap) + + return { + unsubscribe: () => { + taps.delete(tap) + }, + } + }, + }) + + return io +} + +/** + * A version 4 UUID, from `crypto.randomUUID` where the runtime has it and + * from `Math.random` otherwise. + */ +function randomTransactionId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (digit) => { + const random = Math.floor(Math.random() * 16) + + return (digit === 'x' ? random : (random % 4) + 8).toString(16) + }) +} + +function isOfType( + event: IoEvent, + type: TType, +): event is IoEvent & (TType extends '*' ? unknown : {type: TType}) { + return type === '*' || event.type === type +} + +function describeSentMutation(mutation: SentMutation): IoSentMutation { + return { + id: mutation.id, + transactionIds: [...mutation.transactionIds], + patchCount: mutation.patches.length, + } +} + +/** + * Calls the key generator until it returns a key that isn't taken, and marks + * that key as taken. + */ +function generateUniqueKey( + keyGenerator: () => string, + takenKeys: Set, +): string { + let key = keyGenerator() + + while (takenKeys.has(key)) { + key = keyGenerator() + } + + takenKeys.add(key) + + return key +} + +/** + * `patches` without `removed`, each removed patch matching one equal patch. + */ +function withoutPatches( + patches: Array, + removed: Array, +): Array { + const unmatched = [...removed] + + return patches.filter((patch) => { + const index = unmatched.findIndex((candidate) => isEqual(candidate, patch)) + + if (index === -1) { + return true + } + + unmatched.splice(index, 1) + + return false + }) +} + +/** + * The blocks of `after` that `before` has no equal of, each block of + * `before` matching one equal block of `after`. + */ +function touchedBlocks( + before: Array | undefined, + after: Array | undefined, +): Array { + const unmatched = [...(before ?? [])] + + return (after ?? []).filter((block) => { + const index = unmatched.findIndex((candidate) => isEqual(candidate, block)) + + if (index === -1) { + return true + } + + unmatched.splice(index, 1) + + return false + }) +} + +/** Whether `ancestor` is a strict prefix of `path`. */ +function isAncestorPath(ancestor: Patch['path'], path: Patch['path']): boolean { + return ( + ancestor.length < path.length && + ancestor.every((segment, index) => isEqual(segment, path[index])) + ) +} + +function keysAmongSiblings( + value: Array | undefined, + path: Patch['path'], +): Set { + const siblings = resolvePath(value, path.slice(0, -1)) + + return new Set( + Array.isArray(siblings) ? siblings.flatMap((item) => itemKey(item)) : [], + ) +} + +/** + * Whether an insert brings a key that is already taken, or brings the same + * key twice. + */ +function insertCollides(patch: Patch, keys: Set): boolean { + if (patch.type !== 'insert') { + return false + } + + const insertedKeys = patch.items.flatMap((item) => itemKey(item)) + + return ( + new Set(insertedKeys).size < insertedKeys.length || + insertedKeys.some((key) => keys.has(key)) + ) +} + +/** + * The keys `patches` insert, by the list they insert into (the block list, + * or a block's `children`), keyed by `listId`. + */ +function insertedKeysByList( + patches: Array, +): Map}> { + const lists = new Map}>() + + for (const patch of patches) { + if (patch.type !== 'insert') { + continue + } + + const listPath = patch.path.slice(0, -1) + const list = lists.get(listId(listPath)) ?? {listPath, keys: new Set()} + + for (const item of patch.items) { + for (const key of itemKey(item)) { + list.keys.add(key) + } + } + + lists.set(listId(listPath), list) + } + + return lists +} + +function listId(listPath: Patch['path']): string { + return JSON.stringify(listPath) +} + +function keysInList( + value: Array | undefined, + listPath: Patch['path'], +): Array { + const list = resolvePath(value, listPath) + + return Array.isArray(list) ? list.flatMap((item) => itemKey(item)) : [] +} + +/** Every key of the blocks and their children. */ +function keysInValue( + value: Array | undefined, +): Array { + return (value ?? []).flatMap((block) => + isObject(block) + ? [ + ...itemKey(block), + ...childrenOf(block).flatMap((child) => itemKey(child)), + ] + : [], + ) +} + +/** + * Renames the keys of `newKeys` in the list at `listPath`: in the items an + * insert into the list brings, and in a path through one of its items. + */ +function renameKeys( + patch: Patch, + {listPath, newKeys}: {listPath: Patch['path']; newKeys: Map}, +): Patch { + const depth = listPath.length + const inList = + patch.path.length > depth && + listPath.every((segment, index) => isEqual(segment, patch.path[index])) + const itemSegmentKey = inList ? keyOf(patch.path[depth]) : undefined + const renamedPath = + itemSegmentKey !== undefined && newKeys.has(itemSegmentKey) + ? [ + ...patch.path.slice(0, depth), + {_key: newKeys.get(itemSegmentKey) ?? itemSegmentKey}, + ...patch.path.slice(depth + 1), + ] + : patch.path + + if (patch.type === 'insert' && inList && patch.path.length === depth + 1) { + return { + ...patch, + path: renamedPath, + items: patch.items.map((item) => { + const [key] = itemKey(item) + + return key !== undefined && newKeys.has(key) && isObject(item) + ? {...item, _key: newKeys.get(key) ?? key} + : item + }), + } + } + + return {...patch, path: renamedPath} +} diff --git a/packages/io/src/protocol/nodes.ts b/packages/io/src/protocol/nodes.ts new file mode 100644 index 0000000000..158a17f110 --- /dev/null +++ b/packages/io/src/protocol/nodes.ts @@ -0,0 +1,61 @@ +import type {Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' + +export function keyOf( + segment: Patch['path'][number] | undefined, +): string | undefined { + return typeof segment === 'object' && + !Array.isArray(segment) && + typeof segment._key === 'string' + ? segment._key + : undefined +} + +export function findBlock( + value: Array | undefined, + blockKey: string, +): PortableTextBlock | undefined { + return value?.find((candidate) => candidate._key === blockKey) +} + +export function childrenOf(block: PortableTextBlock): Array { + const children: unknown = Reflect.get(block, 'children') + + return Array.isArray(children) ? children : [] +} + +export function itemKey(item: unknown): Array { + if (typeof item !== 'object' || item === null || !('_key' in item)) { + return [] + } + + return typeof item._key === 'string' && item._key !== '' ? [item._key] : [] +} + +export function isEqual(valueA: unknown, valueB: unknown): boolean { + if (valueA === valueB) { + return true + } + + if ( + typeof valueA !== 'object' || + typeof valueB !== 'object' || + valueA === null || + valueB === null || + Array.isArray(valueA) !== Array.isArray(valueB) + ) { + return false + } + + const keysA = Object.keys(valueA) + const keysB = Object.keys(valueB) + + return ( + keysA.length === keysB.length && + keysA.every( + (key) => + Object.hasOwn(valueB, key) && + isEqual(Reflect.get(valueA, key), Reflect.get(valueB, key)), + ) + ) +} diff --git a/packages/io/src/protocol/text-edits.ts b/packages/io/src/protocol/text-edits.ts new file mode 100644 index 0000000000..4f63b24d5e --- /dev/null +++ b/packages/io/src/protocol/text-edits.ts @@ -0,0 +1,98 @@ +import type {DiffMatchPatch, Patch} from '@portabletext/patches' +import { + DIFF_DELETE, + DIFF_EQUAL, + DIFF_INSERT, + adjustIndiciesToUcs2, + makePatches, + parsePatch, + stringifyPatches, + type Diff, +} from '@sanity/diff-match-patch' + +/** + * Text inserted or deleted at an offset, in the text as the edits before it + * left it. + */ +export type TextEdit = {type: 'insert' | 'delete'; offset: number; text: string} + +/** + * A `diffMatchPatch` that deletes `deleteLength` characters at `offset` and + * inserts `insertText` there. Unlike a patch computed from the text before + * and after, it keeps the position: deleting the first `foo` of `foofoo` + * says so. + */ +export function textEditPatch( + text: string, + edit: {offset: number; deleteLength?: number; insertText?: string}, + path: Patch['path'], +): DiffMatchPatch { + const end = edit.offset + (edit.deleteLength ?? 0) + const diffs: Array = [ + [DIFF_EQUAL, text.slice(0, edit.offset)], + [DIFF_DELETE, text.slice(edit.offset, end)], + [DIFF_INSERT, edit.insertText ?? ''], + [DIFF_EQUAL, text.slice(end)], + ] + + return { + type: 'diffMatchPatch', + path, + value: stringifyPatches( + makePatches( + text, + diffs.filter(([, diffText]) => diffText !== ''), + ), + ), + } +} + +/** + * The insertions and deletions a `diffMatchPatch` value makes to `text`, at + * the positions the patch names. + */ +export function textEditsOf(value: string, text: string): Array { + const edits: Array = [] + + for (const hunk of adjustIndiciesToUcs2(parsePatch(value), text)) { + let offset = hunk.start2 + + for (const [operation, diffText] of hunk.diffs) { + if (operation === DIFF_EQUAL) { + offset += diffText.length + } else if (operation === DIFF_INSERT) { + edits.push({type: 'insert', offset, text: diffText}) + offset += diffText.length + } else { + edits.push({type: 'delete', offset, text: diffText}) + } + } + } + + return edits +} + +/** + * Where an offset lands after the edits. Text inserted at the offset goes + * before it, and an offset inside deleted text goes to the deletion's start. + */ +export function mapOffsetThrough( + offset: number, + edits: Array, +): number { + let mapped = offset + + for (const edit of edits) { + if (edit.type === 'insert') { + if (edit.offset <= mapped) { + mapped += edit.text.length + } + } else if (edit.offset + edit.text.length <= mapped) { + mapped -= edit.text.length + } else if (edit.offset < mapped) { + mapped = edit.offset + } + } + + return mapped +} diff --git a/packages/io/src/protocol/types.ts b/packages/io/src/protocol/types.ts new file mode 100644 index 0000000000..9f327483eb --- /dev/null +++ b/packages/io/src/protocol/types.ts @@ -0,0 +1,146 @@ +import type {Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' + +/** + * One transaction the server recorded on the document the editor saves to, + * with its patches scoped to the field. `value`, when present, is the field + * as the server holds it after the transaction, from the listener's result: + * the editor takes it as its base instead of applying `patches` to the old + * one. `patches` still travel, for the editor's tree, `change` and the echo + * check. + */ +export type Transaction = { + transactionId: string + previousRev: string | undefined + resultRev: string | undefined + patches: Array + value?: Array | undefined +} + +/** + * The patches the editor hands to its host to save, as one mutation, with + * the mutation ID `id`. `transactionId` is the transaction ID the editor + * proposes for saving it: a host that saves the mutation as its own request + * uses it as is, and a host that chooses another names that one with + * `mutation sent`. The mutation carries no value: the patches are the save. + */ +export type Mutation = { + id: string + transactionId: string + patches: Array + final?: true +} + +export type MutationSent = {id: string; transactionId: string} + +export type MutationRejected = {id: string} + +/** + * The server's copy of the field and the document revision it is at. `rev` + * is `undefined` when the document doesn't exist. + */ +export type Load = { + value: Array | undefined + rev: string | undefined +} + +/** + * `outcomes` says, by mutation ID, whether a mutation the editor sent is in the + * copy (`'applied'`) or was never saved (`'not applied'`). A mutation not + * applied rejoins the pending changes, ahead of the rest. + */ +export type Resync = Load & { + discardUnsent?: true + outcomes?: Record +} + +export type ErrorEvent = { + reason: + | 'out of order' + | 'duplicate key' + | 'patch failed' + | 'echo mismatch' + | 'invalid content' + transactionId?: string + patch?: Patch +} + +/** + * The user's own work the editor gave up on: pending changes with no + * target, the editor's own patches that came back in its echo with no + * target in the base right before they applied (the server applied them as + * no-ops), pending changes dropped on close while sending was blocked or + * the editor was out of step, or the rejected mutation a resync dropped. + * Each patch is reported once. + */ +export type WorkDropped = { + patches: Array + reason: + | 'no target' + | 'closed while blocked' + | 'closed out of step' + | 'rejected' +} + +/** + * `operations` carries patches as the model's stand-in for the editor's + * operations: the action's patches for a local change, and a whole-value + * `set` for a re-derived screen. A local change's `patches` are the patches + * that will go into its mutation, save that io turns a whole-field `unset` + * into keyed `unset`s while the stored field holds blocks that aren't + * objects. A remote change has nothing to save, so it carries no + * `patches`. + */ +export type ChangeEvent = + | {origin: 'local'; operations: Array; patches: Array} + | {origin: 'remote'; operations: Array} + +/** + * The whole of what io uses from an editor: it listens to `change`, `ready` + * and `closing`, and sends `load`, `resync` and `apply`. A structural type, + * shaped like the editor's own `on` (one event type per listener, answered + * with an `unsubscribe`) and `send`. + */ +export type EditorForIo = { + on: ( + type: TType, + listener: (event: EditorEventForIo & {type: TType}) => void, + ) => {unsubscribe: () => void} + send: (message: EditorMessageForIo) => void +} + +/** + * `change` fires once per user action and once per `resync` or `apply` that + * changed the content. `ready` fires at the end of the first commit, and + * `closing` just before the editor stops: the last moment to send. + */ +export type EditorEventForIo = + | { + type: 'change' + origin: 'local' + operations: Array + patches: Array + } + | {type: 'change'; origin: 'remote'; operations: Array} + | {type: 'ready'} + | {type: 'closing'} + +/** + * `load` is the first content, accepted only during the first commit, and + * `resync` a fresh copy: both are whole values, matched to the tree by key. + * `apply` is one transaction's effect on the content: `patches` is what to + * do to the tree, keyed instructions io authors from its working copy, and + * `underneath` is the transaction's patches, for history. The editor's own + * patches in the transaction are left out, another writer's patch on a + * place the editor's unsaved work didn't touch comes as it is, and a block + * both touched comes as a `set` of the block. A list the transaction + * inserted into, removed from or re-keyed comes lined up key by key when + * the editor's unsaved work touched it too, and always when a key changed. + * A transaction that moved the base and left the screen as it was comes + * with no `patches`, so its `underneath` still reaches the editor's + * history. + */ +export type EditorMessageForIo = + | {type: 'load'; value: Array | undefined} + | {type: 'resync'; value: Array | undefined} + | {type: 'apply'; patches: Array; underneath: Array} diff --git a/packages/io/src/scenario/check.ts b/packages/io/src/scenario/check.ts new file mode 100644 index 0000000000..f122dd503a --- /dev/null +++ b/packages/io/src/scenario/check.ts @@ -0,0 +1,45 @@ +/** + * The checks the `Then` steps make. They throw a plain `Error` naming what + * was checked, so the steps run outside a test framework as well as in one. + */ +export function checkEqual( + what: string, + actual: string | number | boolean | undefined, + expected: string | number | boolean | undefined, +): void { + if (!Object.is(actual, expected)) { + throw new Error( + `${what}: expected ${format(expected)}, got ${format(actual)}`, + ) + } +} + +export function checkNotEqual( + what: string, + actual: string | number | boolean | undefined, + unexpected: string | number | boolean | undefined, +): void { + if (Object.is(actual, unexpected)) { + throw new Error(`${what}: expected anything but ${format(unexpected)}`) + } +} + +export function checkGreaterThan( + what: string, + actual: number, + bound: number, +): void { + if (!(actual > bound)) { + throw new Error(`${what}: expected more than ${bound}, got ${actual}`) + } +} + +export function checkEmpty(what: string, actual: Array): void { + if (actual.length > 0) { + throw new Error(`${what}: expected none, got ${format(actual)}`) + } +} + +function format(value: unknown): string { + return value === undefined ? 'undefined' : JSON.stringify(value) +} diff --git a/packages/io/src/scenario/compile.test.ts b/packages/io/src/scenario/compile.test.ts new file mode 100644 index 0000000000..200e8a32ef --- /dev/null +++ b/packages/io/src/scenario/compile.test.ts @@ -0,0 +1,200 @@ +import {describe, expect, test} from 'vitest' +import listenersFeature from '../../gherkin-spec/listeners.feature?raw' +import loadingAndEmptyFeature from '../../gherkin-spec/loading-and-empty.feature?raw' +import {compileScenarios} from './compile' +import {createWorld} from './world' + +describe(compileScenarios.name, () => { + test('lists every scenario and every row of an outline', () => { + const {feature, scenarios} = compileScenarios(loadingAndEmptyFeature) + + expect({ + feature, + names: scenarios.map((scenario) => scenario.name), + }).toEqual({ + feature: 'Loading and empty', + names: [ + 'A load in the first commit makes the editor ready with the content, and no change', + 'An editor nobody loads is ready and empty when its first commit ends, and a resync fills it', + 'A load after the editor is ready is refused', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has no document)', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has no field)', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has an empty list)', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has one empty block "b1")', + 'Emptying the field sends a whole-field unset', + ], + }) + }) + + test('marks skipped scenarios and reads what a known red one lacks', () => { + const {scenarios} = compileScenarios( + [ + 'Feature: Free play', + ' Scenario: foo', + ' Given the document is "B: foo|"', + '', + ' # known red: no bar yet', + ' @skip', + ' Scenario: bar', + ' Given the document is "B: bar|"', + ].join('\n'), + ) + + expect( + scenarios.map(({name, skipped, knownRed}) => ({name, skipped, knownRed})), + ).toEqual([ + {name: 'foo', skipped: false, knownRed: undefined}, + {name: 'bar', skipped: true, knownRed: 'no bar yet'}, + ]) + }) + + test('a known red comment counts only on the line right above the scenario', () => { + const {scenarios} = compileScenarios( + [ + 'Feature: Free play', + ' # known red: the first', + '', + ' Scenario: foo', + ' Given the document is "B: foo|"', + '', + ' # known red: the second', + ' Scenario Outline: bar ', + ' Given the document is "B: |"', + '', + ' Examples:', + ' | text |', + ' | bar |', + ' | baz |', + ].join('\n'), + ) + + expect( + scenarios.map(({name, skipped, knownRed}) => ({name, skipped, knownRed})), + ).toEqual([ + {name: 'foo', skipped: false, knownRed: undefined}, + {name: 'bar bar', skipped: false, knownRed: 'the second'}, + {name: 'bar baz', skipped: false, knownRed: 'the second'}, + ]) + }) + + test('a scenario runs one step at a time to the end', async () => { + const [scenario] = compileScenarios(listenersFeature).scenarios + const world = createWorld() + + for (const step of scenario.steps) { + await step.run(world) + } + + expect( + scenario.steps.map((step) => `${step.keyword} ${step.text}`), + ).toEqual([ + 'Given the document is "B: foo|"', + 'Then Editor A has emitted no change', + 'When "x" is typed', + 'Then Editor A has emitted 1 change', + 'And Editor A has sent mutation 1', + "And Editor A's change 1 carries the patches of mutation 1", + "When the server receives Editor A's mutation 1", + "And Editor A's mutation 1 comes back", + 'Then Editor A has emitted 1 change', + 'When the style is set to "h1" in Editor B', + 'Then Editor B has sent mutation 1', + "When the server receives Editor B's mutation 1", + "And Editor A receives Editor B's mutation 1", + 'Then Editor A shows "H1: foox|"', + 'And Editor A has emitted 2 changes', + "And Editor A's change 2 carries no patches", + ]) + expect(world.snapshot().editors?.['Editor A'].screen).toEqual('H1: foox|') + }) + + test('a synchronous step runs and checks without a promise, and throws where it fails', () => { + const {scenarios} = compileScenarios( + [ + 'Feature: Free play', + ' Scenario: foo', + ' Given the document is "B: foo|"', + ' When "x" is typed', + ' Then Editor A shows "B: foo|"', + ].join('\n'), + ) + const world = createWorld() + const outcomes: Array = [] + + for (const step of scenarios[0].steps) { + try { + const result = step.run(world) + outcomes.push(result instanceof Promise ? 'a promise' : 'passed') + } catch (error) { + outcomes.push(error instanceof Error ? error.message : String(error)) + } + } + + expect(outcomes).toEqual([ + 'passed', + 'passed', + 'What Editor A shows: expected "B: foo|", got "B: foox|"', + ]) + }) + + test('an empty list on the server is checked apart from no field', async () => { + const {scenarios} = compileScenarios( + [ + 'Feature: Free play', + ' Scenario: foo', + ' Given the server has an empty list', + ' And the editors are in their first commit', + ' When Editor A is loaded', + " And Editor A's first commit ends", + ' Then the server has an empty list', + ' And the server has no field', + ].join('\n'), + ) + const world = createWorld() + const outcomes: Array = [] + + for (const step of scenarios[0].steps) { + try { + await step.run(world) + outcomes.push('passed') + } catch (error) { + outcomes.push(error instanceof Error ? error.message : String(error)) + } + } + + expect(outcomes).toEqual([ + 'passed', + 'passed', + 'passed', + 'passed', + 'passed', + `The server's field: expected "no field", got "an empty list"`, + ]) + }) + + test('a wrong expectation fails at its own step', async () => { + const [scenario] = compileScenarios( + listenersFeature.replace( + 'Then Editor A shows "H1: foox|"', + 'Then Editor A shows "H1: foo|"', + ), + ).scenarios + const world = createWorld() + const outcomes: Array = [] + + for (const step of scenario.steps) { + try { + await step.run(world) + outcomes.push('passed') + } catch (error) { + outcomes.push(error instanceof Error ? error.message : String(error)) + break + } + } + + expect(outcomes).toEqual([ + ...Array.from({length: 13}, () => 'passed'), + 'What Editor A shows: expected "H1: foo|", got "H1: foox|"', + ]) + }) +}) diff --git a/packages/io/src/scenario/compile.ts b/packages/io/src/scenario/compile.ts new file mode 100644 index 0000000000..2782f1b3d3 --- /dev/null +++ b/packages/io/src/scenario/compile.ts @@ -0,0 +1,139 @@ +import * as Gherkin from '@cucumber/gherkin' +import * as Messages from '@cucumber/messages' +import {compileFeature} from 'racejar' +import {parameterTypes} from './parameter-types' +import {stepDefinitions, type Context} from './steps' +import type {World} from './world' + +export type CompiledStep = { + /** The step's keyword as written: `Given`, `When`, `Then`, `And`, `But`. */ + keyword: string + text: string + run: (world: World) => void | Promise +} + +export type CompiledScenario = { + name: string + /** Whether the scenario or its feature is tagged `@skip`. */ + skipped: boolean + /** + * What the model lacks, from a `# known red:` comment on the line right + * above the scenario and its tags. + */ + knownRed: string | undefined + steps: Array +} + +export type CompiledScenarios = { + feature: string + scenarios: Array +} + +/** + * Compiles a feature file against the package's step definitions, one entry + * per scenario and per row of a scenario outline. Each step runs on its own + * against the world it is given, so a caller can run a scenario one step at + * a time and catch the step that fails. + */ +export function compileScenarios(featureText: string): CompiledScenarios { + const compiled = compileFeature({ + featureText, + stepDefinitions, + parameterTypes, + }) + const pickles = parsePickles(featureText) + + if (pickles.scenarios.length !== compiled.scenarios.length) { + throw new Error( + `Expected ${compiled.scenarios.length} scenarios in "${compiled.name}", parsed ${pickles.scenarios.length}`, + ) + } + + return { + feature: compiled.name, + scenarios: compiled.scenarios.map((scenario, scenarioIndex) => { + const parsedScenario = pickles.scenarios[scenarioIndex] + const parsedSteps = parsedScenario.steps + + return { + name: scenario.name, + skipped: scenario.tag === 'skip', + knownRed: parsedScenario.knownRed, + steps: scenario.steps.map((runStep, stepIndex) => ({ + keyword: parsedSteps[stepIndex].keyword, + text: parsedSteps[stepIndex].text, + run: (world) => runStep({world}), + })), + } + }), + } +} + +function parsePickles(featureText: string): { + scenarios: Array<{ + knownRed: string | undefined + steps: Array<{keyword: string; text: string}> + }> +} { + const newId = Messages.IdGenerator.incrementing() + const parser = new Gherkin.Parser( + new Gherkin.AstBuilder(newId), + new Gherkin.GherkinClassicTokenMatcher(), + ) + const gherkinDocument = parser.parse(featureText) + const keywords = new Map() + + for (const step of astSteps(gherkinDocument.feature?.children ?? [])) { + keywords.set(step.id, step.keyword.trim()) + } + + const knownRedSentences = new Map() + + for (const scenario of astScenarios( + gherkinDocument.feature?.children ?? [], + )) { + const firstLine = Math.min( + scenario.location.line, + ...scenario.tags.map((tag) => tag.location.line), + ) + const comment = gherkinDocument.comments.find( + (candidate) => candidate.location.line === firstLine - 1, + ) + const knownRed = comment?.text.match(/^\s*#\s*known red:\s*(.+?)\s*$/) + + if (knownRed) { + knownRedSentences.set(scenario.id, knownRed[1]) + } + } + + const pickles = Gherkin.compile(gherkinDocument, '', newId) + + return { + scenarios: pickles.map((pickle) => ({ + knownRed: knownRedSentences.get(pickle.astNodeIds[0] ?? ''), + steps: pickle.steps.map((step) => ({ + keyword: keywords.get(step.astNodeIds[0] ?? '') ?? '', + text: step.text, + })), + })), + } +} + +function astScenarios( + children: ReadonlyArray, +): Array { + return children.flatMap((child) => [ + ...(child.scenario ? [child.scenario] : []), + ...('rule' in child && child.rule ? astScenarios(child.rule.children) : []), + ]) +} + +function astSteps( + children: ReadonlyArray, +): Array { + return children.flatMap((child) => [ + ...(child.background?.steps ?? []), + ...(child.scenario?.steps ?? []), + ...('rule' in child && child.rule ? astSteps(child.rule.children) : []), + ]) +} diff --git a/packages/io/src/scenario/model-undo.test.ts b/packages/io/src/scenario/model-undo.test.ts new file mode 100644 index 0000000000..5a47111cb8 --- /dev/null +++ b/packages/io/src/scenario/model-undo.test.ts @@ -0,0 +1,827 @@ +import { + diffMatchPatch, + insert, + set, + setIfMissing, + unset, +} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from '../fakes/document' +import {createFakeNetwork} from '../fakes/network' +import {withModelUndo} from './model-undo' +import {createEditorWithIo} from './world' + +describe(withModelUndo.name, () => { + test('undo puts back the style another writer set underneath', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + document.setStyle('h2') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', path)], + }) + + expect(document.toTextspec()).toEqual('H2: foo|') + + editor.undo() + + expect(document.toTextspec()).toEqual('H1: foo|') + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [set('h2', path)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [set('h1', path)], + }, + ]) + }) + + test("undo isn't rebased over the editor's own echo", () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + document.setStyle('h2') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [set('h2', path)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [set('normal', path)], + }, + ]) + }) + + test('undo after the echo puts back the style another writer saved just before it', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + document.setStyle('h2') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', path)], + }) + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(document.toTextspec()).toEqual('H1: foo|') + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [set('h2', path)]}, + {id: 'A-2', transactionId: 'A-tk1', patches: [set('h1', path)]}, + ]) + }) + + test('undo leaves a style another writer set after the editor in the same transaction', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + document.setStyle('h2') + editor.send({type: 'mutation sent', id: 'A-1', transactionId: 'A-1+B-1'}) + editor.send({ + type: 'transaction', + transactionId: 'A-1+B-1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h2', path), set('h1', path)], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('H1: foo|') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [set('h2', path)], + }, + ]) + }) + + test('undoing two confirmed style changes puts back each style in turn while the first undo is in flight', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.setStyle('h2') + document.setStyle('h1') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 'A-tk1', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[1].patches, + }) + editor.undo() + + expect(document.toTextspec()).toEqual('H2: foo|') + + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|') + }) + + test('undoing a style set on the placeholder keeps the block and puts back the normal style', () => { + const {editor, document, heard} = createLoadedEditor(undefined) + const path = [{_key: 'a-k0'}, 'style'] + + document.setStyle('h1') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(document.toTextspec({keys: true})).toEqual('B _key="a-k0": |') + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + set('h1', path), + ], + }, + {id: 'A-2', transactionId: 'A-tk1', patches: [set('normal', path)]}, + ]) + }) + + test('undoing a style set on the placeholder before the block is sent puts back the normal style in the same mutation', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const placeholder = { + _type: 'block', + _key: 'a-k2', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k3', text: '', marks: []}], + } + const path = [{_key: 'a-k2'}, 'style'] + + document.deleteBlock('foo') + document.setStyle('h1') + editor.undo() + + expect(document.toTextspec({keys: true})).toEqual('B _key="a-k2": |') + expect(document.getPlaceholderKey()).toEqual(undefined) + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [unset([{_key: 'd-k0'}]), unset([])], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [ + setIfMissing([], []), + insert([placeholder], 'before', [0]), + set('h1', path), + set('normal', path), + ], + }, + ]) + }) + + test('undoing typing deletes the typed text where another writer moved it', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [diffMatchPatch('foox', 'yfoox', textPath)], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: yfoo|') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('yfoox', 'yfoo', textPath)], + }, + ]) + }) + + test('undoing typing deletes the repeated word where it was typed, not the one the patch names', () => { + const {editor, document, heard} = createLoadedEditor('B: |foofoo') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('foo') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [diffMatchPatch('foofoofoo', 'foobarfoofoo', textPath)], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: bar|foofoo') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foofoo', 'foofoofoo', textPath)], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [ + { + type: 'diffMatchPatch', + path: textPath, + value: '@@ -1,11 +1,8 @@\n-foo\n barfoofo\n', + }, + ], + }, + ]) + }) + + test('undoing typing deletes the typed word where a later local deletion moved it', () => { + const {editor, document, heard} = createLoadedEditor('B: xyz|foofoo') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + document.type('foo') + document.putCaretAfter('xyz') + document.deleteBeforeCaret('xyz') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 'A-tk1', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[1].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't3', + previousRev: 'r3', + resultRev: 'r4', + patches: [diffMatchPatch('foofoofoo', 'foobarfoofoo', textPath)], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: |barfoofoo') + }) + + test("undoing the first keystroke into an empty field deletes the text and keeps another writer's content", () => { + const {editor, document, heard} = createLoadedEditor(undefined) + const textPath = [{_key: 'a-k0'}, 'children', {_key: 'a-k1'}, 'text'] + const barBlock = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B: bar', + ).value[0] + + document.type('x') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [insert([barBlock], 'after', [{_key: 'a-k0'}])], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: |;;B: bar') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + diffMatchPatch('', 'x', textPath), + ], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [diffMatchPatch('x', '', textPath)], + }, + ]) + }) + + test('undoing a delete puts the block back after its previous sibling', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|;;B: bar') + const [, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + + document.deleteBlock('bar') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|;;B: bar') + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [unset([{_key: 'd-k2'}])]}, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + }, + ]) + }) + + test('undoing a delete puts the block back as another writer changed it while the delete was in flight', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|;;B: bar') + const [, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;H1: bar', + ).value + + document.deleteBlock('bar') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k2'}, 'style'])], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|;;H1: bar') + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [unset([{_key: 'd-k2'}])]}, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + }, + ]) + }) + + test('undoing a confirmed delete puts the block back as the base held it just before the delete', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|;;B: bar') + const [, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + + document.deleteBlock('bar') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h1', [{_key: 'd-k2'}, 'style'])], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|;;B: bar') + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [unset([{_key: 'd-k2'}])]}, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + }, + ]) + }) + + test('undoing a delete does nothing once the block is back', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|;;B: bar') + const [, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + + document.deleteBlock('bar') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|;;B: bar') + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [unset([{_key: 'd-k2'}])]}, + ]) + expect(heard.errors).toEqual([]) + }) + + test('undoing a delete puts the block back before its next sibling once another writer removed the previous one', () => { + const {editor, document, heard} = createLoadedEditor( + 'B: qux;;B: foo;;B: bar|;;B: baz', + ) + const [, , barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: qux;;B: foo;;B: bar;;B: baz', + ).value + + document.deleteBlock('bar') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [unset([{_key: 'd-k2'}])], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: qux|;;B: bar;;B: baz') + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [unset([{_key: 'd-k4'}])]}, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [insert([barBlock], 'before', [{_key: 'd-k6'}])], + }, + ]) + }) + + test('undoing a delete puts the block back first once another writer removed both its siblings', () => { + const {editor, document, heard} = createLoadedEditor( + 'B: foo;;B: bar|;;B: baz', + ) + const [, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar;;B: baz', + ).value + const [quxBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B: qux', + ).value + + document.deleteBlock('bar') + editor.send({ + type: 'transaction', + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [ + unset([{_key: 'd-k0'}]), + unset([{_key: 'd-k4'}]), + insert([quxBlock], 'after', [{_key: 'd-k2'}]), + ], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: bar;;B: qux|') + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + {id: 'A-1', transactionId: 'A-tk0', patches: [unset([{_key: 'd-k2'}])]}, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [insert([barBlock], 'before', [{_key: 'b-k0'}])], + }, + ]) + }) + + test('undoing the delete of the last block puts it back as the content of the empty field', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const [fooBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo', + ).value + + document.deleteBlock('foo') + editor.undo() + + expect(document.toTextspec({keys: true})).toEqual('B _key="d-k0": |foo') + expect(document.getPlaceholderKey()).toEqual(undefined) + + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [unset([{_key: 'd-k0'}]), unset([])], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [setIfMissing([], []), insert([fooBlock], 'before', [0])], + }, + ]) + }) + + test('a read-only editor refuses an undo, and the step stays for later', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const typed = diffMatchPatch('foo', 'foox', textPath) + const reverted = diffMatchPatch('foox', 'foo', textPath) + + document.type('x') + document.updateReadOnly(true) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foox|') + expect(editor.getUndoDepth()).toEqual(1) + + document.updateReadOnly(false) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|') + expect(editor.getUndoDepth()).toEqual(0) + expect(heard.changes).toEqual([ + {origin: 'local', operations: [typed], patches: [typed]}, + {origin: 'local', operations: [reverted], patches: [reverted]}, + ]) + }) + + test('undoing an insert deletes the block while it exists, and leaves the block made from the placeholder', () => { + const {editor, document, heard} = createLoadedEditor(undefined) + + document.insertBlock('B: bar') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(document.toTextspec({keys: true})).toEqual('B _key="a-k0": |') + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + insert( + [ + { + _type: 'block', + _key: 'a-k2', + children: [ + {_type: 'span', _key: 'a-k3', text: 'bar', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'a-k0'}], + ), + ], + }, + { + id: 'A-2', + transactionId: 'A-tk1', + patches: [unset([{_key: 'a-k2'}])], + }, + ]) + }) + + test('undoing an insert does nothing once another writer deleted the block', () => { + const {editor, document, heard} = createLoadedEditor('B: foo|') + + document.insertBlock('B: bar') + editor.send({ + type: 'transaction', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.send({ + type: 'transaction', + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [unset([{_key: 'a-k2'}])], + }) + editor.undo() + + expect(document.toTextspec()).toEqual('B: foo|') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + transactionId: 'A-tk0', + patches: [ + insert( + [ + { + _type: 'block', + _key: 'a-k2', + children: [ + {_type: 'span', _key: 'a-k3', text: 'bar', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k0'}], + ), + ], + }, + ]) + }) +}) + +function createLoadedEditor(textspec: string | undefined) { + const {clock} = createFakeNetwork() + const { + document, + io: editor, + heard, + received, + treeMismatches, + } = createEditorWithIo({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + }) + const {value, caret} = + textspec === undefined + ? {value: undefined, caret: undefined} + : parseTextspec({keyGenerator: createTestKeyGenerator('d-')}, textspec) + + editor.send({type: 'load', value, rev: 'r1'}) + document.mount() + + if (caret) { + document.setCaret(caret) + } + + return {editor, document, clock, heard, received, treeMismatches} +} diff --git a/packages/io/src/scenario/model-undo.ts b/packages/io/src/scenario/model-undo.ts new file mode 100644 index 0000000000..a8aafa37e7 --- /dev/null +++ b/packages/io/src/scenario/model-undo.ts @@ -0,0 +1,530 @@ +import { + insert, + set, + setIfMissing, + unset, + type Patch, +} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import { + applyWithContentLakeSemantics, + resolvePath, +} from '../protocol/content-lake' +import {extendIo, getIoInternals, type Io} from '../protocol/io' +import {childrenOf, findBlock, isEqual, itemKey, keyOf} from '../protocol/nodes' +import { + mapOffsetThrough, + textEditPatch, + textEditsOf, +} from '../protocol/text-edits' + +/** + * io with the model's undo, a stand-in for the editor's history. `undo` + * reverts the last of the editor's own changes that undo can still revert, + * and `getUndoDepth` says how many that is. + */ +export type ModelUndoIo = Io & { + undo: () => void + getUndoDepth: () => number +} + +/** + * What a local change did, in terms undo can check against the working copy + * at undo time: text typed into a span at an offset, a block's style set, a + * block inserted, or a block deleted next to its siblings. Creating the + * block from the placeholder isn't part of the step. + */ +type UndoStep = + | { + type: 'typed' + blockKey: string + spanKey: string + offset: number + text: string + } + | {type: 'styled'; blockKey: string; style: string} + | {type: 'inserted'; blockKey: string} + | { + type: 'deleted' + block: PortableTextBlock + previousKey: string | undefined + nextKey: string | undefined + } + +/** A block's style, or `undefined` when there is no such block. */ +type BlockStyle = {style: string | undefined} | undefined + +const placeholderStyle: BlockStyle = {style: 'normal'} + +type HistoryEntry = { + step: UndoStep + mutationId: string | undefined + /** How many patches of the entry's mutation come before the action's own. */ + patchOffset: number + /** + * The base's version of the step's block just before the change took + * effect there, captured when the change's transaction applied. `undefined` + * while the change is unconfirmed. + */ + confirmed: {blockBefore: PortableTextBlock | undefined} | undefined +} + +/** + * Wraps io with an undo ledger, driven by the editor's local changes and the + * transactions io applies. io reads nothing from it. Each undo step is read + * from the local change and the working copy, and its revert is computed + * against the working copy at undo time. `applyLocalEdit` applies the + * revert to the editor as the user's own edit, which the editor reports as + * a local `change`, or refuses while read-only. + */ +export function withModelUndo( + io: Io, + applyLocalEdit: (patches: Array) => void, +): ModelUndoIo { + const internals = getIoInternals(io) + let history: Array = [] + let reverting: {applied: boolean} | undefined + + internals.tap({ + localChange: ({operations, workingCopyBefore, patchOffset}) => { + mapStepsThrough(operations, workingCopyBefore, []) + + if (reverting) { + reverting.applied = true + return + } + + const step = undoStepOf(operations, workingCopyBefore) + + if (step) { + history = [ + ...history, + {step, mutationId: undefined, patchOffset, confirmed: undefined}, + ] + } + }, + mutation: (id) => { + history = history.map((entry) => + entry.mutationId === undefined ? {...entry, mutationId: id} : entry, + ) + }, + transaction: ({confirmedMutationIds, patches, valueBefore, ownPatches}) => { + captureBlocksBefore(confirmedMutationIds) + mapStepsThrough(patches, valueBefore, ownPatches) + }, + resync: () => { + history = [] + }, + }) + + /** + * Moves each typing step's offset past the text that `patches` inserted or + * deleted before it in its span, applying them to `valueBefore` in turn. + * `ownPatches` are the editor's own and move no step: its own work was in + * the working copy already. + */ + function mapStepsThrough( + patches: Array, + valueBefore: Array | undefined, + ownPatches: Array, + ) { + const unmatchedOwnPatches = [...ownPatches] + let value = valueBefore + + for (const patch of patches) { + const ownIndex = unmatchedOwnPatches.findIndex((ownPatch) => + isEqual(ownPatch, patch), + ) + const text = resolvePath(value, patch.path) + + if (ownIndex !== -1) { + unmatchedOwnPatches.splice(ownIndex, 1) + } else if (patch.type === 'diffMatchPatch' && typeof text === 'string') { + const edits = textEditsOf(patch.value, text) + + history = history.map((entry) => + entry.step.type === 'typed' && + isEqual(patch.path, typedPath(entry.step)) + ? { + ...entry, + step: { + ...entry.step, + offset: mapOffsetThrough(entry.step.offset, edits), + }, + } + : entry, + ) + } + + try { + value = applyWithContentLakeSemantics(value, [patch]) + } catch { + return + } + } + } + + /** Runs before the base takes the transaction that confirms the mutations. */ + function captureBlocksBefore(confirmedMutationIds: Set) { + history = history.map((entry) => + entry.mutationId !== undefined && + confirmedMutationIds.has(entry.mutationId) + ? {...entry, confirmed: {blockBefore: blockUnder(entry)}} + : entry, + ) + } + + /** + * The step's block in the base with the editor's own unconfirmed changes + * from before the action applied: what the action changed, as far as the + * base goes. + */ + function blockUnder(entry: HistoryEntry): PortableTextBlock | undefined { + const {base, unconfirmed, pending} = internals.getLayers() + const mutations = [...unconfirmed, {id: undefined, patches: pending}] + const mutationIndex = mutations.findIndex( + (mutation) => mutation.id === entry.mutationId, + ) + + if (mutationIndex === -1) { + return undefined + } + + const patchesBefore = [ + ...mutations + .slice(0, mutationIndex) + .flatMap((mutation) => mutation.patches), + ...mutations[mutationIndex].patches.slice(0, entry.patchOffset), + ] + + return findBlock( + applyWithContentLakeSemantics(base, patchesBefore), + stepBlockKey(entry.step), + ) + } + + /** + * The editor applies the revert as a local change, which books it. A + * read-only editor refuses it, and the step stays. + */ + function undo() { + const status = internals.getStatus() + + if (status === 'unmounted') { + internals.warn('Ignored an undo after the editor unmounted') + return + } + + if (status !== 'ready') { + throw new Error(`The editor is ${status}`) + } + + const entry = history.at(-1) + + if (!entry) { + return + } + + history = history.slice(0, -1) + + const patches = revert(entry) + + if (patches.length === 0) { + return + } + + const attempt = {applied: false} + reverting = attempt + + try { + applyLocalEdit(patches) + } finally { + reverting = undefined + } + + if (!attempt.applied) { + history = [...history, entry] + } + } + + function revert(entry: HistoryEntry): Array { + const {step} = entry + const screen = internals.getWorkingCopy() ?? [] + + switch (step.type) { + case 'typed': + return deleteTyped(screen, step) + case 'styled': { + const block = findBlock(screen, step.blockKey) + + if (block === undefined) { + return [] + } + + const restored = styleToRestore(entry, step, block) + const path = [{_key: step.blockKey}, 'style'] + + if (restored === undefined || block.style === restored.style) { + return [] + } + + return [ + restored.style === undefined + ? unset(path) + : set(restored.style, path), + ] + } + case 'inserted': + return deleteBlockByKey(screen, step.blockKey) + case 'deleted': { + const block = entry.confirmed + ? entry.confirmed.blockBefore + : blockUnder(entry) + + return block === undefined ? [] : restoreBlock(screen, {...step, block}) + } + } + } + + /** + * Once the change is confirmed, a style in the working copy other than the + * one it set means another writer changed the style after it, and undo + * leaves it. The working copy, not the base alone, since the editor's own + * later changes are undone first and may still be unconfirmed. A block the + * base didn't have before the change came from the placeholder in the same + * action, so its style before the change is the placeholder's. + */ + function styleToRestore( + entry: HistoryEntry, + step: Extract, + screenBlock: PortableTextBlock, + ): BlockStyle { + if (!entry.confirmed) { + return styleOf(blockUnder(entry)) ?? placeholderStyle + } + + return styleOf(screenBlock)?.style === step.style + ? (styleOf(entry.confirmed.blockBefore) ?? placeholderStyle) + : undefined + } + + return extendIo(io, {undo, getUndoDepth: () => history.length}) +} + +/** + * What undo needs to revert a local change, read from its operations and the + * working copy before it. Typing, a style, an insert and a delete are steps, + * and deleting text isn't. A text operation that inserts one run of text is + * typing, at the offset the operation names. + */ +function undoStepOf( + patches: Array, + screenBefore: Array | undefined, +): UndoStep | undefined { + const fromPlaceholder = createsBlock(patches) + const value = applyWithContentLakeSemantics( + screenBefore, + fromPlaceholder ? patches.slice(0, 2) : [], + ) + const [patch] = fromPlaceholder ? patches.slice(2) : patches + const [head, field, spanSegment] = patch?.path ?? [] + const blockKey = keyOf(head) + + if (!patch || blockKey === undefined) { + return undefined + } + + if ( + patch.type === 'set' && + patch.path.length === 2 && + field === 'style' && + typeof patch.value === 'string' + ) { + return {type: 'styled', blockKey, style: patch.value} + } + + if (patch.type === 'insert' && patch.path.length === 1) { + const [insertedKey] = patch.items.flatMap((item) => itemKey(item)) + + return insertedKey === undefined + ? undefined + : {type: 'inserted', blockKey: insertedKey} + } + + if (patch.type === 'unset' && patch.path.length === 1) { + const blocks = value ?? [] + const index = blocks.findIndex((block) => block._key === blockKey) + + return index === -1 + ? undefined + : { + type: 'deleted', + block: blocks[index], + previousKey: blocks[index - 1]?._key, + nextKey: blocks[index + 1]?._key, + } + } + + const spanKey = keyOf(spanSegment) + const text = resolvePath(value, patch.path) + + if ( + patch.type !== 'diffMatchPatch' || + patch.path.length !== 4 || + spanKey === undefined || + typeof text !== 'string' + ) { + return undefined + } + + const edits = textEditsOf(patch.value, text) + const [edit] = edits + + return edits.length === 1 && edit.type === 'insert' + ? {type: 'typed', blockKey, spanKey, offset: edit.offset, text: edit.text} + : undefined +} + +/** + * Whether a change turns the placeholder into content: it starts with a + * whole-field `setIfMissing` followed by an `insert`. + */ +function createsBlock(patches: Array): boolean { + const [first, second] = patches + + return ( + first?.type === 'setIfMissing' && + first.path.length === 0 && + second?.type === 'insert' + ) +} + +/** + * Removes typed text from its span, at the occurrence nearest its offset. + * Returns no patches when the span no longer holds the text. + */ +function deleteTyped( + value: Array, + typed: Extract, +): Array { + const block = findBlock(value, typed.blockKey) + const span = block + ? childrenOf(block).find((child) => itemKey(child)[0] === typed.spanKey) + : undefined + const text: unknown = + typeof span === 'object' && span !== null + ? Reflect.get(span, 'text') + : undefined + + if (typeof text !== 'string') { + return [] + } + + const typedOffset = findNearest(text, typed.text, typed.offset) + + if (typedOffset === undefined) { + return [] + } + + return [ + textEditPatch( + text, + {offset: typedOffset, deleteLength: typed.text.length}, + typedPath(typed), + ), + ] +} + +function typedPath(typed: Extract): Patch['path'] { + return [{_key: typed.blockKey}, 'children', {_key: typed.spanKey}, 'text'] +} + +/** + * Removes a block, and the field with it when it was the last. Returns no + * patches when the block is gone. + */ +function deleteBlockByKey( + value: Array, + blockKey: string, +): Array { + if (findBlock(value, blockKey) === undefined) { + return [] + } + + return value.length === 1 + ? [unset([{_key: blockKey}]), unset([])] + : [unset([{_key: blockKey}])] +} + +/** + * Puts a deleted block back after its previous sibling, or else before its + * next one, or else first. An empty field gets the block as its content. + * Returns no patches when the block's key is in the working copy. + */ +function restoreBlock( + value: Array, + deleted: Extract, +): Array { + const {block} = deleted + + if (findBlock(value, block._key) !== undefined) { + return [] + } + + if (value.length === 0) { + return [setIfMissing([], []), insert([block], 'before', [0])] + } + + if ( + deleted.previousKey !== undefined && + findBlock(value, deleted.previousKey) !== undefined + ) { + return [insert([block], 'after', [{_key: deleted.previousKey}])] + } + + const reference = + deleted.nextKey !== undefined && + findBlock(value, deleted.nextKey) !== undefined + ? deleted.nextKey + : value[0]._key + + return [insert([block], 'before', [{_key: reference}])] +} + +/** + * The start of the occurrence of `search` in `text` nearest `offset`, looking + * after the offset first at each distance. + */ +function findNearest( + text: string, + search: string, + offset: number, +): number | undefined { + for ( + let distance = 0; + distance <= Math.max(offset, text.length); + distance++ + ) { + for (const candidate of [offset + distance, offset - distance]) { + if (candidate >= 0 && text.startsWith(search, candidate)) { + return candidate + } + } + } + + return undefined +} + +function stepBlockKey(step: UndoStep): string { + return step.type === 'deleted' ? step.block._key : step.blockKey +} + +function styleOf(block: PortableTextBlock | undefined): BlockStyle { + if (!block) { + return undefined + } + + const style: unknown = block.style + + return {style: typeof style === 'string' ? style : undefined} +} diff --git a/packages/io/src/scenario/parameter-types.ts b/packages/io/src/scenario/parameter-types.ts new file mode 100644 index 0000000000..5815dadae6 --- /dev/null +++ b/packages/io/src/scenario/parameter-types.ts @@ -0,0 +1,108 @@ +import {createParameterType} from 'racejar' +import type {FakeDocumentStatus} from '../fakes/document' +import type {RequestFailure} from '../protocol/host' +import type {IoSync} from '../protocol/io' +import type {ErrorEvent, WorkDropped} from '../protocol/types' +import type {Corruption, EditorName, ServerCopyName} from './world' + +export type MutationReference = {name: EditorName; mutationNumber: number} + +/** + * A sync state a scenario checks for. The editor has no `'stalled'` state, so + * a scenario that expects it is known red. + */ +export type ExpectedSync = IoSync | 'stalled' + +const requestFailures = new Map([ + ['400', 400], + ['403', 403], + ['404', 404], + ['500', 500], + ['503', 503], + ['a network error', 'network error'], +]) + +export const parameterTypes = [ + createParameterType({ + name: 'editor', + matcher: /Editor A|Editor B/, + }), + createParameterType({ + name: 'mutation', + matcher: /(Editor A|Editor B)'s mutation (\d+)/, + transform: (name, mutationNumber) => ({ + name: name === 'Editor A' ? 'Editor A' : 'Editor B', + mutationNumber: Number.parseInt(mutationNumber, 10), + }), + }), + createParameterType({ + name: 'textspec', + matcher: /"(.*)"/, + }), + createParameterType({ + name: 'style', + matcher: /"(normal|h1|h2|h3)"/, + }), + createParameterType({ + name: 'key', + matcher: /"([^"]+)"/, + }), + createParameterType({ + name: 'copy', + matcher: /no document|no field|an empty list/, + }), + createParameterType>({ + name: 'status', + matcher: /"(loading|ready)"/, + }), + createParameterType({ + name: 'sync', + matcher: /"(synced|saving|blocked|out of step|stalled)"/, + }), + createParameterType({ + name: 'failure', + matcher: /(400|403|404|500|503|a network error)/, + transform: (failure) => { + const status = requestFailures.get(failure) + + if (status === undefined) { + throw new Error(`Unknown request failure "${failure}"`) + } + + return status + }, + }), + createParameterType({ + name: 'errorReason', + matcher: + /"(out of order|duplicate key|patch failed|echo mismatch|invalid content)"/, + }), + createParameterType({ + name: 'corruption', + matcher: + /(has no key)|(has no type)|has children "([^"]*)"|has a span whose text is (\d+)|is the string "([^"]*)"/, + transform: (noKey, noType, children, text, string) => { + if (noKey !== undefined) { + return {type: 'no key'} + } + + if (noType !== undefined) { + return {type: 'no type'} + } + + if (children !== undefined) { + return {type: 'children', children} + } + + if (text !== undefined) { + return {type: 'span text', text: Number.parseInt(text, 10)} + } + + return {type: 'string', value: string} + }, + }), + createParameterType({ + name: 'dropReason', + matcher: /"(no target|closed while blocked|closed out of step|rejected)"/, + }), +] diff --git a/packages/io/src/scenario/steps.ts b/packages/io/src/scenario/steps.ts new file mode 100644 index 0000000000..1b68425cc0 --- /dev/null +++ b/packages/io/src/scenario/steps.ts @@ -0,0 +1,893 @@ +import {set} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {Given, Then, When, type StepDefinition} from 'racejar' +import { + comparableTextspec, + createsBlock, + emptiesField, + formatTextspec, +} from '../fakes/document' +import type {FakeDocumentStatus} from '../fakes/document' +import type {RequestFailure} from '../protocol/host' +import type { + ChangeEvent, + EditorMessageForIo, + ErrorEvent, + WorkDropped, +} from '../protocol/types' +import {checkEmpty, checkEqual, checkGreaterThan, checkNotEqual} from './check' +import type {MutationReference, ExpectedSync} from './parameter-types' +import { + heldTransactionTimeout, + type Corruption, + type EditorName, + type ServerCopyName, + type World, +} from './world' + +export type Context = {world: World} + +/** + * Every step ends by checking that each editor's tree equals io's working + * copy, both at the end of the step and right after every message and local + * change during it. + */ +export const stepDefinitions = [ + Given('the document is {textspec}', (context: Context, textspec: string) => { + context.world.documentIs(textspec) + }), + Given('the server has {textspec}', (context: Context, textspec: string) => { + context.world.serverHas(textspec) + }), + Given('the server has {copy}', (context: Context, copy: ServerCopyName) => { + context.world.serverHasCopy(copy) + }), + Given( + 'the server has one empty block {key}', + (context: Context, key: string) => { + context.world.serverHasEmptyBlock(key) + }, + ), + Given( + "the server's block {key} {corruption}", + (context: Context, key: string, corruption: Corruption) => { + context.world.corruptServerBlock(key, corruption) + }, + ), + Given('the editors are in their first commit', (context: Context) => { + context.world.startEditors() + }), + Given( + 'hosts that fold mutations into shared requests', + (context: Context) => { + context.world.setHostShape('folding') + }, + ), + Given('hosts that confirm each mutation themselves', (context: Context) => { + context.world.setHostShape('self-confirming') + }), + Given("transactions that carry the server's copy", (context: Context) => { + context.world.carryServerCopyOnTransactions() + }), + + ...userSteps(), + + When( + "the server receives {editor}'s mutation {int}", + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.receive(name, mutationNumber) + }, + ), + When( + 'the server receives {mutation} and {mutation} as one transaction', + (context: Context, first: MutationReference, second: MutationReference) => { + context.world.receiveAsOne(first, second) + }, + ), + When( + "the server receives {editor}'s final mutation", + (context: Context, name: EditorName) => { + context.world.receiveFinal(name) + }, + ), + When( + "the server's next request fails with {failure}", + (context: Context, failure: RequestFailure) => { + context.world.failNextRequest(failure) + }, + ), + When( + "the host rewrites {editor}'s mutation {int} as a whole-field unset", + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.rewriteAsWholeFieldUnset(name, mutationNumber) + }, + ), + When( + "the save reply for {editor}'s mutation {int} is lost", + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.loseReply(name, mutationNumber) + }, + ), + When( + "{editor}'s mutation {int} is retried", + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.retry(name, mutationNumber) + }, + ), + When("{editor}'s feed is lost", (context: Context, name: EditorName) => { + context.world.feedLost(name) + }), + When( + 'another field of the document is changed on the server', + (context: Context) => { + context.world.changeOtherField() + }, + ), + When( + "{editor} receives {editor}'s mutation {int}", + ( + context: Context, + receiverName: EditorName, + senderName: EditorName, + mutationNumber: number, + ) => { + context.world.deliverMutation(receiverName, senderName, mutationNumber) + }, + ), + When( + "{editor} receives the other field's change", + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, 'the other field') + }, + ), + When( + 'a script sets the field to {textspec}', + (context: Context, textspec: string) => { + context.world.setFieldByScript(textspec) + }, + ), + When( + "{editor} receives the script's change", + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, "the script's change") + }, + ), + When( + "a script changes the server's block {key} so it {corruption}", + (context: Context, key: string, corruption: Corruption) => { + context.world.corruptByScript(key, corruption) + }, + ), + When( + "the server's copy changes without a transaction so its block {key} {corruption}", + (context: Context, key: string, corruption: Corruption) => { + context.world.alterServerCopy(key, corruption) + }, + ), + When( + "{editor} receives the script's corruption", + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, "the script's corruption") + }, + ), + When( + "{editor}'s mutation {int} comes back", + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.deliverMutation(name, name, mutationNumber) + }, + ), + When('the document is deleted', (context: Context) => { + context.world.deleteDocument() + }), + When( + 'the document is recreated as {textspec}', + (context: Context, textspec: string) => { + context.world.recreateDocument(textspec) + }, + ), + When( + '{editor} receives the deletion', + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, 'the deletion') + }, + ), + When( + '{editor} receives the recreation', + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, 'the recreation') + }, + ), + When('the wait for the missing transaction runs out', (context: Context) => { + context.world.advanceClock(heldTransactionTimeout) + }), + When('{int} seconds pass', (context: Context, seconds: number) => { + context.world.advanceClock(seconds * 1000) + }), + + When( + "the save reply for {editor}'s mutation {int} arrives", + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.deliverReply(name, mutationNumber) + }, + ), + When('{editor} is resynced', (context: Context, name: EditorName) => { + context.world.resync(name, {discardUnsent: false}) + }), + When( + '{editor} is resynced with the outcome of mutation {int}', + (context: Context, name: EditorName, mutationNumber: number) => { + context.world.resync(name, { + discardUnsent: false, + outcomeOf: mutationNumber, + }) + }, + ), + When( + '{editor} is resynced, discarding unsent changes', + (context: Context, name: EditorName) => { + context.world.resync(name, {discardUnsent: true}) + }, + ), + When('{editor} is loaded', (context: Context, name: EditorName) => { + context.world.load(name) + }), + When('{editor} is loaded again', (context: Context, name: EditorName) => { + context.world.loadAgain(name) + }), + When("{editor}'s first commit ends", (context: Context, name: EditorName) => { + context.world.endFirstCommit(name) + }), + When('{editor} becomes read-only', (context: Context, name: EditorName) => { + context.world.becomeReadOnly(name) + }), + When('{editor} is closed', (context: Context, name: EditorName) => { + context.world.close(name) + }), + + Then( + '{editor} shows {textspec}', + (context: Context, name: EditorName, textspec: string) => { + const {document} = context.world.getEditor(name) + const {actual, expected} = comparableTextspec( + {value: document.getValue(), selection: document.getSelection()}, + textspec, + ) + + checkEqual(`What ${name} shows`, actual, expected) + }, + ), + Then('the server has {textspec}', (context: Context, textspec: string) => { + const {actual, expected} = comparableTextspec( + { + value: (context.world.getServer().copy().value ?? []).filter(isObject), + selection: null, + }, + textspec, + ) + + checkEqual('What the server has, blocks that are objects', actual, expected) + }), + Then('the server has a block that is not an object', (context: Context) => { + checkEqual( + 'Whether the server has a block that is not an object', + (context.world.getServer().copy().value ?? []).some( + (block) => !isObject(block), + ), + true, + ) + }), + Then('the server has no document', (context: Context) => { + const copy = context.world.getServer().copy() + + checkEqual("The server's field", describeField(copy.value), 'no field') + checkEqual("The server's revision", copy.rev, undefined) + }), + Then('the server has no field', (context: Context) => { + const copy = context.world.getServer().copy() + + checkEqual("The server's field", describeField(copy.value), 'no field') + checkNotEqual("The server's revision", copy.rev, undefined) + }), + Then('the server has an empty list', (context: Context) => { + const copy = context.world.getServer().copy() + + checkEqual("The server's field", describeField(copy.value), 'an empty list') + }), + Then( + 'every block in {editor} has a unique key', + (context: Context, name: EditorName) => { + const value = context.world.getEditor(name).document.getValue() + + checkEmpty(`Key problems in ${name}`, duplicateOrMissingKeys(value)) + }, + ), + Then('every block on the server has a unique key', (context: Context) => { + const value = context.world.getServer().copy().value ?? [] + + checkEmpty('Key problems on the server', duplicateOrMissingKeys(value)) + }), + Then( + '{editor} has sent mutation {int}', + (context: Context, name: EditorName, mutationNumber: number) => { + const worldEditor = context.world.getEditor(name) + + checkEqual( + `The mutations ${name} has sent`, + worldEditor.heard.mutations.length, + mutationNumber, + ) + worldEditor.checkedMutationCount = mutationNumber + }, + ), + Then( + '{editor} has sent nothing new', + (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + + checkEqual( + `The mutations ${name} has sent`, + worldEditor.heard.mutations.length, + worldEditor.checkedMutationCount, + ) + }, + ), + Then( + '{editor} has sent a final mutation', + (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + + checkEqual( + `Whether ${name}'s last mutation is final`, + worldEditor.heard.mutations.at(-1)?.final, + true, + ) + worldEditor.checkedMutationCount = worldEditor.heard.mutations.length + }, + ), + Then( + "{editor}'s mutation {int} creates the block", + (context: Context, name: EditorName, mutationNumber: number) => { + checkEqual( + `Whether ${name}'s mutation ${mutationNumber} creates the block`, + createsBlock(context.world.getMutation(name, mutationNumber).patches), + true, + ) + }, + ), + Then( + "{editor}'s mutation {int} does not create a block", + (context: Context, name: EditorName, mutationNumber: number) => { + checkEqual( + `Whether ${name}'s mutation ${mutationNumber} creates a block`, + createsBlock(context.world.getMutation(name, mutationNumber).patches), + false, + ) + }, + ), + Then( + "{editor}'s mutation {int} does not empty the field", + (context: Context, name: EditorName, mutationNumber: number) => { + checkEqual( + `Whether ${name}'s mutation ${mutationNumber} empties the field`, + emptiesField(context.world.getMutation(name, mutationNumber).patches), + false, + ) + }, + ), + Then( + "{editor}'s mutation {int} empties the field", + (context: Context, name: EditorName, mutationNumber: number) => { + checkEqual( + `Whether ${name}'s mutation ${mutationNumber} empties the field`, + emptiesField(context.world.getMutation(name, mutationNumber).patches), + true, + ) + }, + ), + Then( + '{editor} reports that it is out of step', + (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + const errorCount = worldEditor.heard.errors.length + + checkGreaterThan( + `The errors ${name} has reported`, + errorCount, + worldEditor.checkedErrorCount, + ) + worldEditor.checkedErrorCount = errorCount + }, + ), + Then( + '{editor} reports that it is out of step, with reason {errorReason}', + (context: Context, name: EditorName, reason: ErrorEvent['reason']) => { + const worldEditor = context.world.getEditor(name) + const {errors} = worldEditor.heard + + checkEqual( + `The reasons of the errors ${name} has reported since the last check`, + JSON.stringify( + errors + .slice(worldEditor.checkedErrorCount) + .map((error) => error.reason), + ), + JSON.stringify([reason]), + ) + worldEditor.checkedErrorCount = errors.length + }, + ), + Then('{editor} is in step', (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + + checkEmpty( + `New errors from ${name}`, + worldEditor.heard.errors.slice(worldEditor.checkedErrorCount), + ) + }), + Then('{editor} has been warned', (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + const warningCount = worldEditor.heard.warnings.length + + checkGreaterThan( + `The warnings ${name} has given`, + warningCount, + worldEditor.checkedWarningCount, + ) + worldEditor.checkedWarningCount = warningCount + }), + Then( + '{editor} has been told work was dropped', + (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + const workDroppedCount = worldEditor.heard.workDropped.length + + checkGreaterThan( + `The dropped work ${name} has reported`, + workDroppedCount, + worldEditor.checkedWorkDroppedCount, + ) + worldEditor.checkedWorkDroppedCount = workDroppedCount + }, + ), + Then( + '{editor} has been told work was dropped, with reason {dropReason}', + (context: Context, name: EditorName, reason: WorkDropped['reason']) => { + const worldEditor = context.world.getEditor(name) + const {workDropped} = worldEditor.heard + + checkEqual( + `Whether ${name} has reported dropped work with reason "${reason}"`, + workDropped + .slice(worldEditor.checkedWorkDroppedCount) + .some((dropped) => dropped.reason === reason), + true, + ) + worldEditor.checkedWorkDroppedCount = workDropped.length + }, + ), + Then( + "the retry of {editor}'s mutation {int} was refused as a duplicate", + (context: Context, name: EditorName, mutationNumber: number) => { + const mutationId = context.world.getMutation(name, mutationNumber).id + + checkEqual( + `Whether the server refused a retry of ${name}'s mutation ${mutationNumber} as a duplicate`, + context.world + .getServer() + .getDuplicates() + .some((duplicate) => duplicate.mutationIds.includes(mutationId)), + true, + ) + }, + ), + Then( + "the server has saved {editor}'s mutation {int} once", + (context: Context, name: EditorName, mutationNumber: number) => { + const mutationId = context.world.getMutation(name, mutationNumber).id + + checkEqual( + `The transactions carrying ${name}'s mutation ${mutationNumber}`, + context.world + .getServer() + .getTransactions() + .filter((transaction) => transaction.mutationIds.includes(mutationId)) + .length, + 1, + ) + }, + ), + Then( + "the server saved {editor}'s mutation {int} under the transaction ID it proposed", + (context: Context, name: EditorName, mutationNumber: number) => { + const mutation = context.world.getMutation(name, mutationNumber) + + checkEqual( + `The transaction carrying ${name}'s mutation ${mutationNumber}`, + context.world + .getServer() + .getTransactions() + .find((transaction) => transaction.mutationIds.includes(mutation.id)) + ?.transactionId, + mutation.transactionId, + ) + }, + ), + Then( + "{editor}'s host has named the transaction for mutation {int}", + (context: Context, name: EditorName, mutationNumber: number) => { + const mutationId = context.world.getMutation(name, mutationNumber).id + + checkEqual( + `Whether ${name}'s host sent \`mutation sent\` for mutation ${mutationNumber}`, + context.world + .getEditor(name) + .mutationsSent.some((mutationSent) => mutationSent.id === mutationId), + true, + ) + }, + ), + Then( + "{editor}'s host has not named a transaction", + (context: Context, name: EditorName) => { + checkEmpty( + `The \`mutation sent\` messages ${name}'s host sent`, + context.world.getEditor(name).mutationsSent, + ) + }, + ), + Then('the resync is refused', (context: Context) => { + const resync = context.world.getLastResync() + const {document, heard} = context.world.getEditor(resync.editorName) + + checkGreaterThan( + `The warnings ${resync.editorName} has given`, + heard.warnings.length, + resync.warningCount, + ) + checkEqual( + `What ${resync.editorName} shows`, + document.toTextspec({keys: true}), + resync.screen, + ) + checkEqual( + `The mutations ${resync.editorName} has sent`, + heard.mutations.length, + resync.mutationCount, + ) + }), + Then('the load was refused', (context: Context) => { + const load = context.world.getLastLoad() + const {document} = context.world.getEditor(load.editorName) + + checkEqual(`Whether loading ${load.editorName} threw`, load.threw, true) + checkEqual(`${load.editorName}'s status`, document.getStatus(), 'ready') + checkEqual( + `What ${load.editorName} shows`, + document.toTextspec({keys: true}), + load.screen, + ) + }), + Then( + "{editor}'s status is {status}", + (context: Context, name: EditorName, status: FakeDocumentStatus) => { + checkEqual( + `${name}'s status`, + context.world.getEditor(name).document.getStatus(), + status, + ) + }, + ), + Then( + "{editor}'s sync is {sync}", + (context: Context, name: EditorName, sync: ExpectedSync) => { + checkEqual( + `${name}'s sync`, + context.world.getEditor(name).io.getSnapshot().context.sync, + sync, + ) + }, + ), + Then( + '{editor} has emitted {int} change(s)', + (context: Context, name: EditorName, count: number) => { + checkEqual( + `The changes ${name} has emitted`, + context.world.getEditor(name).heard.changes.length, + count, + ) + }, + ), + Then( + "{editor}'s change {int} carries the patches of mutation {int}", + ( + context: Context, + name: EditorName, + changeNumber: number, + mutationNumber: number, + ) => { + const change = getChange(context, name, changeNumber) + + checkEqual( + `The patches ${name}'s change ${changeNumber} carries`, + JSON.stringify(change.origin === 'local' ? change.patches : undefined), + JSON.stringify(context.world.getMutation(name, mutationNumber).patches), + ) + }, + ), + Then( + "{editor}'s change {int} carries no patches", + (context: Context, name: EditorName, changeNumber: number) => { + checkEqual( + `Whether ${name}'s change ${changeNumber} carries patches`, + 'patches' in getChange(context, name, changeNumber), + false, + ) + }, + ), + Then( + '{editor} has emitted no change', + (context: Context, name: EditorName) => { + checkEqual( + `The changes ${name} has emitted`, + context.world.getEditor(name).heard.changes.length, + 0, + ) + }, + ), + Then( + "{editor}'s last apply carries no patches", + (context: Context, name: EditorName) => { + checkEqual( + `The patches of ${name}'s last apply`, + JSON.stringify(getLastApply(context, name).patches), + JSON.stringify([]), + ) + }, + ), + Then( + "{editor}'s last apply carries the patches of {editor}'s mutation {int}", + ( + context: Context, + name: EditorName, + senderName: EditorName, + mutationNumber: number, + ) => { + checkEqual( + `The patches of ${name}'s last apply`, + JSON.stringify(getLastApply(context, name).patches), + JSON.stringify( + context.world.getMutation(senderName, mutationNumber).patches, + ), + ) + }, + ), + Then( + "{editor}'s last apply has {editor}'s mutation {int} underneath", + ( + context: Context, + name: EditorName, + senderName: EditorName, + mutationNumber: number, + ) => { + checkEqual( + `What ${name}'s last apply has underneath`, + JSON.stringify(getLastApply(context, name).underneath), + JSON.stringify( + context.world.getMutation(senderName, mutationNumber).patches, + ), + ) + }, + ), + Then( + "{editor}'s last apply sets the whole block {string}", + (context: Context, name: EditorName, text: string) => { + const block = findBlockByText( + context.world.getEditor(name).document.getValue(), + text, + ) + + checkEqual( + `The patches of ${name}'s last apply`, + JSON.stringify(getLastApply(context, name).patches), + JSON.stringify([set(block, [{_key: block._key}])]), + ) + }, + ), +].map(checkingTrees) + +function checkingTrees( + definition: StepDefinition, +): StepDefinition { + const callback: ( + context: Context, + paramA: unknown, + paramB: unknown, + paramC: unknown, + ) => Promise | void = definition.callback + + return { + ...definition, + callback: ( + context: Context, + paramA: unknown, + paramB: unknown, + paramC: unknown, + ) => { + const result = callback(context, paramA, paramB, paramC) + + if (result instanceof Promise) { + return result.then(() => checkTrees(context)) + } + + return checkTrees(context) + }, + } +} + +function checkTrees(context: Context) { + checkEmpty( + "Where an editor's tree differed from io's working copy", + context.world.takeTreeMismatches(), + ) +} + +function userSteps() { + const actions = [ + { + text: '{string} is typed', + run: (context: Context, name: EditorName, text: string) => + context.world.type(name, text), + }, + { + text: '{string} is deleted before the caret', + run: (context: Context, name: EditorName, text: string) => + context.world.deleteBeforeCaret(name, text), + }, + { + text: 'the caret is put after {string}', + run: (context: Context, name: EditorName, text: string) => + context.world.putCaretAfter(name, text), + }, + { + text: 'the style is set to {style}', + run: (context: Context, name: EditorName, style: string) => + context.world.setStyle(name, style), + }, + { + text: 'the block {textspec} is inserted', + run: (context: Context, name: EditorName, textspec: string) => + context.world.insertBlock(name, textspec), + }, + { + text: 'the block {string} is deleted', + run: (context: Context, name: EditorName, text: string) => + context.world.deleteBlock(name, text), + }, + ] + + return [ + ...actions.flatMap(({text, run}) => [ + When(text, (context: Context, argument: string) => + run(context, 'Editor A', argument), + ), + When( + `${text} in {editor}`, + (context: Context, argument: string, name: EditorName) => + run(context, name, argument), + ), + ]), + When('the block is split at the caret', (context: Context) => { + context.world.splitAtCaret('Editor A') + }), + When( + 'the block is split at the caret in {editor}', + (context: Context, name: EditorName) => { + context.world.splitAtCaret(name) + }, + ), + When('undo is performed', (context: Context) => { + context.world.undo('Editor A') + }), + When( + 'undo is performed in {editor}', + (context: Context, name: EditorName) => { + context.world.undo(name) + }, + ), + ] +} + +function getChange( + context: Context, + name: EditorName, + changeNumber: number, +): ChangeEvent { + const change = context.world.getEditor(name).heard.changes[changeNumber - 1] + + if (!change) { + throw new Error(`${name} has not emitted change ${changeNumber}`) + } + + return change +} + +function getLastApply( + context: Context, + name: EditorName, +): Extract { + const apply = context.world + .getEditor(name) + .received.findLast((message) => message.type === 'apply') + + if (apply?.type !== 'apply') { + throw new Error(`${name} has not been sent an apply`) + } + + return apply +} + +function findBlockByText( + value: Array, + text: string, +): PortableTextBlock { + const matches = value.filter((block) => blockText(block) === text) + + if (matches.length !== 1) { + throw new Error( + `Expected one block with the text "${text}", found ${matches.length}`, + ) + } + + return matches[0] +} + +function blockText(block: PortableTextBlock): string { + const children: unknown = Reflect.get(block, 'children') + + return Array.isArray(children) + ? children + .map((child: unknown) => { + const text: unknown = + typeof child === 'object' && child !== null + ? Reflect.get(child, 'text') + : undefined + + return typeof text === 'string' ? text : '' + }) + .join('') + : '' +} + +function isObject(value: unknown): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function describeField(value: Array | undefined): string { + if (value === undefined) { + return 'no field' + } + + return value.length === 0 ? 'an empty list' : formatTextspec(value) +} + +function duplicateOrMissingKeys( + value: Array, +): Array { + const seen = new Set() + + return value.flatMap((block, index) => { + const key: unknown = block._key + + if (typeof key !== 'string' || key === '') { + return [`block ${index} has no key`] + } + + if (seen.has(key)) { + return [`block ${index} repeats the key "${key}"`] + } + + seen.add(key) + + return [] + }) +} diff --git a/packages/io/src/scenario/world.test.ts b/packages/io/src/scenario/world.test.ts new file mode 100644 index 0000000000..fb1b025dd2 --- /dev/null +++ b/packages/io/src/scenario/world.test.ts @@ -0,0 +1,523 @@ +import {diffMatchPatch, set} from '@portabletext/patches' +import {describe, expect, test} from 'vitest' +import {createWorld} from './world' + +describe(createWorld.name, () => { + test('a snapshot before the editors exist has no parts', () => { + const world = createWorld() + + world.serverHas('B: foo') + + expect(world.snapshot()).toEqual({ + editors: null, + server: null, + network: null, + }) + }) + + test('a snapshot shows the editors, the server and the network as plain data', () => { + const world = createWorld() + + world.documentIs('B: foo|') + world.type('Editor A', 'x') + world.setStyle('Editor B', 'h1') + world.receive('Editor A', 1) + world.changeOtherField() + world.deliverNamed('Editor B', 'the other field') + world.type('Editor A', 'y') + + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const stylePath = [{_key: 'd-k0'}, 'style'] + + expect(world.snapshot()).toEqual({ + editors: { + 'Editor A': { + id: 'A', + host: 'plain', + status: 'ready', + io: { + status: 'ready', + sync: 'saving', + rev: 'r1', + inFlight: {id: 'A-1', transactionId: 'A-tk0'}, + pending: 1, + }, + screen: 'B: fooxy|', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'fooxy', marks: []}, + ], + style: 'normal', + }, + ], + base: { + textspec: 'B: foo', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + rev: 'r1', + }, + inFlight: { + mutationNumber: 1, + transactionIds: ['A-tk0'], + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + rejected: null, + echoed: [], + pending: [ + { + patchCount: 1, + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], + held: [], + readOnly: false, + undoDepth: 2, + sentMutations: [ + { + number: 1, + id: 'A-1', + transactionId: 'A-tk0', + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + final: false, + }, + ], + events: [ + {type: 'change', origin: 'local', patchCount: 1}, + {type: 'change', origin: 'local', patchCount: 1}, + ], + messages: [ + { + route: 'host to io', + type: 'load', + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + rev: 'r1', + }, + { + route: 'io to editor', + type: 'load', + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + }, + { + route: 'io to host', + type: 'mutation', + id: 'A-1', + transactionId: 'A-tk0', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, + ], + }, + 'Editor B': { + id: 'B', + host: 'plain', + status: 'ready', + io: { + status: 'ready', + sync: 'saving', + rev: 'r1', + inFlight: {id: 'B-1', transactionId: 'B-tk0'}, + pending: 0, + }, + screen: 'H1: foo|', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foo', marks: []}], + style: 'h1', + }, + ], + base: { + textspec: 'B: foo', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + rev: 'r1', + }, + inFlight: { + mutationNumber: 1, + transactionIds: ['B-tk0'], + patchCount: 1, + patches: [set('h1', stylePath)], + }, + rejected: null, + echoed: [], + pending: [], + held: [ + { + transactionId: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }, + ], + readOnly: false, + undoDepth: 1, + sentMutations: [ + { + number: 1, + id: 'B-1', + transactionId: 'B-tk0', + patchCount: 1, + patches: [set('h1', stylePath)], + final: false, + }, + ], + events: [{type: 'change', origin: 'local', patchCount: 1}], + messages: [ + { + route: 'host to io', + type: 'load', + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + rev: 'r1', + }, + { + route: 'io to editor', + type: 'load', + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + }, + { + route: 'io to host', + type: 'mutation', + id: 'B-1', + transactionId: 'B-tk0', + patches: [set('h1', stylePath)], + }, + { + route: 'host to io', + type: 'transaction', + via: 'feed', + transactionId: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }, + ], + }, + }, + server: { + value: 'B: foox', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foox', marks: []}], + style: 'normal', + }, + ], + rev: 'r3', + transactions: [ + { + id: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + mutationIds: ['A-1'], + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + noop: false, + source: { + type: 'mutations', + mutations: [{name: 'Editor A', mutationNumber: 1}], + }, + }, + { + id: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + mutationIds: [], + patchCount: 0, + patches: [], + noop: true, + source: {type: 'named', name: 'the other field'}, + }, + ], + duplicates: [], + nextFailure: null, + }, + network: { + saveRequests: [ + { + editor: 'Editor B', + mutationId: 'B-1', + mutationNumber: 1, + final: false, + patchCount: 1, + patches: [set('h1', stylePath)], + }, + ], + replies: [], + lostReplies: [], + feeds: { + 'Editor A': [ + { + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + mutationIds: ['A-1'], + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + source: { + type: 'mutations', + mutations: [{name: 'Editor A', mutationNumber: 1}], + }, + }, + { + transactionId: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + mutationIds: [], + patchCount: 0, + patches: [], + source: {type: 'named', name: 'the other field'}, + }, + ], + 'Editor B': [ + { + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + mutationIds: ['A-1'], + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + source: { + type: 'mutations', + mutations: [{name: 'Editor A', mutationNumber: 1}], + }, + }, + ], + }, + carriesServerCopy: false, + now: 0, + }, + }) + }) + + test('a self-confirming host forwards the transaction its save answers with', () => { + const world = createWorld() + + world.setHostShape('self-confirming') + world.documentIs('B: foo|') + world.type('Editor A', 'x') + world.receive('Editor A', 1) + + const fooBlock = { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foo', marks: []}], + style: 'normal', + } + const textPatch = diffMatchPatch('foo', 'foox', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + 'text', + ]) + + expect(world.snapshot().editors?.['Editor A'].messages).toEqual([ + {route: 'host to io', type: 'load', value: [fooBlock], rev: 'r1'}, + {route: 'io to editor', type: 'load', value: [fooBlock]}, + { + route: 'io to host', + type: 'mutation', + id: 'A-1', + transactionId: 'A-tk0', + patches: [textPatch], + }, + { + route: 'host to io', + type: 'transaction', + via: 'save reply', + transactionId: 'A-tk0', + previousRev: 'r1', + resultRev: 'r2', + patches: [textPatch], + }, + { + route: 'io to editor', + type: 'apply', + patches: [], + underneath: [textPatch], + }, + ]) + }) + + test('a resync after `feed lost` re-submits the mutation in flight, and the 409 is on the path', () => { + const world = createWorld() + + world.documentIs('B: foo|') + world.type('Editor A', 'x') + world.receive('Editor A', 1) + world.feedLost('Editor A') + world.resync('Editor A', {discardUnsent: false, outcomeOf: 1}) + + const fooxBlock = { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foox', marks: []}], + style: 'normal', + } + + expect(world.snapshot().editors?.['Editor A'].messages.slice(3)).toEqual([ + {route: 'host to io', type: 'feed lost'}, + { + route: 'host to server', + type: 're-submit', + transactionId: 'A-tk0', + answer: {type: 'duplicate'}, + }, + { + route: 'host to io', + type: 'resync', + value: [fooxBlock], + rev: 'r2', + outcomes: {'A-1': 'applied'}, + }, + {route: 'io to editor', type: 'resync', value: [fooxBlock]}, + ]) + }) + + test("a snapshot writes stored blocks textspec can't spell as JSON", () => { + const world = createWorld() + + world.serverHas('B _key="k1": foo;;B _key="k2": bar') + world.corruptServerBlock('k1', {type: 'string', value: 'oops'}) + world.corruptServerBlock('k2', {type: 'no type'}) + world.startEditors() + world.load('Editor A') + + const snapshot = world.snapshot() + + expect({ + server: snapshot.server?.value, + base: snapshot.editors?.['Editor A'].base.textspec, + }).toEqual({ + server: + '"oops";;{"_key":"k2","children":[{"_key":"d-k1","_type":"span","text":"bar","marks":[]}],"style":"normal"}', + base: '"oops";;{"_key":"k2","children":[{"_key":"d-k1","_type":"span","text":"bar","marks":[]}],"style":"normal"}', + }) + }) + + test("an editor's tree apart from io's working copy is reported for the moment it happened and for every step it lasts", () => { + const world = createWorld() + + world.documentIs('B: foo|') + + expect(world.takeTreeMismatches()).toEqual([]) + + world.getEditor('Editor A').document.send({ + type: 'apply', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + underneath: [], + }) + world.type('Editor A', 'x') + + expect(world.takeTreeMismatches()).toEqual([ + { + editor: 'Editor A', + after: 'local change', + tree: 'H1 _key="d-k0": foox', + workingCopy: 'B _key="d-k0": foox', + }, + { + editor: 'Editor A', + after: 'the step', + tree: 'H1 _key="d-k0": foox', + workingCopy: 'B _key="d-k0": foox', + }, + ]) + expect(world.takeTreeMismatches()).toEqual([ + { + editor: 'Editor A', + after: 'the step', + tree: 'H1 _key="d-k0": foox', + workingCopy: 'B _key="d-k0": foox', + }, + ]) + }) + + test('Scenario: a placeholder that is no longer one empty text block is a tree apart from an empty working copy', () => { + const world = createWorld() + + world.serverHasCopy('an empty list') + world.startEditors() + world.load('Editor A') + world.endFirstCommit('Editor A') + + expect(world.takeTreeMismatches()).toEqual([]) + + world + .getEditor('Editor A') + .document.getValue() + .splice(0, 1, { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: 'hidden', marks: []}], + }) + + expect(world.takeTreeMismatches()).toEqual([ + { + editor: 'Editor A', + after: 'the step', + tree: 'B _key="a-k0": hidden', + workingCopy: '', + }, + ]) + }) +}) diff --git a/packages/io/src/scenario/world.ts b/packages/io/src/scenario/world.ts new file mode 100644 index 0000000000..46b1770723 --- /dev/null +++ b/packages/io/src/scenario/world.ts @@ -0,0 +1,1333 @@ +import {set, unset, type Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import { + createFakeDocument, + formatTextspec, + isEqual, + parseTextspec, + type FakeDocument, + type FakeDocumentStatus, +} from '../fakes/document' +import {createFakeNetwork, type Network} from '../fakes/network' +import { + createFakeServer, + type Server, + type ServerTransaction, +} from '../fakes/server' +import {applyWithContentLakeSemantics} from '../protocol/content-lake' +import { + createPassThroughHost, + type PassThroughHost, + type RequestFailure, + type SaveAnswer, +} from '../protocol/host' +import { + createIo, + getIoInternals, + type Clock, + type Io, + type IoMessage, + type IoSentMutation, + type IoSnapshot, +} from '../protocol/io' +import type { + ChangeEvent, + EditorMessageForIo, + ErrorEvent, + Load, + Mutation, + MutationRejected, + MutationSent, + Resync, + Transaction, + WorkDropped, +} from '../protocol/types' +import {withModelUndo, type ModelUndoIo} from './model-undo' + +export type EditorName = 'Editor A' | 'Editor B' + +export type ServerCopyName = 'no document' | 'no field' | 'an empty list' + +/** + * How the hosts save: `'plain'` saves each mutation as its own request under + * the transaction ID it proposes, `'folding'` folds mutations into shared + * requests and names each request's transaction with `mutation sent`, and + * `'self-confirming'` has no feed listener and forwards the transaction each + * save answers with. + */ +export type HostShape = 'plain' | 'folding' | 'self-confirming' + +/** + * What the editor's listeners received, recorded from the moment it was + * created. + */ +export type Heard = { + mutations: Array + changes: Array + errors: Array + warnings: Array + workDropped: Array + /** Changes, errors, dropped work and warnings in the order they were heard. */ + events: Array +} + +export type HeardEvent = + | {type: 'change'; origin: ChangeEvent['origin']; patchCount: number} + | ({type: 'error'} & ErrorEvent) + | ({type: 'work dropped'; patchCount: number} & WorkDropped) + | {type: 'warning'; message: string} + +/** + * A message on an editor's path, recorded in the order it passed: io and its + * host talking, io sending the editor content, and the host sending a save + * request again to find out what became of it. A `transaction` says whether + * the host had it from the feed or from the answer to its own save. + */ +export type PathMessage = + | ({route: 'io to host'; type: 'mutation'} & Mutation) + | ({route: 'host to io'; type: 'mutation sent'} & MutationSent) + | ({route: 'host to io'; type: 'mutation rejected'} & MutationRejected) + | ({ + route: 'host to io' + type: 'transaction' + via: 'feed' | 'save reply' + } & Transaction) + | {route: 'host to io'; type: 'feed lost'} + | ({route: 'host to io'; type: 'load'} & Load) + | ({route: 'host to io'; type: 'resync'} & Resync) + | ({route: 'io to editor'} & EditorMessageForIo) + | { + route: 'host to server' + type: 're-submit' + transactionId: string + answer: SaveAnswer + } + +/** + * A moment an editor's tree differed from io's working copy: right after + * the editor took a message from io or a local change, or at the end of a + * step. Both are textspec with keys, the placeholder left out. + */ +export type TreeMismatch = { + editor: EditorName + after: EditorMessageForIo['type'] | 'local change' | 'the step' + tree: string + workingCopy: string +} + +export type NamedTransaction = + | 'the other field' + | "the script's change" + | "the script's corruption" + | 'the deletion' + | 'the recreation' + +/** + * A change that leaves a block below the floor: without its `_key` or + * `_type`, with `children` that is a string, with its first span's `text` a + * number, or replaced by a string. + */ +export type Corruption = + | {type: 'no key'} + | {type: 'no type'} + | {type: 'children'; children: string} + | {type: 'span text'; text: number} + | {type: 'string'; value: string} + +/** + * What a transaction carries: mutations sent by the editors, or a change made + * on the server. + */ +export type TransactionSource = + | { + type: 'mutations' + mutations: Array<{name: EditorName; mutationNumber: number}> + } + | {type: 'named'; name: NamedTransaction} + +export type MutationSnapshot = { + mutationNumber: number + /** Every transaction ID the host reported for the mutation. */ + transactionIds: Array + patchCount: number + patches: Array +} + +export type EditorSnapshot = { + id: string + host: HostShape + status: FakeDocumentStatus + /** io's own snapshot, as `io.getSnapshot()` returns it. */ + io: IoSnapshot['context'] + /** What the editor shows, with the caret. */ + screen: string + /** What the editor shows, as blocks, the placeholder included. */ + blocks: Array + base: { + textspec: string | null + blocks: Array | null + rev: string | null + } + inFlight: MutationSnapshot | null + rejected: MutationSnapshot | null + /** Mutations that came back and wait behind a held transaction. */ + echoed: Array + pending: Array<{patchCount: number; patches: Array}> + held: Array<{ + transactionId: string + previousRev: string | null + resultRev: string | null + patches: Array + }> + readOnly: boolean + /** How many of the editor's own changes undo can still revert. */ + undoDepth: number + sentMutations: Array<{ + number: number + id: string + transactionId: string + patchCount: number + patches: Array + final: boolean + }> + events: Array + messages: Array +} + +export type ServerSnapshot = { + /** The field as textspec, `null` when there is no field. */ + value: string | null + /** The field as blocks, `null` when there is no field. */ + blocks: Array | null + rev: string | null + transactions: Array<{ + id: string + previousRev: string | null + resultRev: string | null + mutationIds: Array + patchCount: number + patches: Array + noop: boolean + source: TransactionSource + }> + /** Save requests refused with a 409 because their transaction ID exists. */ + duplicates: Array<{ + transactionId: string + mutations: Array<{name: EditorName; mutationNumber: number}> + }> + /** The failure the next save request meets, if a step injected one. */ + nextFailure: RequestFailure | null +} + +export type NetworkSnapshot = { + saveRequests: Array<{ + editor: EditorName + mutationId: string + mutationNumber: number + final: boolean + patchCount: number + patches: Array + }> + /** Replies that say a save request failed, with the failure. */ + replies: Array<{ + editor: EditorName + mutationId: string + mutationNumber: number + status: RequestFailure + }> + /** Saves the server took whose reply never reached the host. */ + lostReplies: Array<{ + editor: EditorName + mutationId: string + mutationNumber: number + }> + feeds: Record< + EditorName, + Array<{ + transactionId: string + previousRev: string | null + resultRev: string | null + mutationIds: Array + patchCount: number + patches: Array + source: TransactionSource + }> + > + /** Whether each transaction reaches the hosts with the server's copy. */ + carriesServerCopy: boolean + now: number +} + +/** + * The world as plain data. `null` parts before the editors exist. + */ +export type WorldSnapshot = { + editors: Record | null + server: ServerSnapshot | null + network: NetworkSnapshot | null +} + +export type WorldEditor = { + /** The fake editor, which the user steps act on. */ + document: FakeDocument + io: ModelUndoIo + host: PassThroughHost + heard: Heard + /** Every `mutation sent` the host gave the editor. */ + mutationsSent: Array + /** Every message io sent the editor. */ + received: Array + /** Every message on the editor's path, in order. */ + messages: Array + /** + * The moments the editor's tree differed from io's working copy, since the + * last `takeTreeMismatches`. + */ + treeMismatches: Array> + /** The mutation count at the previous `has sent` check. */ + checkedMutationCount: number + /** The error count at the previous out-of-step or in-step check. */ + checkedErrorCount: number + /** The warning count at the previous `has been warned` check. */ + checkedWarningCount: number + /** The dropped-work count at the previous `told work was dropped` check. */ + checkedWorkDroppedCount: number +} + +type Setup = { + server: Server + network: Network + editors: Record +} + +type LoadAttempt = { + editorName: EditorName + threw: boolean + screen: string +} + +type ResyncAttempt = { + editorName: EditorName + warningCount: number + screen: string + mutationCount: number +} + +export type World = ReturnType + +export const editorNames: ReadonlyArray = ['Editor A', 'Editor B'] + +/** How long an editor holds a transaction before it reports `out of order`. */ +export const heldTransactionTimeout = 10_000 + +/** + * One server, one network and two editors, each with a pass-through host. + * The server's initial document is set up first, and the editors are created + * on demand, so steps can shape the document before anyone loads it. A + * created editor is in its first commit until a step ends it. With + * `serverCopyOnTransactions`, every transaction reaches the hosts with the + * field as the server held it right after, and the hosts pass it on as + * `value`. + */ +export function createWorld( + options: {serverCopyOnTransactions?: boolean} = {}, +) { + const documentKeyGenerator = createTestKeyGenerator('d-') + let initialDocument: {value: Array | undefined} | undefined + let hostShape: HostShape = 'plain' + let serverCopyOnTransactions = options.serverCopyOnTransactions ?? false + let setup: Setup | undefined + let lastResync: ResyncAttempt | undefined + let lastLoad: LoadAttempt | undefined + const namedTransactions = new Map() + const namedTransactionCounts = new Map() + + function startEditors(): Setup { + const server = createFakeServer({ + documentId: 'document', + document: initialDocument, + }) + const network = createFakeNetwork( + serverCopyOnTransactions + ? {copyAfter: (transactionId) => server.getCopyAfter(transactionId)} + : {}, + ) + const editors = { + 'Editor A': createWorldEditor({ + name: 'Editor A', + server, + network, + hostShape, + }), + 'Editor B': createWorldEditor({ + name: 'Editor B', + server, + network, + hostShape, + }), + } + + setup = {server, network, editors} + + return setup + } + + /** + * The server takes the save requests first, so a host that forms the + * request at that moment has formed it before the save. The server gets + * the request as the first mutation's host formed it, and answers a 409 when + * a re-submit saved it already. A failed request sends each sender a reply + * with the failure. + */ + function publishReceived( + mutations: Array<{name: EditorName; mutation: Mutation}>, + ) { + const {server, network} = getSetup() + + for (const {mutation} of mutations) { + network.takeSaveRequest(mutation.id) + } + + const [first] = mutations + const request = getEditor(first.name).host.getRequest(first.mutation.id) + const result = server.submit(request.mutations, request.transactionId) + + if (result.type === 'saved') { + publishSaved(mutations, result.transaction) + } + + if (result.type === 'failed') { + for (const {name, mutation} of mutations) { + network.queueReply({ + editorId: name, + mutationId: mutation.id, + status: result.status, + }) + } + } + } + + function foldAsOne( + first: {name: EditorName; mutationNumber: number}, + second: {name: EditorName; mutationNumber: number}, + ): Array<{name: EditorName; mutation: Mutation}> { + const mutations = [first, second].map(({name, mutationNumber}) => ({ + name, + mutation: getMutation(name, mutationNumber), + })) + const request = { + transactionId: mutations.map(({mutation}) => mutation.id).join('+'), + mutations: mutations.map(({mutation}) => mutation), + } + + for (const {name, mutation} of mutations) { + getEditor(name).host.foldIntoRequest(mutation.id, request) + } + + return mutations + } + + /** The feed carries the transaction, and so does each sender's save reply. */ + function publishSaved( + mutations: Array<{name: EditorName; mutation: Mutation}>, + transaction: ServerTransaction, + ) { + getSetup().network.publish(transaction) + + for (const {name, mutation} of mutations) { + getEditor(name).host.reportSaved( + mutation.id, + getSetup().network.carry(transaction), + ) + } + } + + function getMutation(name: EditorName, mutationNumber: number): Mutation { + const mutation = getEditor(name).heard.mutations[mutationNumber - 1] + + if (!mutation) { + throw new Error(`${name} has not sent mutation ${mutationNumber}`) + } + + return mutation + } + + function getEditor(name: EditorName): WorldEditor { + return getSetup().editors[name] + } + + function transactionCarrying(mutationId: string): string { + const transaction = getSetup() + .server.getTransactions() + .find((candidate) => candidate.mutationIds.includes(mutationId)) + + if (!transaction) { + throw new Error(`The server has not received mutation "${mutationId}"`) + } + + return transaction.transactionId + } + + function nameTransaction(name: NamedTransaction) { + const count = (namedTransactionCounts.get(name) ?? 0) + 1 + const transactionId = `${namedTransactionPrefixes[name]}-${count}` + namedTransactionCounts.set(name, count) + namedTransactions.set(transactionId, name) + return transactionId + } + + function describeSource(transaction: ServerTransaction): TransactionSource { + if (transaction.mutationIds.length === 0) { + const name = namedTransactions.get(transaction.transactionId) + + if (!name) { + throw new Error( + `Transaction "${transaction.transactionId}" carries no mutation`, + ) + } + + return {type: 'named', name} + } + + return { + type: 'mutations', + mutations: transaction.mutationIds.map((mutationId) => + locateMutation(mutationId), + ), + } + } + + function locateMutation(mutationId: string): { + name: EditorName + mutationNumber: number + } { + for (const name of editorNames) { + const index = getEditor(name).heard.mutations.findIndex( + (mutation) => mutation.id === mutationId, + ) + + if (index !== -1) { + return {name, mutationNumber: index + 1} + } + } + + throw new Error(`No editor has sent mutation "${mutationId}"`) + } + + function snapshotEditor(name: EditorName): EditorSnapshot { + const {document, io, host, heard, messages} = getEditor(name) + const {server} = getSetup() + const internals = getIoInternals(io) + const ledger = internals.inspect() + const base = internals.getBase() + const describeMutation = (mutation: IoSentMutation): MutationSnapshot => ({ + mutationNumber: locateMutation(mutation.id).mutationNumber, + transactionIds: mutation.transactionIds, + patchCount: mutation.patchCount, + patches: getMutation(name, locateMutation(mutation.id).mutationNumber) + .patches, + }) + + return { + id: name === 'Editor A' ? 'A' : 'B', + host: hostShape, + status: document.getStatus(), + io: io.getSnapshot().context, + screen: document.toTextspec(), + blocks: document.getValue(), + base: { + textspec: + base.value === undefined ? null : formatStoredTextspec(base.value), + blocks: base.value ?? null, + rev: base.rev ?? null, + }, + inFlight: ledger.inFlight ? describeMutation(ledger.inFlight) : null, + rejected: ledger.rejected ? describeMutation(ledger.rejected) : null, + echoed: ledger.echoed.map(describeMutation), + pending: ledger.pending, + held: ledger.held.map((transaction) => ({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev ?? null, + resultRev: transaction.resultRev ?? null, + patches: server.getTransaction(transaction.transactionId).patches, + })), + readOnly: document.getReadOnly(), + undoDepth: io.getUndoDepth(), + sentMutations: heard.mutations.map((mutation, index) => ({ + number: index + 1, + id: mutation.id, + transactionId: host.getTransactionId(mutation.id), + patchCount: mutation.patches.length, + patches: mutation.patches, + final: mutation.final === true, + })), + events: [...heard.events], + messages: [...messages], + } + } + + function snapshot(): WorldSnapshot { + if (!setup) { + return {editors: null, server: null, network: null} + } + + const {server, network} = setup + const copy = server.copy() + const describeFeedItem = (transaction: ServerTransaction) => ({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev ?? null, + resultRev: transaction.resultRev ?? null, + mutationIds: [...transaction.mutationIds], + patchCount: transaction.patches.length, + patches: transaction.patches, + source: describeSource(transaction), + }) + + return { + editors: { + 'Editor A': snapshotEditor('Editor A'), + 'Editor B': snapshotEditor('Editor B'), + }, + server: { + value: + copy.value === undefined ? null : formatStoredTextspec(copy.value), + blocks: copy.value ?? null, + rev: copy.rev ?? null, + transactions: server.getLog().map(({transaction, changesField}) => ({ + id: transaction.transactionId, + previousRev: transaction.previousRev ?? null, + resultRev: transaction.resultRev ?? null, + mutationIds: [...transaction.mutationIds], + patchCount: transaction.patches.length, + patches: transaction.patches, + noop: !changesField, + source: describeSource(transaction), + })), + duplicates: server.getDuplicates().map((duplicate) => ({ + transactionId: duplicate.transactionId, + mutations: duplicate.mutationIds.map((mutationId) => + locateMutation(mutationId), + ), + })), + nextFailure: server.getNextFailure() ?? null, + }, + network: { + saveRequests: network.getSaveRequests().map(({editorId, mutation}) => ({ + editor: toEditorName(editorId), + mutationId: mutation.id, + mutationNumber: locateMutation(mutation.id).mutationNumber, + final: mutation.final === true, + patchCount: mutation.patches.length, + patches: mutation.patches, + })), + replies: network.getReplies().map((reply) => ({ + editor: toEditorName(reply.editorId), + mutationId: reply.mutationId, + mutationNumber: locateMutation(reply.mutationId).mutationNumber, + status: reply.status, + })), + lostReplies: network.getLostReplies().map((reply) => ({ + editor: toEditorName(reply.editorId), + mutationId: reply.mutationId, + mutationNumber: locateMutation(reply.mutationId).mutationNumber, + })), + feeds: { + 'Editor A': network.getFeed('Editor A').map(describeFeedItem), + 'Editor B': network.getFeed('Editor B').map(describeFeedItem), + }, + carriesServerCopy: serverCopyOnTransactions, + now: network.clock.now(), + }, + } + } + + function getSetup(): Setup { + if (!setup) { + throw new Error('No editors yet') + } + + return setup + } + + /** + * The tree mismatches recorded since the last call, and any an open editor + * has now. An editor that has closed is left out: its final mutation is no + * longer in the working copy. + */ + function takeTreeMismatches(): Array { + if (!setup) { + return [] + } + + return editorNames.flatMap((name) => { + const worldEditor = getEditor(name) + const recorded = worldEditor.treeMismatches.splice(0) + const now = findTreeMismatch(worldEditor, 'the step') + + return [...recorded, ...(now ? [now] : [])].map((mismatch) => ({ + editor: name, + ...mismatch, + })) + }) + } + + return { + getEditor, + getMutation, + getServer: () => getSetup().server, + snapshot, + takeTreeMismatches, + + documentIs: (textspec: string) => { + const {value, caret} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + initialDocument = {value} + + for (const {document, host} of Object.values(startEditors().editors)) { + host.load() + + if (caret) { + document.setCaret(caret) + } + + document.mount() + } + }, + serverHas: (textspec: string) => { + initialDocument = { + value: parseTextspec({keyGenerator: documentKeyGenerator}, textspec) + .value, + } + }, + serverHasCopy: (copy: ServerCopyName) => { + initialDocument = + copy === 'no document' + ? undefined + : {value: copy === 'no field' ? undefined : []} + }, + serverHasEmptyBlock: (key: string) => { + initialDocument = { + value: parseTextspec( + {keyGenerator: documentKeyGenerator}, + `B _key="${key}": |`, + ).value, + } + }, + /** Changes the server's document before anyone loads it. */ + corruptServerBlock: (key: string, corruption: Corruption) => { + if (setup) { + throw new Error('The editors are set up already') + } + + const value = initialDocument?.value + initialDocument = { + value: applyWithContentLakeSemantics( + value, + corruptionPatches(value, key, corruption), + ), + } + }, + startEditors, + + setHostShape: (shape: HostShape) => { + if (setup) { + throw new Error('The hosts are set up already') + } + + hostShape = shape + }, + carryServerCopyOnTransactions: () => { + if (setup) { + throw new Error('The network is set up already') + } + + serverCopyOnTransactions = true + }, + + receive: (name: EditorName, mutationNumber: number) => { + publishReceived([{name, mutation: getMutation(name, mutationNumber)}]) + }, + receiveFinal: (name: EditorName) => { + const mutation = getEditor(name).heard.mutations.at(-1) + + if (!mutation?.final) { + throw new Error(`${name} has not sent a final mutation`) + } + + publishReceived([{name, mutation}]) + }, + /** The hosts fold both mutations into one request, which stays unsent. */ + foldAsOne, + receiveAsOne: ( + first: {name: EditorName; mutationNumber: number}, + second: {name: EditorName; mutationNumber: number}, + ) => { + publishReceived(foldAsOne(first, second)) + }, + rewriteAsWholeFieldUnset: (name: EditorName, mutationNumber: number) => { + const {server, network} = getSetup() + const mutation = getMutation(name, mutationNumber) + network.takeSaveRequest(mutation.id) + publishSaved( + [{name, mutation}], + server.receive( + {id: mutation.id, patches: [unset([])]}, + getEditor(name).host.getTransactionId(mutation.id), + ), + ) + }, + loseReply: (name: EditorName, mutationNumber: number) => { + const mutation = getMutation(name, mutationNumber) + + if (mutation.final) { + throw new Error( + `${name}'s mutation ${mutationNumber} is final: no reply`, + ) + } + + transactionCarrying(mutation.id) + getSetup().network.loseReply({editorId: name, mutationId: mutation.id}) + }, + retry: (name: EditorName, mutationNumber: number) => { + const mutation = getMutation(name, mutationNumber) + getSetup().network.takeLostReply(mutation.id) + getEditor(name).host.retry(mutation.id) + }, + failNextRequest: (status: RequestFailure) => { + getSetup().server.failNextRequest(status) + }, + changeOtherField: () => { + const {server, network} = getSetup() + network.publish( + server.changeOtherField(nameTransaction('the other field')), + ) + }, + setFieldByScript: (textspec: string) => { + const {server, network} = getSetup() + const {value} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + network.publish( + server.setField(value, nameTransaction("the script's change")), + ) + }, + corruptByScript: (key: string, corruption: Corruption) => { + const {server, network} = getSetup() + network.publish( + server.patchField( + corruptionPatches(server.copy().value, key, corruption), + nameTransaction("the script's corruption"), + ), + ) + }, + alterServerCopy: (key: string, corruption: Corruption) => { + const {server} = getSetup() + server.alterStoredCopy( + corruptionPatches(server.copy().value, key, corruption), + ) + }, + deleteDocument: () => { + const {server, network} = getSetup() + network.publish(server.deleteDocument(nameTransaction('the deletion'))) + }, + recreateDocument: (textspec: string) => { + const {server, network} = getSetup() + const {value} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + network.publish(server.recreate(value, nameTransaction('the recreation'))) + }, + deliverMutation: ( + receiverName: EditorName, + senderName: EditorName, + mutationNumber: number, + ) => { + getSetup().network.deliver( + receiverName, + transactionCarrying(getMutation(senderName, mutationNumber).id), + ) + }, + deliverNamed: (receiverName: EditorName, name: NamedTransaction) => { + const {network} = getSetup() + const transaction = network + .getFeed(receiverName) + .find( + (candidate) => + namedTransactions.get(candidate.transactionId) === name, + ) + + if (!transaction) { + throw new Error( + `No transaction for ${name} is waiting for ${receiverName}`, + ) + } + + network.deliver(receiverName, transaction.transactionId) + }, + advanceClock: (milliseconds: number) => { + getSetup().network.clock.advance(milliseconds) + }, + + deliverReply: (name: EditorName, mutationNumber: number) => { + const {network} = getSetup() + const mutation = getMutation(name, mutationNumber) + + if ( + !network.getReplies().some((reply) => reply.mutationId === mutation.id) + ) { + throw new Error( + `No reply is waiting for ${name}'s mutation ${mutationNumber}`, + ) + } + + network.deliverReply(mutation.id) + }, + resync: ( + name: EditorName, + {discardUnsent, outcomeOf}: {discardUnsent: boolean; outcomeOf?: number}, + ) => { + const {document, host, heard} = getEditor(name) + lastResync = { + editorName: name, + warningCount: heard.warnings.length, + screen: document.toTextspec({keys: true}), + mutationCount: heard.mutations.length, + } + host.resync({ + discardUnsent, + ...(outcomeOf === undefined + ? {} + : {outcomeOf: getMutation(name, outcomeOf).id}), + }) + }, + feedLost: (name: EditorName) => { + getEditor(name).host.feedLost() + }, + load: (name: EditorName) => { + getEditor(name).host.load() + }, + /** Loads the editor and records whether the load threw, without throwing. */ + loadAgain: (name: EditorName) => { + const {document, host} = getEditor(name) + const screen = document.toTextspec({keys: true}) + let threw = false + + try { + host.load() + } catch { + threw = true + } + + lastLoad = {editorName: name, threw, screen} + }, + endFirstCommit: (name: EditorName) => { + getEditor(name).document.mount() + }, + + type: (name: EditorName, text: string) => { + getEditor(name).document.type(text) + }, + deleteBeforeCaret: (name: EditorName, text: string) => { + getEditor(name).document.deleteBeforeCaret(text) + }, + putCaretAfter: (name: EditorName, text: string) => { + getEditor(name).document.putCaretAfter(text) + }, + setStyle: (name: EditorName, style: string) => { + getEditor(name).document.setStyle(style) + }, + insertBlock: (name: EditorName, textspec: string) => { + getEditor(name).document.insertBlock(textspec) + }, + deleteBlock: (name: EditorName, text: string) => { + getEditor(name).document.deleteBlock(text) + }, + splitAtCaret: (name: EditorName) => { + getEditor(name).document.splitAtCaret() + }, + undo: (name: EditorName) => { + getEditor(name).io.undo() + }, + becomeReadOnly: (name: EditorName) => { + getEditor(name).document.updateReadOnly(true) + }, + close: (name: EditorName) => { + getEditor(name).document.close() + }, + + getLastResync: () => { + if (!lastResync) { + throw new Error('No resync yet') + } + + return lastResync + }, + getLastLoad: () => { + if (!lastLoad) { + throw new Error('No load yet') + } + + return lastLoad + }, + } +} + +function createWorldEditor({ + name, + server, + network, + hostShape, +}: { + name: EditorName + server: Server + network: Network + hostShape: HostShape +}): WorldEditor { + const {document, io, heard, received, messages, treeMismatches} = + createEditorWithIo({ + id: name === 'Editor A' ? 'A' : 'B', + keyGenerator: createTestKeyGenerator(name === 'Editor A' ? 'a-' : 'b-'), + clock: network.clock, + }) + const mutationsSent: Array = [] + let arrivingVia: 'feed' | 'save reply' = 'feed' + const passThroughHost = createPassThroughHost({ + io: { + ...io, + send: (message) => { + const pathMessage = toPathMessage(message, arrivingVia) + + if (pathMessage) { + messages.push(pathMessage) + } + + if (message.type === 'mutation sent') { + const {type: _type, ...mutationSent} = message + mutationsSent.push(mutationSent) + } + + io.send(message) + }, + }, + save: (mutation) => network.send(name, mutation), + resubmit: (request) => { + const result = server.submit(request.mutations, request.transactionId) + const answer: SaveAnswer = + result.type === 'saved' ? {type: 'saved'} : result + + if (result.type === 'saved') { + network.publish(result.transaction) + } + + messages.push({ + route: 'host to server', + type: 're-submit', + transactionId: request.transactionId, + answer, + }) + + return answer + }, + hasTransaction: server.hasTransaction, + fetchCopy: () => server.copy(), + subscription: () => network.getFeed(name), + foldMutations: hostShape === 'folding', + selfConfirming: hostShape === 'self-confirming', + }) + const host: PassThroughHost = { + ...passThroughHost, + reportSaved: (mutationId, transaction) => { + arrivingVia = 'save reply' + + try { + passThroughHost.reportSaved(mutationId, transaction) + } finally { + arrivingVia = 'feed' + } + }, + } + + network.connect( + name, + { + receiveTransaction: host.forward, + receiveReply: (reply) => + host.reportFailure(reply.mutationId, reply.status), + receiveSaveTaken: host.reportSaveTaken, + }, + {listening: hostShape !== 'self-confirming'}, + ) + return { + document, + io, + host, + heard, + mutationsSent, + received, + messages, + treeMismatches, + checkedMutationCount: 0, + checkedErrorCount: 0, + checkedWarningCount: 0, + checkedWorkDroppedCount: 0, + } +} + +/** + * A fake document with the protocol's editor side attached, both minting + * keys from the same generator, and what their listeners heard. The document + * is listened to before the editor side attaches, so a change is heard + * before the mutation it leads to. `received` records every message io sends + * the document, `messages` those and every mutation io emits, in order, and + * `treeMismatches` every moment, right after one of those + * messages or a local change io has booked, that the document's tree + * differs from io's working copy. + */ +export function createEditorWithIo({ + id, + keyGenerator, + clock, +}: { + id: string + keyGenerator: () => string + clock: Clock +}): { + document: FakeDocument + io: ModelUndoIo + heard: Heard + received: Array + messages: Array + treeMismatches: Array> +} { + const heard: Heard = { + mutations: [], + changes: [], + errors: [], + warnings: [], + workDropped: [], + events: [], + } + const document = createFakeDocument({keyGenerator}, {value: undefined}) + + document.on('change', (event) => { + const {type: _type, ...change} = event + heard.changes.push(change) + heard.events.push({ + type: 'change', + origin: change.origin, + patchCount: change.operations.length, + }) + }) + + const received: Array = [] + const messages: Array = [] + const treeMismatches: Array> = [] + const recordTreeMismatch = (after: TreeMismatch['after']) => { + const mismatch = findTreeMismatch({document, io}, after) + + if (mismatch) { + treeMismatches.push(mismatch) + } + } + const plainIo = createIo({ + id, + editor: { + on: document.on, + send: (message) => { + received.push(message) + messages.push({route: 'io to editor', ...message}) + document.send(message) + recordTreeMismatch(message.type) + }, + }, + keyGenerator, + transactionIdGenerator: createTestKeyGenerator(`${id}-t`), + clock, + }) + const io = withModelUndo(plainIo, document.applyLocalEdit) + + document.on('change', (event) => { + if (event.origin === 'local') { + recordTreeMismatch('local change') + } + }) + + io.on('*', (event) => { + switch (event.type) { + case 'mutation': { + const {type: _type, ...mutation} = event + heard.mutations.push(mutation) + messages.push({route: 'io to host', type: 'mutation', ...mutation}) + break + } + case 'error': { + const {type: _type, ...error} = event + heard.errors.push(error) + heard.events.push({type: 'error', ...error}) + break + } + case 'work dropped': { + const {type: _type, ...workDropped} = event + heard.workDropped.push(workDropped) + heard.events.push({ + type: 'work dropped', + patchCount: workDropped.patches.length, + ...workDropped, + }) + break + } + case 'warning': + heard.warnings.push(event.message) + heard.events.push({type: 'warning', message: event.message}) + break + } + }) + + return {document, io, heard, received, messages, treeMismatches} +} + +/** + * The editor's tree and io's working copy, when they differ. No field and an + * empty list are the same tree, the placeholder: one empty text block with + * the key the document reports for it. Anything else the document shows + * while it reports a placeholder is compared as content. An editor that has + * closed has no tree to compare. + */ +function findTreeMismatch( + {document, io}: {document: FakeDocument; io: Io}, + after: TreeMismatch['after'], +): Omit | undefined { + if (document.getStatus() === 'unmounted') { + return undefined + } + + const value = document.getValue() + const placeholderKey = document.getPlaceholderKey() + const tree = + placeholderKey !== undefined && isPlaceholder(value, placeholderKey) + ? [] + : value + const workingCopy = getIoInternals(io).getWorkingCopy() ?? [] + + return isEqual(tree, workingCopy) + ? undefined + : { + after, + tree: formatTextspec(tree, {keys: true}), + workingCopy: formatTextspec(workingCopy, {keys: true}), + } +} + +function isPlaceholder( + value: Array, + placeholderKey: string, +): boolean { + const [block, ...rest] = value + const children: unknown = block ? Reflect.get(block, 'children') : undefined + const [child]: Array = Array.isArray(children) ? children : [] + + return ( + rest.length === 0 && + block?._key === placeholderKey && + block._type === 'block' && + Array.isArray(children) && + children.length === 1 && + typeof child === 'object' && + child !== null && + Reflect.get(child, '_type') === 'span' && + Reflect.get(child, 'text') === '' + ) +} + +const namedTransactionPrefixes: Record = { + 'the other field': 'other-field', + "the script's change": 'script', + "the script's corruption": 'corruption', + 'the deletion': 'deletion', + 'the recreation': 'recreation', +} + +/** + * Stored content as one line of textspec, with each block textspec can't + * spell, below the floor, written as its JSON instead. + */ +function formatStoredTextspec(value: Array): string { + return value + .map((block) => { + try { + return formatTextspec([block]) + } catch { + return JSON.stringify(block) + } + }) + .join(';;') +} + +/** The patches a script sends to corrupt the block with the key. */ +function corruptionPatches( + value: Array | undefined, + key: string, + corruption: Corruption, +): Array { + const block = value?.find((candidate) => candidate._key === key) + const path = [{_key: key}] + + if (!block) { + throw new Error(`The server has no block "${key}"`) + } + + switch (corruption.type) { + case 'no key': + return [unset([...path, '_key'])] + case 'no type': + return [unset([...path, '_type'])] + case 'children': + return [set(corruption.children, [...path, 'children'])] + case 'span text': { + const children: unknown = Reflect.get(block, 'children') + const [span]: Array = Array.isArray(children) ? children : [] + const spanKey: unknown = + typeof span === 'object' && span !== null + ? Reflect.get(span, '_key') + : undefined + + if (typeof spanKey !== 'string') { + throw new Error(`The server's block "${key}" has no keyed span`) + } + + return [ + set(corruption.text, [...path, 'children', {_key: spanKey}, 'text']), + ] + } + case 'string': + return [set(corruption.value, path)] + } +} + +/** A host message as the editor's path records it. */ +function toPathMessage( + message: IoMessage, + via: 'feed' | 'save reply', +): PathMessage | undefined { + switch (message.type) { + case 'transaction': + return {route: 'host to io', via, ...message} + case 'close': + return undefined + default: + return {route: 'host to io', ...message} + } +} + +function toEditorName(editorId: string): EditorName { + if (editorId === 'Editor A' || editorId === 'Editor B') { + return editorId + } + + throw new Error(`No editor "${editorId}"`) +} diff --git a/packages/io/src/test/scenarios.test.ts b/packages/io/src/test/scenarios.test.ts new file mode 100644 index 0000000000..dc342848a0 --- /dev/null +++ b/packages/io/src/test/scenarios.test.ts @@ -0,0 +1,49 @@ +import {Before} from 'racejar' +import {Feature} from 'racejar/vitest' +import {describe} from 'vitest' +import concurrentEditsFeature from '../../gherkin-spec/concurrent-edits.feature?raw' +import keysFeature from '../../gherkin-spec/keys.feature?raw' +import lifecycleFeature from '../../gherkin-spec/lifecycle.feature?raw' +import listenersFeature from '../../gherkin-spec/listeners.feature?raw' +import loadingAndEmptyFeature from '../../gherkin-spec/loading-and-empty.feature?raw' +import malformedContentFeature from '../../gherkin-spec/malformed-content.feature?raw' +import otherEditorsFeature from '../../gherkin-spec/other-editors.feature?raw' +import outOfStepAndResyncFeature from '../../gherkin-spec/out-of-step-and-resync.feature?raw' +import sendingAndConfirmingFeature from '../../gherkin-spec/sending-and-confirming.feature?raw' +import {parameterTypes} from '../scenario/parameter-types' +import {stepDefinitions, type Context} from '../scenario/steps' +import {createWorld} from '../scenario/world' + +const features = [ + concurrentEditsFeature, + keysFeature, + lifecycleFeature, + listenersFeature, + loadingAndEmptyFeature, + malformedContentFeature, + otherEditorsFeature, + outOfStepAndResyncFeature, + sendingAndConfirmingFeature, +] + +const modes = [ + {name: 'transactions with patches only', serverCopyOnTransactions: false}, + {name: "transactions with the server's copy", serverCopyOnTransactions: true}, +] + +for (const {name, serverCopyOnTransactions} of modes) { + describe(name, () => { + for (const featureText of features) { + Feature({ + featureText, + hooks: [ + Before((context: Context) => { + context.world = createWorld({serverCopyOnTransactions}) + }), + ], + stepDefinitions, + parameterTypes, + }) + } + }) +} diff --git a/packages/io/src/testing.ts b/packages/io/src/testing.ts new file mode 100644 index 0000000000..93f536bbda --- /dev/null +++ b/packages/io/src/testing.ts @@ -0,0 +1,60 @@ +export type {RequestFailure} from './protocol/host' +export type {Mutation} from './protocol/types' +export {createFakeDocument, formatTextspec} from './fakes/document' +export type {Caret, FakeDocument, FakeDocumentStatus} from './fakes/document' +export {createFakeServer} from './fakes/server' +export type { + SavedMutation, + Server, + ServerCopy, + ServerTransaction, + SubmitResult, +} from './fakes/server' +export {createFakeNetwork} from './fakes/network' +export type { + CarriedTransaction, + FailureReply, + Network, + NetworkReceiver, + Reply, + SaveRequest, + VirtualClock, +} from './fakes/network' + +export { + createEditorWithIo, + createWorld, + editorNames, + heldTransactionTimeout, +} from './scenario/world' +export type { + MutationSnapshot, + Corruption, + EditorName, + EditorSnapshot, + Heard, + HeardEvent, + HostShape, + NamedTransaction, + NetworkSnapshot, + PathMessage, + ServerCopyName, + ServerSnapshot, + TransactionSource, + TreeMismatch, + World, + WorldEditor, + WorldSnapshot, +} from './scenario/world' +export {withModelUndo} from './scenario/model-undo' +export type {ModelUndoIo} from './scenario/model-undo' +export {stepDefinitions} from './scenario/steps' +export type {Context} from './scenario/steps' +export {parameterTypes} from './scenario/parameter-types' +export type {MutationReference} from './scenario/parameter-types' +export {compileScenarios} from './scenario/compile' +export type { + CompiledScenario, + CompiledScenarios, + CompiledStep, +} from './scenario/compile' diff --git a/packages/io/tsconfig.json b/packages/io/tsconfig.json new file mode 100644 index 0000000000..02310f7e97 --- /dev/null +++ b/packages/io/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@sanity/tsconfig/strictest", + "compilerOptions": { + "rootDir": ".", + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/io/vitest.config.ts b/packages/io/vitest.config.ts new file mode 100644 index 0000000000..8c87c3560c --- /dev/null +++ b/packages/io/vitest.config.ts @@ -0,0 +1,15 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }, + ], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c202d290ac..af85763c1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,43 @@ importers: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' + apps/io-playground: + dependencies: + '@portabletext/io': + specifier: workspace:^ + version: link:../../packages/io + react: + specifier: 'catalog:' + version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) + devDependencies: + '@tailwindcss/vite': + specifier: catalog:tooling + version: 4.3.3(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: catalog:tooling + version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.145.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + tailwindcss: + specifier: catalog:tooling + version: 4.3.3 + typescript: + specifier: catalog:tooling + version: 7.0.2 + vite: + specifier: catalog:tooling + version: 8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1) + vitest: + specifier: catalog:tooling + version: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + apps/playground: dependencies: '@floating-ui/react-dom': @@ -729,6 +766,46 @@ importers: specifier: catalog:tooling version: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@20.19.25)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + packages/io: + dependencies: + '@cucumber/gherkin': + specifier: ^41.0.0 + version: 41.0.0 + '@cucumber/messages': + specifier: ^34.0.1 + version: 34.0.1 + '@portabletext/patches': + specifier: workspace:* + version: link:../patches + '@portabletext/schema': + specifier: workspace:^ + version: link:../schema + '@portabletext/test': + specifier: workspace:^ + version: link:../test + '@sanity/diff-match-patch': + specifier: 'catalog:' + version: 3.2.1 + '@textspec/notation': + specifier: ^1.0.2 + version: 1.0.2 + racejar: + specifier: workspace:^ + version: link:../racejar + devDependencies: + '@sanity/tsconfig': + specifier: catalog:tooling + version: 2.1.0 + typescript: + specifier: catalog:tooling + version: 7.0.2 + vite: + specifier: catalog:tooling + version: 8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1) + vitest: + specifier: catalog:tooling + version: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + packages/keyboard-shortcuts: devDependencies: '@sanity/pkg-utils': @@ -22815,7 +22892,7 @@ snapshots: dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + vitest: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@20.19.25)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17)