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})}
+ >
+
+
+
+
+ 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')}
+ />
+
+
+
+
+
+ {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 (
+
+ )
+}
+
+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' ? (
+
+
+ first commit
+
+
+ ) : null}
+
+
+ {editor.io.sync}
+
+
+ {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}
+
+ {hostPresetOf(editor.host).label}
+
+ >
+ ) : 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 revision {' '}
+
+ >
+ )
+ }
+ >
+ {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} {' '}
+ {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 (
+
+
+ {label}:
+
+ {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),
+ }}
+ />
+ ))}
+
+
+
+
+ {
+ const next = {
+ ...setup,
+ hosts:
+ hostPresets.find(
+ (preset) => preset.shape === event.target.value,
+ )?.shape ?? 'plain',
+ }
+ setSetup(next)
+ freePlay.resetWith(next)
+ }}
+ >
+ {hostPresets.map((preset) => (
+
+ {preset.label}
+
+ ))}
+
+
+ setSetup({
+ ...setup,
+ mode:
+ setupModes.find((mode) => mode === event.target.value) ??
+ 'the document is',
+ })
+ }
+ >
+ {setupModes.map((mode) => (
+
+ {mode}
+
+ ))}
+
+ {setup.mode === 'the document is' ? null : (
+
+ setSetup({
+ ...setup,
+ serverCopy:
+ serverCopies.find(
+ (copy) => copy === event.target.value,
+ ) ?? 'textspec',
+ })
+ }
+ >
+ {serverCopies.map((copy) => (
+
+ {copy === 'textspec' ? 'the server has' : copy}
+
+ ))}
+
+ )}
+ {setup.mode === 'the document is' ||
+ setup.serverCopy === 'textspec' ? (
+ setSetup({...setup, textspec})}
+ width="w-40"
+ />
+ ) : null}
+ reset
+
+
+
+
+
+
+ capture checks
+ {
+ void navigator.clipboard.writeText(scenarioText).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ })
+ }}
+ >
+ {copied ? 'copied' : 'copy as scenario'}
+
+
+ {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
+
+
setStyle(event.target.value)}
+ >
+ {styles.map((candidate) => (
+
+ {candidate}
+
+ ))}
+
+
+
+
+ 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:
+
+ say feed lost
+
+
+ do nothing
+
+ >
+ ) : (
+
+ 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 ? (
+ <>
+
+ save requests {' '}
+ {towardServer} Server
+ >
+ }
+ >
+ {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's next request fails with ${failure}`,
+ )
+ ) {
+ onStep(
+ request.final
+ ? `the server receives ${name}'s final mutation`
+ : `the server receives ${name}'s mutation ${request.mutationNumber}`,
+ )
+ }
+ }}
+ className="text-xs text-red-700 underline hover:text-red-900"
+ >
+ fail ({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' ? (
+
+ {towardEditor} {name}
+
+ ) : null}
+ failed saves
+ {listening ? (
+ <>
+ {' '}
+ and the feed
+ >
+ ) : null}
+ {deadFeed ? feed dead : null}
+ {editorSide === 'right' ? (
+
+ {towardEditor} {name}
+
+ ) : 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}
+
{
+ for (const item of feed) {
+ if (!onDeliver(deliveryStep(name, item.source))) {
+ break
+ }
+ }
+ }}
+ >
+ deliver all
+
+
+ ) : 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) => (
+
+
+ {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}
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+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 (
+
+
+ {
+ const [featureIndex, scenarioIndex] = event.target.value
+ .split(':')
+ .map(Number)
+ runner.select({featureIndex, scenarioIndex})
+ }}
+ >
+ {features.map((feature, featureIndex) => (
+
+ {feature.scenarios.map((candidate, scenarioIndex) =>
+ candidate.knownRed === undefined ? (
+
+ {candidate.name}
+
+ ) : null,
+ )}
+
+ ))}
+ {knownRedScenarios.length > 0 ? (
+
+ {knownRedScenarios.map(({featureIndex, scenarioIndex, name}) => (
+
+ {name}
+
+ ))}
+
+ ) : null}
+
+
+ next step
+
+
+ run all
+
+
+ reset
+
+ {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 ? (
+
+ revision {' '}
+
+
+ ) : null}
+ {server && server.nextFailure !== null ? (
+
+ next request fails with {server.nextFailure}
+
+ ) : null}
+
+
+
+ onToggleServerCopy?.(event.target.checked)}
+ />
+ listener sends the document with each transaction
+
+
+
+ {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 ? (
+
+
+
+ onStep(
+ 'another field of the document is changed on the server',
+ )
+ }
+ >
+ other field
+
+ onStep('the document is deleted')}>
+ delete
+
+
+ onStep(
+ `the document is recreated as ${quoted(recreatedAs)}`,
+ )
+ }
+ >
+ recreate
+
+
+
+
+
+ onStep('the wait for the missing transaction runs out')
+ }
+ suggested={advanceClock.suggested !== undefined}
+ title={
+ advanceClock.suggested ??
+ 'When the wait for the missing transaction runs out'
+ }
+ >
+ advance 10 s
+
+
+ t = {(now ?? 0) / 1000} s
+
+
+
+
+ onStep(`a script sets the field to ${quoted(scriptValue)}`)
+ }
+ >
+ a script sets the field
+
+
+
+
+
+ receive the two selected as one
+
+
+
+ ) : null}
+
+ {onStep ? (
+ · corrupt the stored copy, then pick a door>}
+ >
+
+ block
+ setChosenKey(event.target.value)}
+ >
+ {keys.map((key) => (
+
+ {key}
+
+ ))}
+
+ setCorruption(event.target.value)}
+ >
+ {floorRules.map((rule) => (
+
+ {rule.label}
+
+ ))}
+
+
+
+
{
+ 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 (
+ <>
+ {
+ const rect = event.currentTarget.getBoundingClientRect()
+ setPosition((current) =>
+ current === null
+ ? {
+ left: Math.min(rect.left, window.innerWidth - 272),
+ top: rect.bottom + 4,
+ }
+ : null,
+ )
+ }}
+ onBlur={() => setPosition(null)}
+ className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full border border-gray-400 font-serif text-[9px] leading-none font-normal text-gray-500 italic hover:border-blue-500 hover:text-blue-600"
+ >
+ i
+
+ {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 (
+
+ setOpen((current) => !current)}
+ className={`self-start rounded bg-white px-1.5 py-0.5 text-left font-mono text-sm break-all ring-1 hover:ring-blue-400 ${
+ open ? 'ring-blue-400' : 'ring-gray-200'
+ }`}
+ >
+ {textspec === '' ? 'an empty list' : textspec}
+
+ {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
+}
+
+/** 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 (
+
+ {children}
+
+ )
+}
+
+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 (
+
+ {children}
+
+ )
+}
+
+/** A button whose state comes from the protocol's applicability rules. */
+export function ActionButton({
+ applicability,
+ onClick,
+ children,
+}: {
+ applicability: Applicability
+ onClick: () => void
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** 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 : (
+ {
+ void navigator.clipboard.writeText(text).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ })
+ }}
+ >
+ {copied ? 'copied' : 'copy the text'}
+
+ )}
+ dismiss
+
+
+ )
+}
+
+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