Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
9294d80
chore(io): scaffold the private protocol model package with the scena…
christianhg Sep 29, 2026
a21c895
feat(io): model the document as PTE-shaped patches with textspec in a…
christianhg Sep 29, 2026
817b1a7
feat(io): fake Content Lake and network with explicit queues and a vi…
christianhg Sep 29, 2026
c7013cb
feat(io): implement the editor's side of the protocol and the pass-th…
christianhg Sep 29, 2026
8ce1144
test(io): run the 24 protocol scenarios against the model
christianhg Sep 29, 2026
46a4b70
fix(io): derive undo from the current document and make the host keep…
christianhg Sep 29, 2026
5127220
test(io): pin the holes the mutants found
christianhg Sep 29, 2026
8d3776f
fix(io): undo puts back what the base held before the editor's own ch…
christianhg Sep 29, 2026
f5ba287
docs(io): describe what the model proves, what is fake, and how to ru…
christianhg Sep 29, 2026
5cc034e
refactor(io): expose the world, the steps and a scenario compiler
christianhg Sep 29, 2026
dc7a591
feat(io-playground): play the protocol scenarios step by step in a br…
christianhg Sep 29, 2026
a7a9cda
feat(io-playground): show waiting feed items per editor and deliver t…
christianhg Sep 29, 2026
e1834d0
refactor(io): carry blocks and patches in the world snapshot
christianhg Sep 29, 2026
708fbb6
feat(io-playground): show the message path, open every value and patc…
christianhg Sep 29, 2026
9e9a03c
fix(io-playground): keep the scenario controls in view while the step…
christianhg Sep 29, 2026
4999c24
fix(io-playground): put the narration beside the steps, each scrollin…
christianhg Sep 29, 2026
0f3696b
test(io): pin four concurrent-edit loss classes, two of them known red
christianhg Sep 29, 2026
98ed60e
feat(io-playground): list known-red scenarios and say what they expec…
christianhg Sep 29, 2026
b46c885
refactor(io): drop `mutation accepted`, the host reports sent and rej…
christianhg Sep 29, 2026
fa49c29
fix(io-playground): keep the free-play controls in place while the lo…
christianhg Sep 29, 2026
1d414cb
fix(io): undo a style set on the placeholder, and survive a re-keyed …
christianhg Sep 29, 2026
976e557
feat(io-playground): offer only the actions the protocol allows, and …
christianhg Sep 29, 2026
7ce9d65
fix(io): send no final batch while sending is blocked by a rejection
christianhg Sep 29, 2026
e237aba
fix(io): a resync always keeps the unsent changes, discarding is the …
christianhg Sep 29, 2026
9439ebe
test(io): two editors filling an empty field at once end with two blocks
christianhg Sep 29, 2026
38a8202
feat(io): feed loss, retries by transaction ID, echo scope check, wor…
christianhg Sep 29, 2026
24bc334
feat(io): propose the transaction ID in `mutation`, and make `mutatio…
christianhg Oct 1, 2026
b65a2d6
feat(io): give a local `change` the patches of its batch, and a remot…
christianhg Oct 1, 2026
88c4d8e
fix(io): rejoin a batch not applied to pending, and report the reject…
christianhg Oct 1, 2026
ec25996
feat(io): end the first commit at `mount`, with no load claim
christianhg Oct 1, 2026
e90f743
feat(io): find the outcome of the batch in flight by re-submitting it
christianhg Oct 1, 2026
3c16653
feat(io): add a self-confirming host, and pin the quiet document and …
christianhg Oct 1, 2026
2a2faa7
fix(io-playground): tie the event drawer to the world it was opened in
christianhg Oct 1, 2026
447f2aa
refactor(io): give the fake document the editor's `on` and `send`
christianhg Oct 1, 2026
6a8c388
refactor(io): speak to the editor through `on` and `send` only
christianhg Oct 1, 2026
889ba39
refactor(io): split the package into protocol, fakes and a testing entry
christianhg Oct 1, 2026
1070128
docs(io): describe the editor seam and the package layout
christianhg Oct 1, 2026
2dc0fcd
fix(io): freeze the folding host's save request when it is formed
christianhg Oct 1, 2026
f22d43d
refactor(io): give `EditorForIo` the editor's `on` and drop the local…
christianhg Oct 1, 2026
38aac56
fix(io): send `underneath` for a transaction that leaves the screen a…
christianhg Oct 1, 2026
07348b6
fix(io): record where typing happened and undo it there
christianhg Oct 1, 2026
8078b48
refactor(io): inject request failures into the fake server by status
christianhg Oct 1, 2026
4d6b998
fix(io): hear the feed again after reconnecting with a listener
christianhg Oct 1, 2026
af5c812
test(io): load in a `When` step and check the refusal in a `Then`
christianhg Oct 1, 2026
74d5ab9
fix(io): pass on no answer for the final batch from a self-confirming…
christianhg Oct 1, 2026
938a326
fix(io): replace a primitive on `set` through it, as Content Lake does
christianhg Oct 1, 2026
10aaa91
docs(io): list what the model leaves out, and what the real editor st…
christianhg Oct 1, 2026
fcd2385
test(io): assert complete values instead of counts and slices
christianhg Oct 1, 2026
6cfd0f9
test(io): check after every step that each editor's tree equals io's …
christianhg Oct 1, 2026
7ad5637
test(io): apply keyed patches in the fake document and move the caret…
christianhg Oct 1, 2026
5a2c12b
feat(io): author `apply` as keyed instructions instead of a whole-val…
christianhg Oct 1, 2026
d800740
test(io): pin keyed `apply` in scenarios, and keep the caret with its…
christianhg Oct 1, 2026
7b9f789
feat(io): mint repair keys from the revision and the path
christianhg Oct 1, 2026
65fc88a
feat(io): put the editor out of step when a transaction takes a pendi…
christianhg Oct 1, 2026
3b6aad1
feat(io): repair received content to the floor, and refuse a transact…
christianhg Oct 1, 2026
67aaa77
feat(io): take the base from `transaction.value` when a transaction c…
christianhg Oct 1, 2026
cff9c08
fix(io): run a synchronous step's tree check synchronously
christianhg Oct 1, 2026
862cc7d
feat(io): record each editor's message path in the world
christianhg Oct 1, 2026
69783f5
feat(io-playground): offer the host presets by the hosts they stand f…
christianhg Oct 1, 2026
8efa4da
feat(io-playground): let the feed die in free play, and recover by sa…
christianhg Oct 1, 2026
fdc606b
feat(io-playground): show each `work dropped` as a notice with the dr…
christianhg Oct 1, 2026
214c0c5
feat(io-playground): corrupt the stored copy in free play, and reach …
christianhg Oct 1, 2026
7f805e7
feat(io-playground): start free play in the first commit, and show wh…
christianhg Oct 1, 2026
5371fc7
feat(io-playground): let the listener send the document with each tra…
christianhg Oct 1, 2026
30c7652
feat(io-playground): pick up every feature file in the package's `ghe…
christianhg Oct 1, 2026
5f2af2c
docs(io-playground): describe the message path and what free play offers
christianhg Oct 1, 2026
9063159
fix(io): send no batch while out of step
christianhg Oct 1, 2026
be23382
fix(io): decide `apply` instructions per list, not per patch
christianhg Oct 1, 2026
3c89cb6
fix(io): address a remote patch by index to the editor's block
christianhg Oct 1, 2026
9c188d9
fix(io): keep the fake document's caret through a block `set` and an …
christianhg Oct 1, 2026
c28f788
fix(io): check the floor and key collisions for children too
christianhg Oct 1, 2026
bf8f904
fix(io): check pending keys against the base a transaction selects
christianhg Oct 1, 2026
8525d4d
fix(io): keep the objects of a text block's mixed `children` on repair
christianhg Oct 1, 2026
53bebe7
fix(io): keep stored non-object blocks when a local edit empties the …
christianhg Oct 1, 2026
fd3777e
test(io): check the placeholder's shape before the tree check skips it
christianhg Oct 1, 2026
e50db02
fix(io-playground): reload a fresh document from the server, and capt…
christianhg Oct 1, 2026
3af96cb
test(io): name scenario-shaped tests as scenarios and assert whole pa…
christianhg Oct 1, 2026
c40d9a2
feat(io): give io the editor's store shape
christianhg Oct 2, 2026
e3886cc
refactor(io): call the unit io sends a mutation, not a batch
christianhg Oct 2, 2026
ff8d0bd
feat(io): propose transaction IDs from an injected generator
christianhg Oct 2, 2026
d1e4668
refactor(io): move the model's undo out of io into `withModelUndo`
christianhg Oct 2, 2026
ab3ee2a
refactor(io): extract the `apply` authoring into `apply.ts`
christianhg Oct 2, 2026
c3d2edb
fix(io): report the editor's own patches its echo saved as no-ops
christianhg Oct 2, 2026
dbfdbbd
test(io): pin where typing lands when another editor splits its block…
christianhg Oct 2, 2026
6a91f69
refactor(io-playground): decide what can happen from io's own snapshot
christianhg Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/io-playground/README.md
Original file line number Diff line number Diff line change
@@ -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
```
12 changes: 12 additions & 0 deletions apps/io-playground/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>I/O protocol playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions apps/io-playground/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
234 changes: 234 additions & 0 deletions apps/io-playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<Tab>('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<string>
} | null>(null)
const selectedMutationIds =
selection?.world === world ? selection.mutationIds : []
const [dismissedWork, setDismissedWork] = useState<{
world: World
byEditor: Record<EditorName, Array<number>>
} | 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 (
<OpenDetailsProvider
value={(selection) => setDrawer({type: 'details', selection})}
>
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-gray-200 bg-white px-4 py-1.5">
<h1 className="text-sm font-semibold">I/O protocol playground</h1>
<p className="flex-1 text-xs text-gray-500">
Two editors save to one server through a link each. Click any value,
mutation or transaction to look inside it.
</p>
<Button
onClick={() =>
setDrawer((current) =>
current?.type === 'concepts' ? null : {type: 'concepts'},
)
}
>
Concepts
</Button>
</header>

<main className="grid min-h-0 flex-1 grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,1.25fr)] gap-2 p-2">
<EditorPanel
name="Editor A"
editor={snapshot.editors?.['Editor A']}
savingFor={
tab === 'free play' ? freePlay.savingFor('Editor A') : undefined
}
dismissedWork={dismissedFor('Editor A')}
onDismissWork={(index) => dismissWork('Editor A', index)}
waitingCount={snapshot.network?.feeds['Editor A'].length ?? 0}
prompts={promptsFor('Editor A')}
/>
<LinkPanel
name="Editor A"
editorSide="left"
editor={snapshot.editors?.['Editor A']}
network={snapshot.network}
applicability={network.links['Editor A']}
onStep={onStep}
onDeliver={onDeliver}
selectedMutationIds={selectedMutationIds}
onToggleSelected={toggleSelected}
deadFeed={deadFeeds.includes('Editor A')}
/>
<ServerPanel
server={snapshot.server}
now={snapshot.network?.now}
advanceClock={network.advanceClock}
onStep={onStep}
selectedRequests={selectedRequests}
onReceiveSelected={receiveSelected}
editorA={snapshot.editors?.['Editor A']}
editorADeadFeed={deadFeeds.includes('Editor A')}
carriesServerCopy={
snapshot.network?.carriesServerCopy ??
(tab === 'free play' && freePlay.setup.transactionsCarryCopy)
}
onToggleServerCopy={
tab === 'free play'
? freePlay.setTransactionsCarryCopy
: undefined
}
/>
<LinkPanel
name="Editor B"
editorSide="right"
editor={snapshot.editors?.['Editor B']}
network={snapshot.network}
applicability={network.links['Editor B']}
onStep={onStep}
onDeliver={onDeliver}
selectedMutationIds={selectedMutationIds}
onToggleSelected={toggleSelected}
deadFeed={deadFeeds.includes('Editor B')}
/>
<EditorPanel
name="Editor B"
editor={snapshot.editors?.['Editor B']}
savingFor={
tab === 'free play' ? freePlay.savingFor('Editor B') : undefined
}
dismissedWork={dismissedFor('Editor B')}
onDismissWork={(index) => dismissWork('Editor B', index)}
waitingCount={snapshot.network?.feeds['Editor B'].length ?? 0}
prompts={promptsFor('Editor B')}
/>
</main>

<footer className="flex h-[40vh] flex-col border-t border-gray-300 bg-white">
<nav className="flex gap-1 border-b border-gray-200 px-4 pt-2">
{(['scenarios', 'free play'] as const).map((candidate) => (
<button
key={candidate}
type="button"
onClick={() => setTab(candidate)}
className={`rounded-t px-3 py-1 text-sm capitalize ${
candidate === tab
? 'border border-b-0 border-gray-300 bg-gray-50 font-semibold'
: 'text-gray-500 hover:text-gray-800'
}`}
>
{candidate}
</button>
))}
</nav>
<div className="min-h-0 flex-1 overflow-hidden p-4">
{tab === 'scenarios' ? (
<ScenariosTab runner={scenarioRunner} />
) : (
<FreePlayTab freePlay={freePlay} />
)}
</div>
</footer>
</div>

{drawer ? (
<DrawerView
drawer={drawer}
snapshot={snapshot}
onClose={() => setDrawer(null)}
/>
) : null}
</OpenDetailsProvider>
)
}
Loading
Loading