From 9294d80a7c79f85ec4ea98715ea32909102285c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 10:14:40 +0200 Subject: [PATCH 01/85] chore(io): scaffold the private protocol model package with the scenarios `@portabletext/io` is where the v9 host contract gets an implementation before it lands in the editor: the editor's side of the protocol and the host adapter written for real, with a fake document, server and network around them. The package is private and runs unit tests only (a single vitest `unit` project on node). The 24 Gherkin scenarios in `gherkin-spec/` are the suite. They are the executable companion to the protocol spec: two editors, one server, and what each editor shows and sends, with every state in textspec notation. `scenarios.test.ts` proves the racejar wiring with one inline feature for now; the real runner arrives with the step definitions. --- packages/io/gherkin-spec/keys.feature | 64 ++++++++++ packages/io/gherkin-spec/lifecycle.feature | 34 +++++ packages/io/gherkin-spec/listeners.feature | 17 +++ .../io/gherkin-spec/loading-and-empty.feature | 58 +++++++++ .../io/gherkin-spec/other-editors.feature | 97 +++++++++++++++ .../out-of-step-and-resync.feature | 116 ++++++++++++++++++ .../sending-and-confirming.feature | 71 +++++++++++ packages/io/package.json | 27 ++++ packages/io/src/global.d.ts | 4 + packages/io/src/test/scenarios.test.ts | 23 ++++ packages/io/tsconfig.json | 10 ++ packages/io/vitest.config.ts | 15 +++ pnpm-lock.yaml | 18 +++ 13 files changed, 554 insertions(+) create mode 100644 packages/io/gherkin-spec/keys.feature create mode 100644 packages/io/gherkin-spec/lifecycle.feature create mode 100644 packages/io/gherkin-spec/listeners.feature create mode 100644 packages/io/gherkin-spec/loading-and-empty.feature create mode 100644 packages/io/gherkin-spec/other-editors.feature create mode 100644 packages/io/gherkin-spec/out-of-step-and-resync.feature create mode 100644 packages/io/gherkin-spec/sending-and-confirming.feature create mode 100644 packages/io/package.json create mode 100644 packages/io/src/global.d.ts create mode 100644 packages/io/src/test/scenarios.test.ts create mode 100644 packages/io/tsconfig.json create mode 100644 packages/io/vitest.config.ts diff --git a/packages/io/gherkin-spec/keys.feature b/packages/io/gherkin-spec/keys.feature new file mode 100644 index 0000000000..e399410da5 --- /dev/null +++ b/packages/io/gherkin-spec/keys.feature @@ -0,0 +1,64 @@ +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 batch 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "B: foo;;B _key="k9": bar" + When Editor A receives Editor B's batch 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 batch 1 + Then the server has "B: foo;;B _key="k9": baz;;B _key="k9": bar" + When Editor A's batch 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 batch 2 + When the server receives Editor A's batch 2 + Then every block on the server has a unique key + + Scenario: An unsent insert that would reuse a key another editor used gets a new key + Given the document is "B: foo|" + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent batch 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "B: foo;;B _key="k9": bar" + When Editor A receives Editor B's batch 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 + When the server receives Editor A's batch 1 + Then the server has "B: foox;;B _key="k9": bar" + When Editor A's batch 1 comes back + Then Editor A has sent batch 2 + When the server receives Editor A's batch 2 + Then the server has "B: foox;;B: baz;;B _key="k9": bar" + And every block on the server has a unique key + + Scenario: Content received without keys is repaired, and the repair goes out in the next batch + Given the server has "B: foo" + And the server's block has no key + And an editor that claims the first load + When Editor A is loaded + Then Editor A shows "B: foo" + And every block in Editor A has a unique key + And Editor A has sent batch 1 + When the server receives Editor A's batch 1 + Then the server has "B: foo" + And every block on the server has a unique key + When Editor A's batch 1 comes back + Then Editor A has sent nothing new diff --git a/packages/io/gherkin-spec/lifecycle.feature b/packages/io/gherkin-spec/lifecycle.feature new file mode 100644 index 0000000000..62aa209ead --- /dev/null +++ b/packages/io/gherkin-spec/lifecycle.feature @@ -0,0 +1,34 @@ +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 batch 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 batch 1 + Then the server has "B: foox" + When Editor A's batch 1 comes back + Then Editor A has sent batch 2 + When the server receives Editor A's batch 2 + Then the server has "B: fooxy" + + Scenario: Changes made just before the editor closes go out as a final batch, even with a batch still out + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent batch 1 + When "y" is typed + Then Editor A has sent nothing new + When Editor A is closed + Then Editor A has sent a final batch + When the server receives Editor A's batch 1 + Then the server has "B: foox" + When the server receives Editor A's final batch + Then the server has "B: fooxy" diff --git a/packages/io/gherkin-spec/listeners.feature b/packages/io/gherkin-spec/listeners.feature new file mode 100644 index 0000000000..b771b4791e --- /dev/null +++ b/packages/io/gherkin-spec/listeners.feature @@ -0,0 +1,17 @@ +Feature: Listeners + + Scenario: Listeners hear one change per user action and per received change, none for the first load or their own echoes + 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 batch 1 + When the server receives Editor A's batch 1 + And Editor A's batch 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 batch 1 + When the server receives Editor B's batch 1 + And Editor A receives Editor B's batch 1 + Then Editor A shows "H1: foox|" + And Editor A has emitted 2 changes 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..e7e1908162 --- /dev/null +++ b/packages/io/gherkin-spec/loading-and-empty.feature @@ -0,0 +1,58 @@ +Feature: Loading and empty + + Scenario: An editor that doesn't claim the first load starts ready and empty, and a resync fills it + Given the server has "B: foo" + And an editor that doesn't claim the first load + Then Editor A's status is "ready" + And Editor A shows "B: |" + When Editor A is resynced + Then Editor A shows "B: foo" + + Scenario: An editor that claims the first load waits for it + Given the server has "B: foo" + And an editor that claims the first load + Then Editor A's status is "loading" + When Editor A is loaded + Then Editor A's status is "ready" + And Editor A shows "B: foo" + And Editor A has emitted no change + + Scenario: Releasing the claim makes the editor ready and empty + Given the server has "B: foo" + And an editor that claims the first load + When the claim is released + Then Editor A's status is "ready" + 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 an editor that claims the first load + When Editor A is loaded + Then Editor A shows "B: |" + When "x" is typed + Then Editor A shows "B: x|" + And Editor A has sent batch 1 + And Editor A's batch 1 + When the server receives Editor A's batch 1 + Then the server has "" + When Editor A's batch 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 batch 1 + And Editor A's batch 1 empties the field + When the server receives Editor A's batch 1 + Then the server has no field + When Editor A's batch 1 comes back + Then Editor A shows "B: |" + And Editor A 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..9714804c4a --- /dev/null +++ b/packages/io/gherkin-spec/other-editors.feature @@ -0,0 +1,97 @@ +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 batch 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "B: foo;;B: barz" + When Editor A receives Editor B's batch 1 + Then Editor A shows "B: fooxy|;;B: barz" + And Editor A has sent nothing new + When the server receives Editor A's batch 1 + Then the server has "B: foox;;B: barz" + When Editor A's batch 1 comes back + Then Editor A has sent batch 2 + When the server receives Editor A's batch 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 batch 1 + When the style is set to "h1" in Editor B + Then Editor B shows "H1: foo|" + And Editor B has sent batch 1 + When the server receives + And the server receives + Then the server has "" + When Editor A's batch 1 is accepted + Then Editor A shows "H2: foo|" + 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 batch 1 | Editor A's batch 1 | H2: foo | Editor A receives Editor B's batch 1 | H2: foo\| | Editor A's batch 1 comes back | + | Editor A's batch 1 | Editor B's batch 1 | H1: foo | Editor A's batch 1 comes back | H2: foo\| | Editor A receives Editor B's batch 1 | + + Scenario: Two batches saved in one transaction are each confirmed + 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 batch 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 batch 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 batch 1 and Editor B's batch 1 as one transaction + Then the server has "B: foox;;B: bary" + When Editor A's batch 1 comes back + Then Editor A shows "B: fooxz|;;B: bary" + And Editor A has sent batch 2 + When Editor B's batch 1 comes back + Then Editor B shows "B: foox;;B: baryw|" + And Editor B has sent batch 2 + + 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 batch 1 + When the server receives Editor A's batch 1 + And Editor A's batch 1 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "H1: foox" + When Editor A receives Editor B's batch 1 + Then Editor A shows "H1: foox|" + When undo is performed + Then Editor A shows "H1: foo|" + And Editor A has sent batch 2 + When the server receives Editor A's batch 2 + Then the server has "H1: foo" + When Editor A's batch 2 comes back + And Editor A is resynced + And undo is performed + Then Editor A shows "H1: foo|" + And Editor A has sent nothing new 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..609727f52c --- /dev/null +++ b/packages/io/gherkin-spec/out-of-step-and-resync.feature @@ -0,0 +1,116 @@ +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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "H1: foo" + When Editor B's batch 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 batch 2 + When the server receives Editor B's batch 2 + Then the server has "H2: foo" + When Editor A receives Editor B's batch 2 + Then Editor A shows "B: foo|" + And Editor A is in step + When Editor A receives Editor B's batch 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 batch 1 + When the server receives Editor B's batch 1 + And Editor B's batch 1 comes back + And the style is set to "h2" in Editor B + Then Editor B has sent batch 2 + When the server receives Editor B's batch 2 + Then the server has "H2: foo" + When Editor A receives Editor B's batch 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 shows "B: foo|" + When Editor A receives Editor B's batch 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 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 batch 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "H1: foo" + When Editor A receives Editor B's batch 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 batch 1 + When the server receives Editor A's batch 1 + Then the server has "B: foo" + When Editor A's batch 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "B: foo" + When Editor A receives Editor B's batch 1 + Then Editor A shows "B: foo|" + And Editor A is in step + When Editor B receives Editor A's batch 1 + Then Editor B shows "B: foo" + When Editor B's batch 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 batch 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 batch 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 batch 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 batch 1 is accepted + And Editor A's batch 1 comes back + Then Editor A has sent batch 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 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..b79075587f --- /dev/null +++ b/packages/io/gherkin-spec/sending-and-confirming.feature @@ -0,0 +1,71 @@ +Feature: Sending and confirming + + Background: + Given the document is "B: foo|" + + Scenario: Queued changes wait for the batch's echo, not its acceptance + When "x" is typed + Then Editor A shows "B: foox|" + And Editor A has sent batch 1 + 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 batch 1 + Then the server has "B: foox" + When Editor A's batch 1 is accepted + Then Editor A has sent nothing new + When Editor A's batch 1 comes back + Then Editor A has sent batch 2 + When the server receives Editor A's batch 2 + Then the server has "B: fooxyz" + When Editor A's batch 2 comes back + Then Editor A has sent nothing new + + Scenario: A batch that changes nothing on the server still comes back, and is confirmed like any other + When the style is set to "h1" in Editor B + Then Editor B shows "H1: foo|" + And Editor B has sent batch 1 + When the server receives Editor B's batch 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 batch 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 batch 1 + Then the server has "H1: foo" + When Editor A's batch 1 is accepted + Then Editor A shows "H1: foox|" + And Editor A has sent nothing new + When Editor A receives Editor B's batch 1 + Then Editor A shows "H1: foox|" + And Editor A has sent nothing new + When Editor A's batch 1 comes back + Then Editor A shows "H1: foox|" + And Editor A has sent batch 2 + When the server receives Editor A's batch 2 + Then the server has "H1: foox" + + Scenario Outline: A rejected batch stops sending until a resync, which the unsent changes + When the style is set to "h1" + Then Editor A shows "H1: foo|" + And Editor A has sent batch 1 + When "y" is typed + Then Editor A shows "H1: fooy|" + And Editor A has sent nothing new + When the server refuses Editor A's batch 1 + Then the server has "B: foo" + When Editor A's batch 1 is rejected + Then Editor A has sent nothing new + And Editor A shows "H1: fooy|" + When Editor A + Then Editor A shows "" + And Editor A has sent + + Examples: + | keeps or discards | resync | state | sent | + | keeps | is resynced | B: fooy\| | batch 2 | + | discards | is resynced, discarding unsent changes | B: foo\| | nothing new | diff --git a/packages/io/package.json b/packages/io/package.json new file mode 100644 index 0000000000..cb011989ad --- /dev/null +++ b/packages/io/package.json @@ -0,0 +1,27 @@ +{ + "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, + "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" + }, + "devDependencies": { + "@sanity/tsconfig": "catalog:tooling", + "racejar": "workspace:*", + "typescript": "catalog:tooling", + "vite": "catalog:tooling", + "vitest": "catalog:tooling" + }, + "engines": { + "node": ">=22.12" + } +} 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/test/scenarios.test.ts b/packages/io/src/test/scenarios.test.ts new file mode 100644 index 0000000000..d80c94deb6 --- /dev/null +++ b/packages/io/src/test/scenarios.test.ts @@ -0,0 +1,23 @@ +import {Given, Then} from 'racejar' +import {Feature} from 'racejar/vitest' +import {expect} from 'vitest' + +type Context = { + documentText: string +} + +Feature({ + featureText: ` + Feature: Wiring + Scenario: A step runs + Given the document is "B: foo|" + Then the document text is "B: foo|"`, + stepDefinitions: [ + Given('the document is {string}', (context: Context, text: string) => { + context.documentText = text + }), + Then('the document text is {string}', (context: Context, text: string) => { + expect(context.documentText).toEqual(text) + }), + ], +}) diff --git a/packages/io/tsconfig.json b/packages/io/tsconfig.json new file mode 100644 index 0000000000..02310f7e97 --- /dev/null +++ b/packages/io/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@sanity/tsconfig/strictest", + "compilerOptions": { + "rootDir": ".", + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/io/vitest.config.ts b/packages/io/vitest.config.ts new file mode 100644 index 0000000000..8c87c3560c --- /dev/null +++ b/packages/io/vitest.config.ts @@ -0,0 +1,15 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }, + ], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c202d290ac..62dbae3113 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,6 +729,24 @@ importers: specifier: catalog:tooling version: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@20.19.25)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + packages/io: + devDependencies: + '@sanity/tsconfig': + specifier: catalog:tooling + version: 2.1.0 + racejar: + specifier: workspace:* + version: link:../racejar + typescript: + specifier: catalog:tooling + version: 7.0.2 + vite: + specifier: catalog:tooling + version: 8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1) + vitest: + specifier: catalog:tooling + version: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + packages/keyboard-shortcuts: devDependencies: '@sanity/pkg-utils': From a21c895a4531159a96d144ef5bae78153777a502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 10:14:42 +0200 Subject: [PATCH 02/85] feat(io): model the document as PTE-shaped patches with textspec in and out The fake document is a `PortableTextBlock[]` with a caret. Each user action (set a style, type, put the caret, insert a block, delete a block) applies to the tree and produces the patches the real editor would emit: `set` for a style, `diffMatchPatch` for typed text, `insert` and `unset` for blocks. Each action also records its inverse patches for undo. The placeholder follows the protocol's empty-field rules and the shapes in `subscriber.patch-generation.ts`: an empty document shows one placeholder block known by key, the first edit into it prepends `setIfMissing([], [])` and `insert([block], 'before', [0])`, deleting the last block appends `unset([])`, and a block received from the host is real content whose edits produce only their own patch. State goes in and out as textspec through `@portabletext/test`. The comparison helper compares keys only when the expected notation names them and the caret only when the expected notation has one, so scenarios can say exactly as much as they mean. --- packages/io/package.json | 5 + packages/io/src/document.test.ts | 733 +++++++++++++++++++++++++++++++ packages/io/src/document.ts | 532 ++++++++++++++++++++++ pnpm-lock.yaml | 10 + 4 files changed, 1280 insertions(+) create mode 100644 packages/io/src/document.test.ts create mode 100644 packages/io/src/document.ts diff --git a/packages/io/package.json b/packages/io/package.json index cb011989ad..e7e856b025 100644 --- a/packages/io/package.json +++ b/packages/io/package.json @@ -14,6 +14,11 @@ "test:unit": "vitest --run --project unit", "test:unit:watch": "vitest --project unit" }, + "dependencies": { + "@portabletext/patches": "workspace:^", + "@portabletext/schema": "workspace:^", + "@portabletext/test": "workspace:^" + }, "devDependencies": { "@sanity/tsconfig": "catalog:tooling", "racejar": "workspace:*", diff --git a/packages/io/src/document.test.ts b/packages/io/src/document.test.ts new file mode 100644 index 0000000000..54ea8b0910 --- /dev/null +++ b/packages/io/src/document.test.ts @@ -0,0 +1,733 @@ +import { + applyAll, + diffMatchPatch, + insert, + set, + setIfMissing, + unset, +} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import { + comparableTextspec, + createDocument, + createsBlock, + emptiesField, + parseTextspec, +} 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(createDocument.name, () => { + test('writes back the notation it was built from', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {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 = createDocument( + {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 = createDocument( + {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 = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: ba|r'), + ) + const before = document.getValue() + + const result = document.setStyle('h1') + + expect(result).toEqual({ + patches: [set('h1', [{_key: 'k2'}, 'style'])], + inversePatches: [set('normal', [{_key: 'k2'}, 'style'])], + }) + expect(document.toTextspec()).toEqual('B: foo;;H1: ba|r') + expect(applyAll(before, result.patches)).toEqual(document.getValue()) + expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) + }) + + test('setting an unknown style throws', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {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 = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fo|o;;B: bar'), + ) + const before = document.getValue() + + const result = document.type('xy') + + expect(result).toEqual({ + patches: [ + diffMatchPatch('foo', 'foxyo', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + inversePatches: [ + diffMatchPatch('foxyo', 'foo', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + }) + expect(document.toTextspec()).toEqual('B: foxy|o;;B: bar') + expect(applyAll(before, result.patches)).toEqual(document.getValue()) + expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) + }) + + test('the caret is put after text found in one block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {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 = createDocument( + {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 = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|;;B: bar'), + ) + const before = document.getValue() + + const result = document.insertBlock('B _key="k9": baz') + + expect(result).toEqual({ + patches: [ + insert( + [ + { + _type: 'block', + _key: 'k9', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ], + inversePatches: [unset([{_key: 'k9'}])], + }) + expect(document.toTextspec({keys: true})).toEqual( + 'B _key="k0": foo;;B _key="k9": baz|;;B _key="k2": bar', + ) + expect(applyAll(before, result.patches)).toEqual(document.getValue()) + expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) + }) + + test('an inserted block without a named key gets a generated one', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + + const result = document.insertBlock('H2: baz') + + expect(result).toEqual({ + patches: [ + insert( + [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'baz', marks: []}], + style: 'h2', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ], + inversePatches: [unset([{_key: 'k2'}])], + }) + expect(document.toTextspec()).toEqual('B: foo;;H2: baz|') + }) + + test('deleting the caret block moves the caret to the end of the previous block', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: ba|r;;B: baz'), + ) + const before = document.getValue() + + const result = document.deleteBlock('bar') + + expect(result).toEqual({ + patches: [unset([{_key: 'k2'}])], + inversePatches: [ + insert( + [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k0'}], + ), + ], + }) + expect(document.toTextspec()).toEqual('B: foo|;;B: baz') + expect(applyAll(before, result.patches)).toEqual(document.getValue()) + expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) + }) + + test('deleting the first block puts it back before its next sibling on undo', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fo|o;;B: bar'), + ) + const before = document.getValue() + + const result = document.deleteBlock('foo') + + expect(result).toEqual({ + patches: [unset([{_key: 'k0'}])], + inversePatches: [ + insert( + [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + ], + 'before', + [{_key: 'k2'}], + ), + ], + }) + expect(document.toTextspec()).toEqual('B: |bar') + expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) + }) + + test('deleting another block leaves the caret where it is', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {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 = createDocument( + {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('new content keeps the caret in its block, clamped to the text', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: bar|'), + ) + + document.setValue( + 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 = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo;;B: bar|'), + ) + + document.setValue(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 = createDocument({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 batch', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument({keyGenerator}, {value: undefined}) + + const firstResult = document.type('x') + + expect(firstResult).toEqual({ + patches: [ + 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', + ]), + ], + inversePatches: [ + diffMatchPatch('x', '', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + unset([{_key: 'k0'}]), + unset([]), + ], + }) + expect(createsBlock(firstResult.patches)).toEqual(true) + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(document.toTextspec()).toEqual('B: x|') + expect(applyAll(undefined, firstResult.patches)).toEqual( + document.getValue(), + ) + expect(applyAll(document.getValue(), firstResult.inversePatches)).toEqual( + undefined, + ) + + const secondResult = document.type('y') + + expect(secondResult).toEqual({ + patches: [ + diffMatchPatch('x', 'xy', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + inversePatches: [ + diffMatchPatch('xy', 'x', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + }) + expect(createsBlock(secondResult.patches)).toEqual(false) + }) + + test('setting a style on the placeholder creates the block first', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument({keyGenerator}, {value: []}) + + const result = document.setStyle('h1') + + expect(result.patches).toEqual([ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + set('h1', [{_key: 'k0'}, 'style']), + ]) + expect(applyAll([], result.patches)).toEqual(document.getValue()) + }) + + test('inserting a block after the placeholder creates the placeholder first', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument({keyGenerator}, {value: undefined}) + + const result = document.insertBlock('B: foo') + + expect(result.patches).toEqual([ + 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'}], + ), + ]) + expect(applyAll(undefined, result.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 = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B _key="b1": '), + ) + + const result = document.type('x') + + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(result.patches).toEqual([ + diffMatchPatch('', 'x', [{_key: 'b1'}, 'children', {_key: 'k0'}, 'text']), + ]) + expect(createsBlock(result.patches)).toEqual(false) + }) + + test('deleting the last block empties the field and shows a fresh placeholder', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|'), + ) + const before = document.getValue() + + const deleteResult = document.deleteBlock('foo') + + expect(deleteResult).toEqual({ + patches: [unset([{_key: 'k0'}]), unset([])], + inversePatches: [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + ], + 'before', + [0], + ), + ], + }) + expect(emptiesField(deleteResult.patches)).toEqual(true) + expect(document.getPlaceholderKey()).toEqual('k2') + expect(document.toTextspec({keys: true})).toEqual('B _key="k2": |') + expect(applyAll(before, deleteResult.patches)).toEqual(undefined) + expect(applyAll(undefined, deleteResult.inversePatches)).toEqual(before) + + const typeResult = document.type('x') + + expect(createsBlock(typeResult.patches)).toEqual(true) + expect(emptiesField(typeResult.patches)).toEqual(false) + }) + + test('deleting the placeholder sends nothing', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument({keyGenerator}, {value: undefined}) + + expect(document.deleteBlock('')).toEqual({patches: [], inversePatches: []}) + expect(document.getPlaceholderKey()).toEqual('k0') + }) + + test('the placeholder survives new empty content, and new content replaces it', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument({keyGenerator}, {value: undefined}) + + document.setValue([]) + + expect(document.getPlaceholderKey()).toEqual('k0') + + document.setValue(parseTextspec({keyGenerator}, 'B: foo').value) + + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(document.toTextspec({keys: true})).toEqual('B _key="k2": |foo') + + document.setValue(undefined) + + expect(document.getPlaceholderKey()).toEqual('k4') + expect(document.toTextspec({keys: true})).toEqual('B _key="k4": |') + }) +}) + +describe(comparableTextspec.name, () => { + test('ignores keys when the expected notation names none', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {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 = createDocument( + {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('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 = createDocument( + {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 block = { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + } + + expect( + createsBlock([setIfMissing([], []), insert([block], 'before', [0])]), + ).toEqual(true) + expect(createsBlock([insert([block], 'before', [0])])).toEqual(false) + expect( + createsBlock([ + setIfMissing([], [{_key: 'k0'}, 'children']), + insert([block], 'before', [0]), + ]), + ).toEqual(false) + expect(createsBlock([setIfMissing([], [])])).toEqual(false) + }) +}) + +describe(emptiesField.name, () => { + test('needs a whole-field unset', () => { + expect(emptiesField([unset([{_key: 'k0'}]), unset([])])).toEqual(true) + expect(emptiesField([unset([{_key: 'k0'}])])).toEqual(false) + }) +}) diff --git a/packages/io/src/document.ts b/packages/io/src/document.ts new file mode 100644 index 0000000000..db3257e24e --- /dev/null +++ b/packages/io/src/document.ts @@ -0,0 +1,532 @@ +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 { + fromTextspec, + toTextspec, + type TextspecSelection, +} from '@portabletext/test' + +const schema = compileSchema( + defineSchema({styles: [{name: 'h1'}, {name: 'h2'}]}), +) + +/** + * A collapsed selection: a block key plus a character offset into the + * block's text. + */ +export type Caret = {blockKey: string; offset: number} + +/** + * What a user action produced: the patches the editor sends, and the patches + * that undo them, in the order they apply. + */ +export type ActionResult = { + patches: Array + inversePatches: Array +} + +export type Document = { + /** The content on screen, the placeholder included. */ + getValue: () => Array + getCaret: () => Caret + getSelection: () => TextspecSelection + /** The key of the placeholder block, while one is shown. */ + getPlaceholderKey: () => string | undefined + /** + * Replaces the content on screen. Empty content shows the placeholder. The + * caret stays in its block if the block survives. + */ + setValue: (value: Array | undefined) => void + toTextspec: (options?: {keys?: boolean}) => string + setStyle: (style: string) => ActionResult + type: (text: string) => ActionResult + putCaretAfter: (text: string) => void + insertBlock: (textspec: string) => ActionResult + deleteBlock: (text: string) => ActionResult +} + +export function createDocument( + context: {keyGenerator: () => string}, + initial: {value: Array | undefined; caret?: Caret}, +): Document { + let value: Array = [] + let placeholderKey: string | undefined + let caret: Caret = {blockKey: '', offset: 0} + + setValue(initial.value) + + if (initial.caret) { + placeCaret(initial.caret) + } + + 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(result: ActionResult): ActionResult { + if (placeholderKey === undefined) { + return result + } + + const placeholder = findBlock(placeholderKey) + const createdKey = placeholderKey + placeholderKey = undefined + + return { + patches: [ + setIfMissing([], []), + insert([placeholder], 'before', [0]), + ...result.patches, + ], + inversePatches: [ + ...result.inversePatches, + unset([{_key: createdKey}]), + unset([]), + ], + } + } + + function setStyle(style: string): ActionResult { + 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 path = [{_key: block._key}, 'style'] + const result = withPlaceholderCreation({ + patches: [set(style, path)], + inversePatches: [ + block.style === undefined ? unset(path) : set(block.style, path), + ], + }) + + value = replaceAt(value, blockIndex, {...block, style}) + + return result + } + + function type(text: string): ActionResult { + 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 result = withPlaceholderCreation({ + patches: [diffMatchPatch(span.text, nextText, path)], + inversePatches: [diffMatchPatch(nextText, span.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 result + } + + 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): ActionResult { + const {blocks} = fromTextspec( + {schema, keyGenerator: context.keyGenerator}, + textspec, + ) + + if (blocks.length !== 1) { + throw new Error( + `Expected one block in "${textspec}", found ${blocks.length}`, + ) + } + + const newBlock = blocks[0] + const caretBlockKey = caret.blockKey + const blockIndex = value.findIndex((block) => block._key === caretBlockKey) + const result = withPlaceholderCreation({ + patches: [insert([newBlock], 'after', [{_key: caretBlockKey}])], + inversePatches: [unset([{_key: newBlock._key}])], + }) + + value = [ + ...value.slice(0, blockIndex + 1), + newBlock, + ...value.slice(blockIndex + 1), + ] + caret = { + blockKey: newBlock._key, + offset: getTextBlock(newBlock).text.length, + } + + return result + } + + function deleteBlock(text: string): ActionResult { + 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 {patches: [], inversePatches: []} + } + + const blockIndex = value.indexOf(block) + const previousBlock = value[blockIndex - 1] + const nextBlock = value[blockIndex + 1] + const reinsert = previousBlock + ? insert([block], 'after', [{_key: previousBlock._key}]) + : nextBlock + ? insert([block], 'before', [{_key: nextBlock._key}]) + : insert([block], 'before', [0]) + const remainingValue = value.filter( + (candidate) => candidate._key !== block._key, + ) + + if (remainingValue.length === 0) { + setValue(undefined) + + return { + patches: [unset([{_key: block._key}]), unset([])], + inversePatches: [setIfMissing([], []), reinsert], + } + } + + if (caret.blockKey === block._key) { + caret = previousBlock + ? { + blockKey: previousBlock._key, + offset: getTextBlock(previousBlock).text.length, + } + : {blockKey: nextBlock._key, offset: 0} + } + + value = remainingValue + + return { + patches: [unset([{_key: block._key}])], + inversePatches: [reinsert], + } + } + + return { + getValue: () => value, + getCaret: () => caret, + getSelection, + getPlaceholderKey: () => placeholderKey, + setValue, + toTextspec: (options) => + serializeTextspec({ + value, + selection: getSelection(), + keys: options?.keys ?? false, + }), + setStyle, + type, + putCaretAfter, + insertBlock, + deleteBlock, + } +} + +/** + * 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 generatedKeys = new Set() + const keyGenerator = createRecordingKeyGenerator(generatedKeys) + const parsed = fromTextspec({schema, keyGenerator}, expected) + const namedKeys = new Set( + parsed.blocks + .map((block) => block._key) + .filter((key) => !generatedKeys.has(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, + }), + } +} + +/** + * Whether a batch 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 batch 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 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 /(?) { + let index = 0 + + return function keyGenerator() { + const key = `expected-k${index}` + index++ + generatedKeys.add(key) + return key + } +} + +/** + * Serializes single-line textspec, one block at a time so each block's prefix + * can be written the way the scenarios write it: `H1: foo` rather than the + * `B style="h1": foo` that `toTextspec` produces. + */ +function serializeTextspec({ + value, + selection, + keys, +}: { + value: Array + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 62dbae3113..3684c19956 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -730,6 +730,16 @@ importers: version: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@20.19.25)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) packages/io: + dependencies: + '@portabletext/patches': + specifier: workspace:* + version: link:../patches + '@portabletext/schema': + specifier: workspace:^ + version: link:../schema + '@portabletext/test': + specifier: workspace:^ + version: link:../test devDependencies: '@sanity/tsconfig': specifier: catalog:tooling From 817b1a71cb82bdec7934c4a843024cf481bc4661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 10:14:43 +0200 Subject: [PATCH 03/85] feat(io): fake Content Lake and network with explicit queues and a virtual clock The server holds one document with a field and a revision, applies each batch's patches with `applyAll` under Content Lake semantics, and records a transaction for every batch it receives, changed or not, as measured against the service. A keyed patch whose target is gone is a no-op, an `insert` with a key that already exists is stored as sent, and only `refuse` is a decision rather than a result. `applyAll` throws when a path runs into `undefined`, so the server skips a patch whose parent no longer exists instead of failing the transaction. Deleting the document records `resultRev: undefined`, recreating it `previousRev: undefined`. The network holds queues that only the test steps drain: save requests, one reply per batch, and one feed per editor that delivers transactions in whatever order a step asks for, so both sides of a race can be written. The clock is virtual: timers fire only inside `advance`. --- packages/io/src/test/network.test.ts | 189 ++++++++++++++++ packages/io/src/test/network.ts | 181 +++++++++++++++ packages/io/src/test/server.test.ts | 317 +++++++++++++++++++++++++++ packages/io/src/test/server.ts | 223 +++++++++++++++++++ 4 files changed, 910 insertions(+) create mode 100644 packages/io/src/test/network.test.ts create mode 100644 packages/io/src/test/network.ts create mode 100644 packages/io/src/test/server.test.ts create mode 100644 packages/io/src/test/server.ts diff --git a/packages/io/src/test/network.test.ts b/packages/io/src/test/network.test.ts new file mode 100644 index 0000000000..0ea028b9b7 --- /dev/null +++ b/packages/io/src/test/network.test.ts @@ -0,0 +1,189 @@ +import {set} from '@portabletext/patches' +import {describe, expect, test} from 'vitest' +import {createNetwork, type Reply} from './network' +import type {ServerTransaction} from './server' + +describe(createNetwork.name, () => { + test('save requests leave in the order the caller takes them', () => { + const network = createNetwork() + const batchA1 = {id: 'a1', patches: [set('h1', [{_key: 'k0'}, 'style'])]} + const batchA2 = {id: 'a2', patches: [set('h2', [{_key: 'k0'}, 'style'])]} + const batchB1 = { + id: 'b1', + patches: [set('normal', [{_key: 'k0'}, 'style'])], + } + + network.send('A', batchA1) + network.send('A', batchA2) + network.send('B', batchB1) + + expect(network.takeSaveRequest('b1')).toEqual({ + editorId: 'B', + batch: batchB1, + }) + expect(network.takeSaveRequest('a2')).toEqual({ + editorId: 'A', + batch: batchA2, + }) + expect(network.getSaveRequests()).toEqual([{editorId: 'A', batch: batchA1}]) + expect(network.takeSaveRequest('a1')).toEqual({ + editorId: 'A', + batch: batchA1, + }) + expect(network.getSaveRequests()).toEqual([]) + expect(() => network.takeSaveRequest('a1')).toThrow( + 'No save request for batch "a1"', + ) + }) + + test('replies reach the sending editor in the order the caller delivers them', () => { + const network = createNetwork() + const received: Array<{editorId: string; reply: Reply}> = [] + + for (const editorId of ['A', 'B']) { + network.connect(editorId, { + receiveTransaction: () => {}, + receiveReply: (reply) => { + received.push({editorId, reply}) + }, + }) + } + + network.queueReply({editorId: 'A', batchId: 'a1', outcome: 'accepted'}) + network.queueReply({editorId: 'B', batchId: 'b1', outcome: 'rejected'}) + network.deliverReply('b1') + + expect(network.getReplies()).toEqual([ + {editorId: 'A', batchId: 'a1', outcome: 'accepted'}, + ]) + + network.deliverReply('a1') + + expect(received).toEqual([ + { + editorId: 'B', + reply: {editorId: 'B', batchId: 'b1', outcome: 'rejected'}, + }, + { + editorId: 'A', + reply: {editorId: 'A', batchId: 'a1', outcome: 'accepted'}, + }, + ]) + expect(network.getReplies()).toEqual([]) + }) + + test('each editor receives its feed in the order the caller delivers it', () => { + const network = createNetwork() + const received: Array<{editorId: string; transactionId: string}> = [] + const firstTransaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k0'}, 'style'])], + batchIds: ['b1'], + } + const secondTransaction: ServerTransaction = { + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + batchIds: [], + } + + for (const editorId of ['A', 'B']) { + network.connect(editorId, { + receiveTransaction: (transaction) => { + received.push({editorId, transactionId: transaction.transactionId}) + }, + receiveReply: () => {}, + }) + } + + 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 = createNetwork() + const receiver = {receiveTransaction: () => {}, receiveReply: () => {}} + const transaction: ServerTransaction = { + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + batchIds: [], + } + + network.connect('A', receiver) + network.publish(transaction) + network.connect('B', receiver) + + expect(network.getFeed('A')).toEqual([transaction]) + expect(network.getFeed('B')).toEqual([]) + }) + + test('the clock runs what falls due, in due order, only when advanced', () => { + const network = createNetwork() + 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 = createNetwork() + 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/test/network.ts b/packages/io/src/test/network.ts new file mode 100644 index 0000000000..02baf1006f --- /dev/null +++ b/packages/io/src/test/network.ts @@ -0,0 +1,181 @@ +import type {SavedBatch, ServerTransaction} from './server' + +export type SaveRequest = { + editorId: string + batch: TBatch +} + +export type Reply = { + editorId: string + batchId: string + outcome: 'accepted' | 'rejected' +} + +/** + * What an editor's host receives from the network. + */ +export type NetworkReceiver = { + receiveTransaction: (transaction: ServerTransaction) => void + receiveReply: (reply: Reply) => 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 + connect: (editorId: string, receiver: NetworkReceiver) => void + send: (editorId: string, batch: TBatch) => void + getSaveRequests: () => Array> + takeSaveRequest: (batchId: string) => SaveRequest + queueReply: (reply: Reply) => void + getReplies: () => Array + deliverReply: (batchId: string) => void + /** Appends the transaction to the feed of every connected editor. */ + publish: (transaction: ServerTransaction) => void + getFeed: (editorId: string) => Array + deliver: (editorId: string, transactionId: string) => void +} + +/** + * Queues between the editors' hosts and the server. Nothing moves until a + * caller takes or delivers it, in whatever order the caller asks for. + */ +export function createNetwork< + TBatch extends SavedBatch = SavedBatch, +>(): Network { + const receivers = new Map() + const feeds = new Map>() + let saveRequests: Array> = [] + let replies: 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) => { + receivers.set(editorId, receiver) + feeds.set(editorId, []) + }, + send: (editorId, batch) => { + saveRequests = [...saveRequests, {editorId, batch}] + }, + getSaveRequests: () => saveRequests, + takeSaveRequest: (batchId) => { + const request = saveRequests.find( + (candidate) => candidate.batch.id === batchId, + ) + + if (!request) { + throw new Error(`No save request for batch "${batchId}"`) + } + + saveRequests = saveRequests.filter((candidate) => candidate !== request) + + return request + }, + queueReply: (reply) => { + replies = [...replies, reply] + }, + getReplies: () => replies, + deliverReply: (batchId) => { + const reply = replies.find((candidate) => candidate.batchId === batchId) + + if (!reply) { + throw new Error(`No reply for batch "${batchId}"`) + } + + replies = replies.filter((candidate) => candidate !== reply) + getReceiver(reply.editorId).receiveReply(reply) + }, + publish: (transaction) => { + for (const [editorId, feed] of feeds) { + 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(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/test/server.test.ts b/packages/io/src/test/server.test.ts new file mode 100644 index 0000000000..24c99da7dd --- /dev/null +++ b/packages/io/src/test/server.test.ts @@ -0,0 +1,317 @@ +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 {createServer} from './server' + +describe(createServer.name, () => { + test('an existing document starts at the first revision', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createServer({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 = createServer({documentId: 'document', document: undefined}) + + expect(server.copy()).toEqual({value: undefined, rev: undefined}) + }) + + test('a batch that changes nothing is still recorded and moves the revision', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'H1: foo') + const server = createServer({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'])], + batchIds: ['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 = createServer({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 = createServer({ + 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 = createServer({documentId: 'document', document: {value}}) + const barBlock = { + _type: 'block', + _key: 'k9', + children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], + style: 'normal', + } + + 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 batch for a missing document creates it', () => { + const server = createServer({documentId: 'document', document: undefined}) + const placeholder = { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + } + 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, + batchIds: ['b1'], + }) + expect(server.copy()).toEqual({ + value: [ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: 'x', marks: []}], + }, + ], + rev: 'r1', + }) + }) + + test('two batches received as one are one transaction', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;B: bar') + const server = createServer({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 transaction = server.receiveAsOne( + {id: 'a1', patches: [fooPatch]}, + {id: 'b1', patches: [barPatch]}, + 't1', + ) + + expect(transaction).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [fooPatch, barPatch], + batchIds: ['a1', 'b1'], + }) + 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 refused batch records nothing', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createServer({documentId: 'document', document: {value}}) + + server.refuse('b1') + + expect(server.isRefused('b1')).toEqual(true) + expect(server.isRefused('b2')).toEqual(false) + expect(server.getTransactions()).toEqual([]) + expect(server.copy()).toEqual({value, rev: 'r1'}) + }) + + test('a change to another field moves the revision with no field patches', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createServer({documentId: 'document', document: {value}}) + + expect(server.changeOtherField('t1')).toEqual({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [], + batchIds: [], + }) + expect(server.copy()).toEqual({value, rev: 'r2'}) + }) + + test('deleting and recreating the document', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createServer({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([])], + batchIds: [], + }) + 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, [])], + batchIds: [], + }) + 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('a copy does not share content with the server', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + const server = createServer({documentId: 'document', document: {value}}) + + const copy = server.copy() + copy.value?.pop() + + expect(server.copy()).toEqual({value, rev: 'r1'}) + }) +}) diff --git a/packages/io/src/test/server.ts b/packages/io/src/test/server.ts new file mode 100644 index 0000000000..3436ed8f45 --- /dev/null +++ b/packages/io/src/test/server.ts @@ -0,0 +1,223 @@ +import { + applyAll, + set, + unset, + type Patch, + type Path, + type PathSegment, +} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' + +/** + * A batch as the server sees it: the batch ID and its patches, scoped to the + * field. + */ +export type SavedBatch = {id: string; patches: Array} + +export type ServerTransaction = { + transactionId: string + previousRev: string | undefined + resultRev: string | undefined + patches: Array + batchIds: Array +} + +export type ServerCopy = { + value: Array | undefined + rev: string | undefined +} + +export type Server = { + documentId: string + /** + * Applies the batch and records a transaction, whether or not anything + * changed. Creates the document if it doesn't exist. + */ + receive: (batch: SavedBatch, transactionId: string) => ServerTransaction + /** Applies both batches and records them as one transaction. */ + receiveAsOne: ( + batchA: SavedBatch, + batchB: SavedBatch, + transactionId: string, + ) => ServerTransaction + /** Refuses the batch for good. Nothing is recorded. */ + refuse: (batchId: string) => void + isRefused: (batchId: string) => boolean + /** Records a transaction that changes only another field of the document. */ + changeOtherField: (transactionId: string) => ServerTransaction + deleteDocument: (transactionId: string) => ServerTransaction + recreate: ( + value: Array, + transactionId: string, + ) => ServerTransaction + copy: () => ServerCopy + getTransactions: () => Array + 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 createServer(initial: { + documentId: string + document: {value: Array | undefined} | undefined +}): Server { + let revisionCounter = 0 + let value: Array | undefined + let rev: string | undefined + const transactions: Array = [] + const refusedBatchIds = new Set() + + if (initial.document) { + value = initial.document.value + rev = nextRevision() + } + + function nextRevision() { + revisionCounter++ + return `r${revisionCounter}` + } + + function record( + transaction: Omit, + resultRev: string | undefined, + ): ServerTransaction { + const recorded = {...transaction, previousRev: rev, resultRev} + transactions.push(recorded) + rev = resultRev + return recorded + } + + function receiveBatches(batches: Array, transactionId: string) { + const patches = batches.flatMap((batch) => batch.patches) + value = applyWithContentLakeSemantics(value, patches) + + return record( + { + transactionId, + patches, + batchIds: batches.map((batch) => batch.id), + }, + nextRevision(), + ) + } + + return { + documentId: initial.documentId, + receive: (batch, transactionId) => receiveBatches([batch], transactionId), + receiveAsOne: (batchA, batchB, transactionId) => + receiveBatches([batchA, batchB], transactionId), + refuse: (batchId) => { + refusedBatchIds.add(batchId) + }, + isRefused: (batchId) => refusedBatchIds.has(batchId), + changeOtherField: (transactionId) => { + if (rev === undefined) { + throw new Error('The document does not exist') + } + + return record({transactionId, patches: [], batchIds: []}, nextRevision()) + }, + deleteDocument: (transactionId) => { + if (rev === undefined) { + throw new Error('The document does not exist') + } + + value = undefined + + return record( + {transactionId, patches: [unset([])], batchIds: []}, + undefined, + ) + }, + recreate: (nextValue, transactionId) => { + if (rev !== undefined) { + throw new Error('The document already exists') + } + + value = nextValue + + return record( + {transactionId, patches: [set(nextValue, [])], batchIds: []}, + nextRevision(), + ) + }, + copy: () => ({value: structuredClone(value), rev}), + getTransactions: () => transactions, + getTransaction: (transactionId) => { + const transaction = transactions.find( + (candidate) => candidate.transactionId === transactionId, + ) + + if (!transaction) { + throw new Error(`No transaction "${transactionId}"`) + } + + return transaction + }, + } +} + +/** + * 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. Duplicate keys are stored as sent. + */ +function applyWithContentLakeSemantics( + value: Array | undefined, + patches: Array, +): Array | undefined { + return patches.reduce | undefined>( + (currentValue, patch) => + hasContainer(currentValue, patch.path) + ? applyAll(currentValue, [patch]) + : currentValue, + value, + ) +} + +function hasContainer(value: unknown, path: Path): boolean { + if (path.length === 0) { + return true + } + + let container = value + + for (const segment of path.slice(0, -1)) { + container = resolveSegment(container, segment) + } + + return typeof container === 'object' && container !== null +} + +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 +} From c7013cbbfc590f3e2b1fe72510f7ed57b0e430b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 10:30:56 +0200 Subject: [PATCH 04/85] feat(io): implement the editor's side of the protocol and the pass-through host `createIoEditor` is the protocol as `protocol.md` writes it, in plain TypeScript with no React, XState or `@portabletext/editor` dependency, so it can move into the editor later. It keeps a base (the server's copy at a revision), one batch in flight, a rejected batch, pending changes and held transactions, and derives the screen as base plus unconfirmed work under Content Lake semantics (`applyWithContentLakeSemantics`, shared with the fake server). A transaction whose `previousRev` doesn't match is held, the chain is applied once the missing one arrives, and `out of order` fires only when nothing connects within 10 s on the virtual clock. Confirmation is the echo: the batch named by `mutation sent` leaves the ledger when its transaction arrives, whether applied, held or ignored while out of step, and a held echo keeps its patches on screen until its transaction reaches the base. A rejection blocks sending until a resync, which is refused while a batch is in flight and drops the rejected batch and its undo steps. A pending insert whose key collides with the base is re-keyed, with later patches, undo steps and the caret following. Undo puts back what the base holds now and is never rebased over the editor's own contribution. `createPassThroughHost` maps each batch to its transaction, reports `mutation sent` before the save request, forwards the feed unchanged, reports rejections, and fetches the server copy for `load` and `resync`. --- packages/io/src/content-lake.ts | 80 ++++ packages/io/src/document.ts | 3 + packages/io/src/editor.test.ts | 233 ++++++++++ packages/io/src/editor.ts | 735 ++++++++++++++++++++++++++++++++ packages/io/src/host.ts | 89 ++++ packages/io/src/index.ts | 15 + packages/io/src/test/server.ts | 73 +--- packages/io/src/types.ts | 58 +++ 8 files changed, 1215 insertions(+), 71 deletions(-) create mode 100644 packages/io/src/content-lake.ts create mode 100644 packages/io/src/editor.test.ts create mode 100644 packages/io/src/editor.ts create mode 100644 packages/io/src/host.ts create mode 100644 packages/io/src/index.ts create mode 100644 packages/io/src/types.ts diff --git a/packages/io/src/content-lake.ts b/packages/io/src/content-lake.ts new file mode 100644 index 0000000000..0e603d9a4f --- /dev/null +++ b/packages/io/src/content-lake.ts @@ -0,0 +1,80 @@ +import { + applyAll, + 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. Duplicate keys are stored as sent. + */ +export function applyWithContentLakeSemantics( + value: Array | undefined, + patches: Array, +): Array | undefined { + return patches.reduce | undefined>( + (currentValue, patch) => + hasContainer(currentValue, patch.path) + ? applyAll(currentValue, [patch]) + : currentValue, + value, + ) +} + +/** + * 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 +} + +function hasContainer(value: unknown, path: Path): boolean { + if (path.length === 0) { + return true + } + + const container = resolvePath(value, path.slice(0, -1)) + + return typeof container === 'object' && container !== null +} + +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/document.ts b/packages/io/src/document.ts index db3257e24e..0d47093367 100644 --- a/packages/io/src/document.ts +++ b/packages/io/src/document.ts @@ -44,6 +44,8 @@ export type Document = { /** 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 @@ -312,6 +314,7 @@ export function createDocument( return { getValue: () => value, getCaret: () => caret, + setCaret: placeCaret, getSelection, getPlaceholderKey: () => placeholderKey, setValue, diff --git a/packages/io/src/editor.test.ts b/packages/io/src/editor.test.ts new file mode 100644 index 0000000000..5a8e0ff2f4 --- /dev/null +++ b/packages/io/src/editor.test.ts @@ -0,0 +1,233 @@ +import {diffMatchPatch, insert, set, unset} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from './document' +import {createIoEditor} from './editor' +import {createNetwork} from './test/network' + +describe(createIoEditor.name, () => { + test('held transactions are applied in chain order once the missing one arrives', () => { + const {editor, clock} = createLoadedEditor('B: foo') + const path = [{_key: 'd-k0'}, 'style'] + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.transaction({ + transactionId: 't3', + previousRev: 'r3', + resultRev: 'r4', + patches: [diffMatchPatch('foo', 'foox', textPath)], + }) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h2', path)], + }) + + expect(editor.document.toTextspec()).toEqual('B: |foo') + expect(editor.getBase().rev).toEqual('r1') + + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', path)], + }) + + expect(editor.document.toTextspec()).toEqual('H2: |foox') + expect(editor.getBase().rev).toEqual('r4') + expect(editor.changes.length).toEqual(3) + + clock.advance(10_000) + + expect(editor.errors).toEqual([]) + }) + + test('a held echo lets the next batch go out and keeps its work on screen until it applies', () => { + const {editor} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.type('x') + editor.type('y') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r2', + resultRev: 'r3', + patches: editor.sentBatches[0].patches, + }) + + expect(editor.sentBatches.map((batch) => batch.patches)).toEqual([ + [diffMatchPatch('foo', 'foox', textPath)], + [diffMatchPatch('foox', 'fooxy', textPath)], + ]) + expect(editor.document.toTextspec()).toEqual('B: fooxy|') + + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + + expect(editor.document.toTextspec()).toEqual('H1: fooxy|') + expect(editor.getBase()).toEqual({ + value: parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'H1: foox', + ).value, + rev: 'r3', + }) + }) + + test('a pending insert re-keyed on collision takes its later patches, its undo steps and the caret with it', () => { + const {editor} = createLoadedEditor('B: foo|') + const barBlock = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B _key="k9": bar', + ).value[0] + + editor.type('x') + editor.insertBlock('B _key="k9": baz') + editor.type('q') + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + }) + + expect(editor.errors).toEqual([]) + expect(editor.document.toTextspec({keys: true})).toEqual( + 'B _key="d-k0": foox;;B _key="a-k3": bazq|;;B _key="k9": bar', + ) + + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r2', + resultRev: 'r3', + patches: editor.sentBatches[0].patches, + }) + + expect(editor.sentBatches[1].patches).toEqual([ + insert( + [ + { + _type: 'block', + _key: 'a-k3', + children: [{_key: 'a-k2', _type: 'span', text: 'baz', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k0'}], + ), + diffMatchPatch('baz', 'bazq', [ + {_key: 'a-k3'}, + 'children', + {_key: 'a-k2'}, + 'text', + ]), + ]) + + editor.undo() + editor.undo() + + expect(editor.document.toTextspec({keys: true})).toEqual( + 'B _key="d-k0": |foox;;B _key="k9": bar', + ) + }) + + test('undo puts back the style another writer set underneath', () => { + const {editor} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + editor.setStyle('h2') + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', path)], + }) + + expect(editor.document.toTextspec()).toEqual('H2: foo|') + + editor.undo() + + expect(editor.document.toTextspec()).toEqual('H1: foo|') + + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r2', + resultRev: 'r3', + patches: editor.sentBatches[0].patches, + }) + + expect(editor.sentBatches.map((batch) => batch.patches)).toEqual([ + [set('h2', path)], + [set('h1', path)], + ]) + }) + + test("undo isn't rebased over the editor's own echo", () => { + const {editor} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + editor.setStyle('h2') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: editor.sentBatches[0].patches, + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: foo|') + expect(editor.sentBatches.map((batch) => batch.patches)).toEqual([ + [set('h2', path)], + [set('normal', path)], + ]) + }) + + test('a rejected batch stays on screen while the feed keeps applying', () => { + const {editor} = createLoadedEditor('B: foo|;;B: bar') + + editor.type('x') + editor.mutationRejected({id: 'A-1'}) + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [unset([{_key: 'd-k2'}])], + }) + + expect(editor.document.toTextspec()).toEqual('B: foox|') + expect(editor.sentBatches.length).toEqual(1) + }) +}) + +function createLoadedEditor(textspec: string) { + const {clock} = createNetwork() + const editor = createIoEditor({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + claimLoad: true, + }) + const {value, caret} = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + textspec, + ) + + editor.on((event) => { + if (event.type === 'mutation' && !event.final) { + editor.mutationSent({id: event.id, transactionId: event.id}) + } + }) + editor.load({value, rev: 'r1'}) + + if (caret) { + editor.document.setCaret(caret) + } + + return {editor, clock} +} diff --git a/packages/io/src/editor.ts b/packages/io/src/editor.ts new file mode 100644 index 0000000000..d65410d287 --- /dev/null +++ b/packages/io/src/editor.ts @@ -0,0 +1,735 @@ +import {set, type Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' +import {applyWithContentLakeSemantics, resolvePath} from './content-lake' +import {createDocument, type ActionResult, type Document} from './document' +import type { + ChangeEvent, + ErrorEvent, + Load, + MutationAccepted, + MutationBatch, + MutationRejected, + MutationSent, + Resync, + Transaction, +} from './types' + +const heldTransactionTimeout = 10_000 + +export type Clock = { + now: () => number + /** Returns a function that cancels the callback. */ + schedule: (delay: number, callback: () => void) => () => void +} + +export type IoEditorStatus = 'loading' | 'ready' | 'unmounted' + +export type IoEditorEvent = + | ({type: 'mutation'} & MutationBatch) + | ({type: 'change'} & ChangeEvent) + | ({type: 'error'} & ErrorEvent) + | {type: 'ready'} + +export type IoEditor = { + /** The content on screen and the caret. */ + document: Document + getStatus: () => IoEditorStatus + getBase: () => Load + on: (listener: (event: IoEditorEvent) => void) => () => void + + load: (load: Load) => void + releaseClaim: () => void + resync: (resync: Resync) => void + transaction: (transaction: Transaction) => void + mutationSent: (mutationSent: MutationSent) => void + mutationAccepted: (mutationAccepted: MutationAccepted) => void + mutationRejected: (mutationRejected: MutationRejected) => void + updateReadOnly: (readOnly: boolean) => void + + setStyle: (style: string) => void + type: (text: string) => void + putCaretAfter: (text: string) => void + insertBlock: (textspec: string) => void + deleteBlock: (text: string) => void + undo: () => void + close: () => void + + sentBatches: Array + changes: Array + errors: Array + warnings: Array +} + +type SentBatch = { + id: string + patches: Array + transactionId: string | undefined +} + +type HeldTransaction = {transaction: Transaction; arrivedAt: number} + +/** + * The editor side of the pass-through protocol. Batch IDs are the editor's + * `id` plus a counter, so editors with different IDs never share one. + */ +export function createIoEditor(options: { + id: string + keyGenerator: () => string + clock: Clock + claimLoad?: boolean +}): IoEditor { + const {keyGenerator, clock} = options + const listeners = new Set<(event: IoEditorEvent) => void>() + const document = createDocument({keyGenerator}, {value: undefined}) + const emittedBatchIds = new Set() + const sentBatches: Array = [] + const changes: Array = [] + const errors: Array = [] + const warnings: Array = [] + + let status: IoEditorStatus = options.claimLoad ? 'loading' : 'ready' + let base: Load = {value: undefined, rev: undefined} + let readOnly = false + let outOfStep = false + let batchCounter = 0 + let inFlight: SentBatch | undefined + let rejected: SentBatch | undefined + let echoedAwaitingBase: Array = [] + let pending: Array> = [] + let held: Array = [] + let cancelHeldTimeout: (() => void) | undefined + let undoSteps: Array> = [] + + function emit(event: IoEditorEvent) { + for (const listener of listeners) { + listener(event) + } + } + + function warn(message: string) { + warnings.push(message) + } + + function load(incoming: Load) { + if (status !== 'loading') { + throw new Error('`load` is only accepted while the first load is claimed') + } + + base = {value: incoming.value, rev: incoming.rev} + queueKeyRepair(incoming.value) + document.setValue(deriveScreen()) + becomeReady() + } + + function releaseClaim() { + if (status === 'loading') { + becomeReady() + } + } + + function becomeReady() { + status = 'ready' + emit({type: 'ready'}) + flush() + } + + function resync(incoming: Resync) { + if (status === 'loading') { + throw new Error('`resync` is not accepted before the editor is ready') + } + + if (inFlight) { + warn( + `Refused a resync while batch "${inFlight.id}" is in flight: wait until it comes back or is rejected`, + ) + return + } + + base = {value: incoming.value, rev: incoming.rev} + rejected = undefined + echoedAwaitingBase = [] + outOfStep = false + undoSteps = [] + releaseHeld() + + if (incoming.discardUnsent) { + pending = [] + } + + queueKeyRepair(incoming.value) + updateScreen() + flush() + } + + function transaction(incoming: Transaction) { + if (status === 'loading') { + throw new Error( + '`transaction` is not accepted before the editor is ready', + ) + } + + noteOwnTransaction(incoming.transactionId) + + if (outOfStep) { + flush() + return + } + + if (incoming.previousRev !== base.rev) { + held = [...held, {transaction: incoming, arrivedAt: clock.now()}] + scheduleHeldTimeout() + flush() + return + } + + let next: Transaction | undefined = incoming + + while (next && applyTransaction(next)) { + next = takeConnectedHeldTransaction() + } + + scheduleHeldTimeout() + flush() + } + + function noteOwnTransaction(transactionId: string) { + if (inFlight && inFlight.transactionId === transactionId) { + echoedAwaitingBase = [...echoedAwaitingBase, inFlight] + inFlight = undefined + } + } + + function takeConnectedHeldTransaction(): Transaction | undefined { + const connected = held.find( + (candidate) => candidate.transaction.previousRev === base.rev, + ) + + if (!connected) { + return undefined + } + + held = held.filter((candidate) => candidate !== connected) + + return connected.transaction + } + + function applyTransaction(incoming: Transaction): boolean { + const ownBatches = echoedAwaitingBase.filter( + (batch) => batch.transactionId === incoming.transactionId, + ) + const ownPatchIndexes = findOwnPatchIndexes(incoming.patches, ownBatches) + const otherPatches = incoming.patches.filter( + (_patch, index) => !ownPatchIndexes.has(index), + ) + let nextValue = base.value + + for (const [index, patch] of incoming.patches.entries()) { + if ( + !ownPatchIndexes.has(index) && + patch.type === 'insert' && + insertCollides(patch, keysAmongSiblings(nextValue, patch.path)) + ) { + return fail({ + reason: 'duplicate key', + transactionId: incoming.transactionId, + patch, + }) + } + + try { + nextValue = applyWithContentLakeSemantics(nextValue, [patch]) + } catch { + return fail({ + reason: 'patch failed', + transactionId: incoming.transactionId, + patch, + }) + } + } + + const unconfirmedKeys = insertedBlockKeys( + [ + ...echoedAwaitingBase.filter((batch) => !ownBatches.includes(batch)), + ...(inFlight ? [inFlight] : []), + ...(rejected ? [rejected] : []), + ].flatMap((batch) => batch.patches), + ) + const collidingPatch = otherPatches.find( + (patch) => + patch.type === 'insert' && insertCollides(patch, unconfirmedKeys), + ) + + if (collidingPatch) { + return fail({ + reason: 'duplicate key', + transactionId: incoming.transactionId, + patch: collidingPatch, + }) + } + + base = {value: nextValue, rev: incoming.resultRev} + echoedAwaitingBase = echoedAwaitingBase.filter( + (batch) => !ownBatches.includes(batch), + ) + rebaseUndoSteps(otherPatches) + + if (incoming.patches.length > 0) { + updateScreen() + } + + return true + } + + function fail(error: ErrorEvent): false { + outOfStep = true + releaseHeld() + errors.push(error) + emit({type: 'error', ...error}) + return false + } + + function scheduleHeldTimeout() { + cancelHeldTimeout?.() + cancelHeldTimeout = undefined + + const [oldest] = held + + if (!oldest) { + return + } + + cancelHeldTimeout = clock.schedule( + oldest.arrivedAt + heldTransactionTimeout - clock.now(), + () => { + cancelHeldTimeout = undefined + fail({ + reason: 'out of order', + transactionId: oldest.transaction.transactionId, + }) + }, + ) + } + + function releaseHeld() { + held = [] + scheduleHeldTimeout() + } + + function mutationSent(incoming: MutationSent) { + if (!emittedBatchIds.has(incoming.id)) { + warn(`\`mutation sent\` for unknown batch "${incoming.id}"`) + return + } + + if (inFlight?.id === incoming.id) { + inFlight = {...inFlight, transactionId: incoming.transactionId} + } + } + + function mutationAccepted(incoming: MutationAccepted) { + if (!emittedBatchIds.has(incoming.id)) { + warn(`\`mutation accepted\` for unknown batch "${incoming.id}"`) + } + } + + function mutationRejected(incoming: MutationRejected) { + if (!emittedBatchIds.has(incoming.id)) { + warn(`\`mutation rejected\` for unknown batch "${incoming.id}"`) + return + } + + if (inFlight?.id !== incoming.id) { + warn(`\`mutation rejected\` for batch "${incoming.id}", not in flight`) + return + } + + rejected = inFlight + inFlight = undefined + } + + function act(run: () => ActionResult) { + if (!canEdit()) { + return + } + + const result = run() + commitLocalChange(result.patches) + + if (result.patches.length > 0) { + undoSteps = [...undoSteps, result.inversePatches] + } + } + + function undo() { + const inversePatches = undoSteps.at(-1) + + if (!canEdit() || !inversePatches) { + return + } + + undoSteps = undoSteps.slice(0, -1) + document.setValue( + applyWithContentLakeSemantics(getContent(), inversePatches), + ) + commitLocalChange(inversePatches) + } + + function canEdit(): boolean { + if (status !== 'ready') { + throw new Error(`The editor is ${status}`) + } + + return !readOnly + } + + function commitLocalChange(patches: Array) { + if (patches.length === 0) { + return + } + + pending = [...pending, patches] + recordChange({operations: patches, origin: 'local'}) + flush() + } + + function close() { + if (status === 'unmounted') { + return + } + + if (pending.length > 0) { + emitBatch({final: true}) + } + + status = 'unmounted' + releaseHeld() + } + + function flush() { + if (status !== 'ready' || inFlight || rejected || pending.length === 0) { + return + } + + emitBatch({final: false}) + } + + function emitBatch({final}: {final: boolean}) { + batchCounter++ + + const batch: MutationBatch = { + id: `${options.id}-${batchCounter}`, + patches: pending.flat(), + value: getContent(), + ...(final ? {final: true as const} : {}), + } + + pending = [] + emittedBatchIds.add(batch.id) + sentBatches.push(batch) + + if (!final) { + inFlight = { + id: batch.id, + patches: batch.patches, + transactionId: undefined, + } + } + + emit({type: 'mutation', ...batch}) + } + + function updateScreen() { + const newKeys = rekeyPendingInserts() + const caret = document.getCaret() + const caretBlockKey = newKeys.get(caret.blockKey) + const before = document.getValue() + document.setValue(deriveScreen()) + const after = document.getValue() + + if (caretBlockKey !== undefined) { + // The old key now names another writer's block, so the caret would + // follow the key into it. + document.setCaret({blockKey: caretBlockKey, offset: caret.offset}) + } + + if (!isEqual(before, after)) { + recordChange({operations: [set(after, [])], origin: 'remote'}) + } + } + + function deriveScreen(): Array | undefined { + const unconfirmedPatches = [ + ...echoedAwaitingBase, + ...(inFlight ? [inFlight] : []), + ...(rejected ? [rejected] : []), + ].flatMap((batch) => batch.patches) + + return applyWithContentLakeSemantics(base.value, [ + ...unconfirmedPatches, + ...pending.flat(), + ]) + } + + function rekeyPendingInserts(): Map { + const baseKeys = new Set( + (base.value ?? []).flatMap((block) => + typeof block._key === 'string' ? [block._key] : [], + ), + ) + const newKeys = new Map() + + for (const key of insertedBlockKeys(pending.flat())) { + if (baseKeys.has(key)) { + newKeys.set(key, keyGenerator()) + } + } + + if (newKeys.size === 0) { + return newKeys + } + + pending = pending.map((patches) => + patches.map((patch) => renameBlockKeys(patch, newKeys)), + ) + undoSteps = undoSteps.map((patches) => + patches.map((patch) => renameBlockKeys(patch, newKeys)), + ) + + return newKeys + } + + function rebaseUndoSteps(otherPatches: Array) { + for (const otherPatch of otherPatches) { + if (otherPatch.type !== 'set' && otherPatch.type !== 'unset') { + continue + } + + undoSteps = undoSteps.map((patches) => + patches.map((patch) => + (patch.type === 'set' || patch.type === 'unset') && + isEqual(patch.path, otherPatch.path) + ? otherPatch + : patch, + ), + ) + } + } + + function queueKeyRepair(value: Array | undefined) { + const repairPatches = repairKeys(value, keyGenerator) + + if (repairPatches.length > 0) { + warn(`Repaired ${repairPatches.length} missing or duplicate keys`) + pending = [repairPatches, ...pending] + } + } + + function recordChange(change: ChangeEvent) { + changes.push(change) + emit({type: 'change', ...change}) + } + + function getContent(): Array | undefined { + return document.getPlaceholderKey() === undefined + ? document.getValue() + : undefined + } + + return { + document, + getStatus: () => status, + getBase: () => base, + on: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + load, + releaseClaim, + resync, + transaction, + mutationSent, + mutationAccepted, + mutationRejected, + updateReadOnly: (nextReadOnly) => { + readOnly = nextReadOnly + }, + setStyle: (style) => act(() => document.setStyle(style)), + type: (text) => act(() => document.type(text)), + putCaretAfter: (text) => document.putCaretAfter(text), + insertBlock: (textspec) => act(() => document.insertBlock(textspec)), + deleteBlock: (text) => act(() => document.deleteBlock(text)), + undo, + close, + sentBatches, + changes, + errors, + warnings, + } +} + +/** + * The indexes of a transaction's patches that are this editor's own batches: + * each batch's patches, found as one contiguous run. + */ +function findOwnPatchIndexes( + patches: Array, + ownBatches: Array, +): Set { + const indexes = new Set() + + for (const batch of ownBatches) { + const start = patches.findIndex( + (_patch, index) => + !indexes.has(index) && + batch.patches.every((batchPatch, offset) => + isEqual(patches[index + offset], batchPatch), + ), + ) + + if (start === -1) { + continue + } + + for (let offset = 0; offset < batch.patches.length; offset++) { + indexes.add(start + offset) + } + } + + return indexes +} + +function keysAmongSiblings( + value: Array | undefined, + path: Patch['path'], +): Set { + const siblings = resolvePath(value, path.slice(0, -1)) + + return new Set( + Array.isArray(siblings) ? siblings.flatMap((item) => itemKey(item)) : [], + ) +} + +function insertCollides(patch: Patch, keys: Set): boolean { + return ( + patch.type === 'insert' && + patch.items.some((item) => itemKey(item).some((key) => keys.has(key))) + ) +} + +function insertedBlockKeys(patches: Array): Set { + return new Set( + patches.flatMap((patch) => + patch.type === 'insert' && patch.path.length === 1 + ? patch.items.flatMap((item) => itemKey(item)) + : [], + ), + ) +} + +function renameBlockKeys(patch: Patch, newKeys: Map): Patch { + const [head, ...tail] = patch.path + const renamedPath = + typeof head === 'object' && !Array.isArray(head) && newKeys.has(head._key) + ? [{_key: newKeys.get(head._key) ?? head._key}, ...tail] + : patch.path + + if (patch.type === 'insert' && patch.path.length === 1) { + return { + ...patch, + path: renamedPath, + items: patch.items.map((item) => { + const [key] = itemKey(item) + + return key !== undefined && + newKeys.has(key) && + typeof item === 'object' && + !Array.isArray(item) + ? {...item, _key: newKeys.get(key) ?? key} + : item + }), + } + } + + return {...patch, path: renamedPath} +} + +/** + * Repairs missing and duplicate keys among blocks and among each block's + * children, keeping the first of each duplicate. Index paths address the + * blocks, since a missing or duplicate key can't. + */ +function repairKeys( + value: Array | undefined, + keyGenerator: () => string, +): Array { + const patches: Array = [] + const blockKeys = new Set() + + for (const [blockIndex, block] of (value ?? []).entries()) { + const [blockKey] = itemKey(block) + + if (blockKey === undefined || blockKeys.has(blockKey)) { + patches.push(set(keyGenerator(), [blockIndex, '_key'])) + } else { + blockKeys.add(blockKey) + } + + const children: unknown = Reflect.get(block, 'children') + const childKeys = new Set() + + for (const [childIndex, child] of (Array.isArray(children) + ? children + : [] + ).entries()) { + const [childKey] = itemKey(child) + + if (childKey === undefined || childKeys.has(childKey)) { + patches.push( + set(keyGenerator(), [blockIndex, 'children', childIndex, '_key']), + ) + } else { + childKeys.add(childKey) + } + } + } + + return patches +} + +function itemKey(item: unknown): Array { + if (typeof item !== 'object' || item === null || !('_key' in item)) { + return [] + } + + return typeof item._key === 'string' && item._key !== '' ? [item._key] : [] +} + +function isEqual(valueA: unknown, valueB: unknown): boolean { + if (valueA === valueB) { + return true + } + + if ( + typeof valueA !== 'object' || + typeof valueB !== 'object' || + valueA === null || + valueB === null || + Array.isArray(valueA) !== Array.isArray(valueB) + ) { + return false + } + + const keysA = Object.keys(valueA) + const keysB = Object.keys(valueB) + + return ( + keysA.length === keysB.length && + keysA.every( + (key) => + Object.hasOwn(valueB, key) && + isEqual(Reflect.get(valueA, key), Reflect.get(valueB, key)), + ) + ) +} diff --git a/packages/io/src/host.ts b/packages/io/src/host.ts new file mode 100644 index 0000000000..85188a25dd --- /dev/null +++ b/packages/io/src/host.ts @@ -0,0 +1,89 @@ +import type {IoEditor} from './editor' +import type {Load, MutationBatch, Transaction} from './types' + +export type PassThroughHost = { + /** The transaction a batch will be saved as. */ + getTransactionId: (batchId: string) => string + /** + * Saves a batch as another transaction, as when several batches go out in + * one request, and tells the editor before the request goes out. + */ + mapToTransaction: (batchId: string, transactionId: string) => void + forward: (transaction: Transaction) => void + reportAccepted: (batchId: string) => void + reportRejected: (batchId: string) => void + load: () => void + resync: (options: {discardUnsent: boolean}) => void +} + +/** + * Forwards the feed to the editor as it arrives, saves each batch as the + * transaction named by its batch ID, and fetches the server's copy for `load` + * and `resync`. Waiting for the batch in flight before a resync is the + * caller's job. + */ +export function createPassThroughHost({ + editor, + save, + fetchCopy, +}: { + editor: IoEditor + save: (batch: MutationBatch) => void + fetchCopy: () => Load +}): PassThroughHost { + const transactionIds = new Map() + + editor.on((event) => { + if (event.type !== 'mutation') { + return + } + + const {type: _type, ...batch} = event + transactionIds.set(batch.id, batch.id) + + if (!batch.final) { + editor.mutationSent({id: batch.id, transactionId: batch.id}) + } + + save(batch) + }) + + return { + getTransactionId: (batchId) => { + const transactionId = transactionIds.get(batchId) + + if (transactionId === undefined) { + throw new Error(`No batch "${batchId}" was saved`) + } + + return transactionId + }, + mapToTransaction: (batchId, transactionId) => { + transactionIds.set(batchId, transactionId) + editor.mutationSent({id: batchId, transactionId}) + }, + forward: (transaction) => { + editor.transaction({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev, + resultRev: transaction.resultRev, + patches: transaction.patches, + }) + }, + reportAccepted: (batchId) => { + editor.mutationAccepted({id: batchId}) + }, + reportRejected: (batchId) => { + editor.mutationRejected({id: batchId}) + }, + load: () => { + editor.load(fetchCopy()) + }, + resync: ({discardUnsent}) => { + editor.resync({ + ...fetchCopy(), + ...(discardUnsent ? {discardUnsent: true as const} : {}), + }) + }, + } +} diff --git a/packages/io/src/index.ts b/packages/io/src/index.ts new file mode 100644 index 0000000000..fb09bc6cff --- /dev/null +++ b/packages/io/src/index.ts @@ -0,0 +1,15 @@ +export {createIoEditor} from './editor' +export type {Clock, IoEditor, IoEditorEvent, IoEditorStatus} from './editor' +export {createPassThroughHost} from './host' +export type {PassThroughHost} from './host' +export type { + ChangeEvent, + ErrorEvent, + Load, + MutationAccepted, + MutationBatch, + MutationRejected, + MutationSent, + Resync, + Transaction, +} from './types' diff --git a/packages/io/src/test/server.ts b/packages/io/src/test/server.ts index 3436ed8f45..e829e5d064 100644 --- a/packages/io/src/test/server.ts +++ b/packages/io/src/test/server.ts @@ -1,12 +1,6 @@ -import { - applyAll, - set, - unset, - type Patch, - type Path, - type PathSegment, -} from '@portabletext/patches' +import {set, unset, type Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' +import {applyWithContentLakeSemantics} from '../content-lake' /** * A batch as the server sees it: the batch ID and its patches, scoped to the @@ -158,66 +152,3 @@ export function createServer(initial: { }, } } - -/** - * 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. Duplicate keys are stored as sent. - */ -function applyWithContentLakeSemantics( - value: Array | undefined, - patches: Array, -): Array | undefined { - return patches.reduce | undefined>( - (currentValue, patch) => - hasContainer(currentValue, patch.path) - ? applyAll(currentValue, [patch]) - : currentValue, - value, - ) -} - -function hasContainer(value: unknown, path: Path): boolean { - if (path.length === 0) { - return true - } - - let container = value - - for (const segment of path.slice(0, -1)) { - container = resolveSegment(container, segment) - } - - return typeof container === 'object' && container !== null -} - -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/types.ts b/packages/io/src/types.ts new file mode 100644 index 0000000000..574ac17f3c --- /dev/null +++ b/packages/io/src/types.ts @@ -0,0 +1,58 @@ +import type {Patch} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' + +/** + * One transaction the server recorded on the document the editor saves to, + * with its patches scoped to the field. + */ +export type Transaction = { + transactionId: string + previousRev: string | undefined + resultRev: string | undefined + patches: Array +} + +/** + * A batch of patches the editor hands to its host to save. `value` is the + * editor's content when the batch goes out, `undefined` when the field is + * empty. + */ +export type MutationBatch = { + id: string + patches: Array + value: Array | undefined + final?: true +} + +export type MutationSent = {id: string; transactionId: string} + +export type MutationRejected = {id: string} + +export type MutationAccepted = {id: string} + +/** + * The server's copy of the field and the document revision it is at. `rev` + * is `undefined` when the document doesn't exist. + */ +export type Load = { + value: Array | undefined + rev: string | undefined +} + +export type Resync = Load & {discardUnsent?: true} + +export type ErrorEvent = { + reason: 'out of order' | 'duplicate key' | 'patch failed' + transactionId?: string + patch?: Patch +} + +/** + * The model carries patches as a stand-in for the editor's operations: the + * action's patches for a local change, and a whole-value `set` for a + * re-derived screen. + */ +export type ChangeEvent = { + operations: Array + origin: 'local' | 'remote' +} From 8ce1144e954bbfcae0fea14ac7e43903b247f3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 10:30:57 +0200 Subject: [PATCH 05/85] test(io): run the 24 protocol scenarios against the model The world wires two editors with their hosts, the fake server and the network, with a shared key generator for the initial document and one per editor for new keys. The steps are the vocabulary from the scenarios page, one definition per keyword they appear under: happenings as `When`, checks as `Then`. `has sent batch N` asserts the exact count, `has sent nothing new` compares with the previous `has sent` check, `is in step` means no `error` since the previous check, and `shows` and `the server has` compare keys and the caret only when the expected notation names them. The `{textspec}` parameter is a greedy quoted string so keyed notation with inner quotes parses, and `{batch}` carries an editor-and-number pair because racejar passes at most three step arguments. All 24 scenarios (29 runs) pass. Breaking nine mechanisms one at a time turned eight of them red; the ninth (dropping the rejected batch from the screen) is covered by a unit test, since no scenario delivers a remote transaction while a rejected batch is on screen. --- packages/io/src/test/parameter-types.ts | 40 +++ packages/io/src/test/scenarios.test.ts | 52 ++-- packages/io/src/test/steps.ts | 368 +++++++++++++++++++++++ packages/io/src/test/world.ts | 373 ++++++++++++++++++++++++ 4 files changed, 813 insertions(+), 20 deletions(-) create mode 100644 packages/io/src/test/parameter-types.ts create mode 100644 packages/io/src/test/steps.ts create mode 100644 packages/io/src/test/world.ts diff --git a/packages/io/src/test/parameter-types.ts b/packages/io/src/test/parameter-types.ts new file mode 100644 index 0000000000..5a84c32cfe --- /dev/null +++ b/packages/io/src/test/parameter-types.ts @@ -0,0 +1,40 @@ +import {createParameterType} from 'racejar' +import type {IoEditorStatus} from '../editor' +import type {EditorName, ServerCopyName} from './world' + +export type BatchReference = {name: EditorName; batchNumber: number} + +export const parameterTypes = [ + createParameterType({ + name: 'editor', + matcher: /Editor A|Editor B/, + }), + createParameterType({ + name: 'batch', + matcher: /(Editor A|Editor B)'s batch (\d+)/, + transform: (name, batchNumber) => ({ + name: name === 'Editor A' ? 'Editor A' : 'Editor B', + batchNumber: Number.parseInt(batchNumber, 10), + }), + }), + createParameterType({ + name: 'textspec', + matcher: /"(.*)"/, + }), + createParameterType({ + name: 'style', + matcher: /"(normal|h1|h2|h3)"/, + }), + createParameterType({ + name: 'key', + matcher: /"([^"]+)"/, + }), + createParameterType({ + name: 'copy', + matcher: /no document|no field|an empty list/, + }), + createParameterType>({ + name: 'status', + matcher: /"(loading|ready)"/, + }), +] diff --git a/packages/io/src/test/scenarios.test.ts b/packages/io/src/test/scenarios.test.ts index d80c94deb6..e77c34d0e7 100644 --- a/packages/io/src/test/scenarios.test.ts +++ b/packages/io/src/test/scenarios.test.ts @@ -1,23 +1,35 @@ -import {Given, Then} from 'racejar' +import {Before} from 'racejar' import {Feature} from 'racejar/vitest' -import {expect} from 'vitest' +import keysFeature from '../../gherkin-spec/keys.feature?raw' +import lifecycleFeature from '../../gherkin-spec/lifecycle.feature?raw' +import listenersFeature from '../../gherkin-spec/listeners.feature?raw' +import loadingAndEmptyFeature from '../../gherkin-spec/loading-and-empty.feature?raw' +import otherEditorsFeature from '../../gherkin-spec/other-editors.feature?raw' +import outOfStepAndResyncFeature from '../../gherkin-spec/out-of-step-and-resync.feature?raw' +import sendingAndConfirmingFeature from '../../gherkin-spec/sending-and-confirming.feature?raw' +import {parameterTypes} from './parameter-types' +import {stepDefinitions, type Context} from './steps' +import {createWorld} from './world' -type Context = { - documentText: string -} +const features = [ + keysFeature, + lifecycleFeature, + listenersFeature, + loadingAndEmptyFeature, + otherEditorsFeature, + outOfStepAndResyncFeature, + sendingAndConfirmingFeature, +] -Feature({ - featureText: ` - Feature: Wiring - Scenario: A step runs - Given the document is "B: foo|" - Then the document text is "B: foo|"`, - stepDefinitions: [ - Given('the document is {string}', (context: Context, text: string) => { - context.documentText = text - }), - Then('the document text is {string}', (context: Context, text: string) => { - expect(context.documentText).toEqual(text) - }), - ], -}) +for (const featureText of features) { + Feature({ + featureText, + hooks: [ + Before((context: Context) => { + context.world = createWorld() + }), + ], + stepDefinitions, + parameterTypes, + }) +} diff --git a/packages/io/src/test/steps.ts b/packages/io/src/test/steps.ts new file mode 100644 index 0000000000..a41aa22011 --- /dev/null +++ b/packages/io/src/test/steps.ts @@ -0,0 +1,368 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import {Given, Then, When} from 'racejar' +import {expect} from 'vitest' +import {comparableTextspec, createsBlock, emptiesField} from '../document' +import type {IoEditorStatus} from '../editor' +import type {BatchReference} from './parameter-types' +import type {EditorName, ServerCopyName, World} from './world' + +export type Context = {world: World} + +export const stepDefinitions = [ + Given('the document is {textspec}', (context: Context, textspec: string) => { + context.world.documentIs(textspec) + }), + Given('the server has {textspec}', (context: Context, textspec: string) => { + context.world.serverHas(textspec) + }), + Given('the server has {copy}', (context: Context, copy: ServerCopyName) => { + context.world.serverHasCopy(copy) + }), + Given( + 'the server has one empty block {key}', + (context: Context, key: string) => { + context.world.serverHasEmptyBlock(key) + }, + ), + Given("the server's block has no key", (context: Context) => { + context.world.removeServerBlockKey() + }), + Given('an editor that claims the first load', (context: Context) => { + context.world.startEditors({claimLoad: true}) + }), + Given("an editor that doesn't claim the first load", (context: Context) => { + context.world.startEditors({claimLoad: false}) + }), + + ...userSteps(), + + When( + "the server receives {editor}'s batch {int}", + (context: Context, name: EditorName, batchNumber: number) => { + context.world.receive(name, batchNumber) + }, + ), + When( + 'the server receives {batch} and {batch} as one transaction', + (context: Context, first: BatchReference, second: BatchReference) => { + context.world.receiveAsOne(first, second) + }, + ), + When( + "the server receives {editor}'s final batch", + (context: Context, name: EditorName) => { + context.world.receiveFinal(name) + }, + ), + When( + "the server refuses {editor}'s batch {int}", + (context: Context, name: EditorName, batchNumber: number) => { + context.world.refuse(name, batchNumber) + }, + ), + When( + 'another field of the document is changed on the server', + (context: Context) => { + context.world.changeOtherField() + }, + ), + When( + "{editor} receives {editor}'s batch {int}", + ( + context: Context, + receiverName: EditorName, + senderName: EditorName, + batchNumber: number, + ) => { + context.world.deliverBatch(receiverName, senderName, batchNumber) + }, + ), + When( + "{editor} receives the other field's change", + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, 'the other field') + }, + ), + When( + "{editor}'s batch {int} comes back", + (context: Context, name: EditorName, batchNumber: number) => { + context.world.deliverBatch(name, name, batchNumber) + }, + ), + When('the document is deleted', (context: Context) => { + context.world.deleteDocument() + }), + When( + 'the document is recreated as {textspec}', + (context: Context, textspec: string) => { + context.world.recreateDocument(textspec) + }, + ), + When( + '{editor} receives the deletion', + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, 'the deletion') + }, + ), + When( + '{editor} receives the recreation', + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, 'the recreation') + }, + ), + When('the wait for the missing transaction runs out', (context: Context) => { + context.world.runOutHeldTransactionWait() + }), + + When( + "{editor}'s batch {int} is accepted", + (context: Context, name: EditorName, batchNumber: number) => { + context.world.accept(name, batchNumber) + }, + ), + When( + "{editor}'s batch {int} is rejected", + (context: Context, name: EditorName, batchNumber: number) => { + context.world.reject(name, batchNumber) + }, + ), + When('{editor} is resynced', (context: Context, name: EditorName) => { + context.world.resync(name, {discardUnsent: false}) + }), + When( + '{editor} is resynced, discarding unsent changes', + (context: Context, name: EditorName) => { + context.world.resync(name, {discardUnsent: true}) + }, + ), + When('{editor} is loaded', (context: Context, name: EditorName) => { + context.world.getEditor(name).host.load() + }), + When('the claim is released', (context: Context) => { + context.world.getEditor('Editor A').editor.releaseClaim() + }), + When('{editor} becomes read-only', (context: Context, name: EditorName) => { + context.world.getEditor(name).editor.updateReadOnly(true) + }), + When('{editor} is closed', (context: Context, name: EditorName) => { + context.world.getEditor(name).editor.close() + }), + + Then( + '{editor} shows {textspec}', + (context: Context, name: EditorName, textspec: string) => { + const {document} = context.world.getEditor(name).editor + const {actual, expected} = comparableTextspec( + {value: document.getValue(), selection: document.getSelection()}, + textspec, + ) + + expect(actual).toEqual(expected) + }, + ), + Then('the server has {textspec}', (context: Context, textspec: string) => { + const {actual, expected} = comparableTextspec( + {value: context.world.getServer().copy().value ?? [], selection: null}, + textspec, + ) + + expect(actual).toEqual(expected) + }), + Then('the server has no document', (context: Context) => { + expect(context.world.getServer().copy()).toEqual({ + value: undefined, + rev: undefined, + }) + }), + Then('the server has no field', (context: Context) => { + const copy = context.world.getServer().copy() + + expect(copy.value).toEqual(undefined) + expect(copy.rev).not.toEqual(undefined) + }), + Then( + 'every block in {editor} has a unique key', + (context: Context, name: EditorName) => { + const value = context.world.getEditor(name).editor.document.getValue() + + expect(duplicateOrMissingKeys(value)).toEqual([]) + }, + ), + Then('every block on the server has a unique key', (context: Context) => { + const value = context.world.getServer().copy().value ?? [] + + expect(duplicateOrMissingKeys(value)).toEqual([]) + }), + Then( + '{editor} has sent batch {int}', + (context: Context, name: EditorName, batchNumber: number) => { + const worldEditor = context.world.getEditor(name) + + expect(worldEditor.editor.sentBatches.length).toEqual(batchNumber) + worldEditor.checkedBatchCount = batchNumber + }, + ), + Then( + '{editor} has sent nothing new', + (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + + expect(worldEditor.editor.sentBatches.length).toEqual( + worldEditor.checkedBatchCount, + ) + }, + ), + Then( + '{editor} has sent a final batch', + (context: Context, name: EditorName) => { + expect( + context.world.getEditor(name).editor.sentBatches.at(-1)?.final, + ).toEqual(true) + }, + ), + Then( + "{editor}'s batch {int} creates the block", + (context: Context, name: EditorName, batchNumber: number) => { + expect( + createsBlock(context.world.getBatch(name, batchNumber).patches), + ).toEqual(true) + }, + ), + Then( + "{editor}'s batch {int} does not create a block", + (context: Context, name: EditorName, batchNumber: number) => { + expect( + createsBlock(context.world.getBatch(name, batchNumber).patches), + ).toEqual(false) + }, + ), + Then( + "{editor}'s batch {int} empties the field", + (context: Context, name: EditorName, batchNumber: number) => { + expect( + emptiesField(context.world.getBatch(name, batchNumber).patches), + ).toEqual(true) + }, + ), + Then( + '{editor} reports that it is out of step', + (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + const errorCount = worldEditor.editor.errors.length + + expect(errorCount).toBeGreaterThan(worldEditor.checkedErrorCount) + worldEditor.checkedErrorCount = errorCount + }, + ), + Then('{editor} is in step', (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + + expect( + worldEditor.editor.errors.slice(worldEditor.checkedErrorCount), + ).toEqual([]) + }), + Then('the resync is refused', (context: Context) => { + const resync = context.world.getLastResync() + const {editor} = context.world.getEditor(resync.editorName) + + expect(editor.warnings.length).toBeGreaterThan(resync.warningCount) + expect(editor.document.toTextspec({keys: true})).toEqual(resync.screen) + expect(editor.sentBatches.length).toEqual(resync.batchCount) + }), + Then( + "{editor}'s status is {status}", + (context: Context, name: EditorName, status: IoEditorStatus) => { + expect(context.world.getEditor(name).editor.getStatus()).toEqual(status) + }, + ), + Then( + '{editor} has emitted {int} change(s)', + (context: Context, name: EditorName, count: number) => { + expect(context.world.getEditor(name).editor.changes.length).toEqual(count) + }, + ), + Then( + '{editor} has emitted no change', + (context: Context, name: EditorName) => { + expect(context.world.getEditor(name).editor.changes).toEqual([]) + }, + ), +] + +/** + * Each user step twice: on Editor A when no editor is named, and on the named + * editor. + */ +function userSteps() { + const actions = [ + { + text: '{string} is typed', + run: (context: Context, name: EditorName, text: string) => + context.world.getEditor(name).editor.type(text), + }, + { + text: 'the caret is put after {string}', + run: (context: Context, name: EditorName, text: string) => + context.world.getEditor(name).editor.putCaretAfter(text), + }, + { + text: 'the style is set to {style}', + run: (context: Context, name: EditorName, style: string) => + context.world.getEditor(name).editor.setStyle(style), + }, + { + text: 'the block {textspec} is inserted', + run: (context: Context, name: EditorName, textspec: string) => + context.world.getEditor(name).editor.insertBlock(textspec), + }, + { + text: 'the block {string} is deleted', + run: (context: Context, name: EditorName, text: string) => + context.world.getEditor(name).editor.deleteBlock(text), + }, + ] + + return [ + ...actions.flatMap(({text, run}) => [ + When(text, (context: Context, argument: string) => + run(context, 'Editor A', argument), + ), + When( + `${text} in {editor}`, + (context: Context, argument: string, name: EditorName) => + run(context, name, argument), + ), + ]), + When('undo is performed', (context: Context) => { + context.world.getEditor('Editor A').editor.undo() + }), + When( + 'undo is performed in {editor}', + (context: Context, name: EditorName) => { + context.world.getEditor(name).editor.undo() + }, + ), + ] +} + +function duplicateOrMissingKeys( + value: Array, +): Array { + const seen = new Set() + + return value.flatMap((block, index) => { + const key: unknown = block._key + + if (typeof key !== 'string' || key === '') { + return [`block ${index} has no key`] + } + + if (seen.has(key)) { + return [`block ${index} repeats the key "${key}"`] + } + + seen.add(key) + + return [] + }) +} diff --git a/packages/io/src/test/world.ts b/packages/io/src/test/world.ts new file mode 100644 index 0000000000..2c63ddddda --- /dev/null +++ b/packages/io/src/test/world.ts @@ -0,0 +1,373 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {parseTextspec} from '../document' +import {createIoEditor, type IoEditor} from '../editor' +import {createPassThroughHost, type PassThroughHost} from '../host' +import type {MutationBatch} from '../types' +import {createNetwork, type Network} from './network' +import {createServer, type Server} from './server' + +export type EditorName = 'Editor A' | 'Editor B' + +export type ServerCopyName = 'no document' | 'no field' | 'an empty list' + +export type WorldEditor = { + editor: IoEditor + host: PassThroughHost + /** The batch count at the previous `has sent` check. */ + checkedBatchCount: number + /** The error count at the previous out-of-step or in-step check. */ + checkedErrorCount: number +} + +type Setup = { + server: Server + network: Network + editors: Record +} + +type ResyncAttempt = { + editorName: EditorName + warningCount: number + screen: string + batchCount: number +} + +export type World = ReturnType + +const heldTransactionTimeout = 10_000 + +/** + * One server, one network and two editors, each with a pass-through host. + * The server's initial document is set up first, and the editors are created + * on demand, so steps can shape the document before anyone loads it. + */ +export function createWorld() { + const documentKeyGenerator = createTestKeyGenerator('d-') + let initialDocument: {value: Array | undefined} | undefined + let setup: Setup | undefined + let lastResync: ResyncAttempt | undefined + const namedTransactionIds = new Map() + + function getSetup(): Setup { + if (!setup) { + throw new Error('No editors yet') + } + + return setup + } + + function startEditors({claimLoad}: {claimLoad: boolean}): Setup { + const server = createServer({ + documentId: 'document', + document: initialDocument, + }) + const network = createNetwork() + const editors = { + 'Editor A': createWorldEditor({ + name: 'Editor A', + server, + network, + claimLoad, + }), + 'Editor B': createWorldEditor({ + name: 'Editor B', + server, + network, + claimLoad, + }), + } + + setup = {server, network, editors} + + return setup + } + + function getEditor(name: EditorName): WorldEditor { + return getSetup().editors[name] + } + + function getBatch(name: EditorName, batchNumber: number): MutationBatch { + const batch = getEditor(name).editor.sentBatches[batchNumber - 1] + + if (!batch) { + throw new Error(`${name} has not sent batch ${batchNumber}`) + } + + return batch + } + + function publishReceived( + batches: Array<{name: EditorName; batch: MutationBatch}>, + transactionId: string, + ) { + const {server, network} = getSetup() + const [first, second] = batches.map(({batch}) => { + network.takeSaveRequest(batch.id) + return batch + }) + const transaction = second + ? server.receiveAsOne(first, second, transactionId) + : server.receive(first, transactionId) + + network.publish(transaction) + + for (const {name, batch} of batches) { + network.queueReply({ + editorId: name, + batchId: batch.id, + outcome: 'accepted', + }) + } + } + + function transactionCarrying(batchId: string): string { + const transaction = getSetup() + .server.getTransactions() + .find((candidate) => candidate.batchIds.includes(batchId)) + + if (!transaction) { + throw new Error(`The server has not received batch "${batchId}"`) + } + + return transaction.transactionId + } + + function deliverReply( + name: EditorName, + batchNumber: number, + outcome: 'accepted' | 'rejected', + ) { + const {network} = getSetup() + const batch = getBatch(name, batchNumber) + const reply = network + .getReplies() + .find((candidate) => candidate.batchId === batch.id) + + if (reply?.outcome !== outcome) { + throw new Error( + `No "${outcome}" reply is waiting for ${name}'s batch ${batchNumber}`, + ) + } + + network.deliverReply(batch.id) + } + + function recordNamedTransaction(name: string, transactionId: string) { + namedTransactionIds.set(name, transactionId) + return transactionId + } + + return { + getEditor, + getBatch, + getServer: () => getSetup().server, + + documentIs: (textspec: string) => { + const {value, caret} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + initialDocument = {value} + + for (const {editor, host} of Object.values( + startEditors({claimLoad: true}).editors, + )) { + host.load() + + if (caret) { + editor.document.setCaret(caret) + } + } + }, + serverHas: (textspec: string) => { + initialDocument = { + value: parseTextspec({keyGenerator: documentKeyGenerator}, textspec) + .value, + } + }, + serverHasCopy: (copy: ServerCopyName) => { + initialDocument = + copy === 'no document' + ? undefined + : {value: copy === 'no field' ? undefined : []} + }, + serverHasEmptyBlock: (key: string) => { + initialDocument = { + value: parseTextspec( + {keyGenerator: documentKeyGenerator}, + `B _key="${key}": |`, + ).value, + } + }, + removeServerBlockKey: () => { + const [block, ...rest] = initialDocument?.value ?? [] + + if (!block || rest.length > 0) { + throw new Error('Expected the server to have one block') + } + + const keylessBlock = {...block} + Reflect.deleteProperty(keylessBlock, '_key') + initialDocument = {value: [keylessBlock]} + }, + startEditors, + + receive: (name: EditorName, batchNumber: number) => { + const batch = getBatch(name, batchNumber) + publishReceived( + [{name, batch}], + getEditor(name).host.getTransactionId(batch.id), + ) + }, + receiveFinal: (name: EditorName) => { + const batch = getEditor(name).editor.sentBatches.at(-1) + + if (!batch?.final) { + throw new Error(`${name} has not sent a final batch`) + } + + publishReceived( + [{name, batch}], + getEditor(name).host.getTransactionId(batch.id), + ) + }, + receiveAsOne: ( + first: {name: EditorName; batchNumber: number}, + second: {name: EditorName; batchNumber: number}, + ) => { + const batches = [first, second].map(({name, batchNumber}) => ({ + name, + batch: getBatch(name, batchNumber), + })) + const transactionId = batches.map(({batch}) => batch.id).join('+') + + for (const {name, batch} of batches) { + getEditor(name).host.mapToTransaction(batch.id, transactionId) + } + + publishReceived(batches, transactionId) + }, + refuse: (name: EditorName, batchNumber: number) => { + const {server, network} = getSetup() + const batch = getBatch(name, batchNumber) + network.takeSaveRequest(batch.id) + server.refuse(batch.id) + network.queueReply({ + editorId: name, + batchId: batch.id, + outcome: 'rejected', + }) + }, + changeOtherField: () => { + const {server, network} = getSetup() + network.publish( + server.changeOtherField( + recordNamedTransaction('the other field', 'other-field-1'), + ), + ) + }, + deleteDocument: () => { + const {server, network} = getSetup() + network.publish( + server.deleteDocument( + recordNamedTransaction('the deletion', 'deletion-1'), + ), + ) + }, + recreateDocument: (textspec: string) => { + const {server, network} = getSetup() + const {value} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + network.publish( + server.recreate( + value, + recordNamedTransaction('the recreation', 'recreation-1'), + ), + ) + }, + deliverBatch: ( + receiverName: EditorName, + senderName: EditorName, + batchNumber: number, + ) => { + getSetup().network.deliver( + receiverName, + transactionCarrying(getBatch(senderName, batchNumber).id), + ) + }, + deliverNamed: ( + receiverName: EditorName, + name: 'the other field' | 'the deletion' | 'the recreation', + ) => { + const transactionId = namedTransactionIds.get(name) + + if (transactionId === undefined) { + throw new Error(`No transaction for ${name} yet`) + } + + getSetup().network.deliver(receiverName, transactionId) + }, + runOutHeldTransactionWait: () => { + getSetup().network.clock.advance(heldTransactionTimeout) + }, + + accept: (name: EditorName, batchNumber: number) => + deliverReply(name, batchNumber, 'accepted'), + reject: (name: EditorName, batchNumber: number) => + deliverReply(name, batchNumber, 'rejected'), + resync: (name: EditorName, {discardUnsent}: {discardUnsent: boolean}) => { + const {editor, host} = getEditor(name) + lastResync = { + editorName: name, + warningCount: editor.warnings.length, + screen: editor.document.toTextspec({keys: true}), + batchCount: editor.sentBatches.length, + } + host.resync({discardUnsent}) + }, + getLastResync: () => { + if (!lastResync) { + throw new Error('No resync yet') + } + + return lastResync + }, + } +} + +function createWorldEditor({ + name, + server, + network, + claimLoad, +}: { + name: EditorName + server: Server + network: Network + claimLoad: boolean +}): WorldEditor { + const editor = createIoEditor({ + id: name === 'Editor A' ? 'A' : 'B', + keyGenerator: createTestKeyGenerator(name === 'Editor A' ? 'a-' : 'b-'), + clock: network.clock, + claimLoad, + }) + const host = createPassThroughHost({ + editor, + save: (batch) => network.send(name, batch), + fetchCopy: () => server.copy(), + }) + + network.connect(name, { + receiveTransaction: host.forward, + receiveReply: (reply) => + reply.outcome === 'accepted' + ? host.reportAccepted(reply.batchId) + : host.reportRejected(reply.batchId), + }) + + return {editor, host, checkedBatchCount: 0, checkedErrorCount: 0} +} From 46a4b7097b78187896fe6edd695b3f8c5a1cd0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 11:08:48 +0200 Subject: [PATCH 06/85] fix(io): derive undo from the current document and make the host keep its rules Undo no longer replays stored inverse patches. Each action records what it did, and undo reverts that through the document's own actions, so the empty-field rule decides whether an `unset([])` is needed and a delete is only re-inserted while its key is absent. Replaying inverses let undoing the first keystroke into an empty field wipe another writer's block, and let undoing a delete restore a stale block. With undo derived at undo time, the editor no longer needs to find its own patches inside a mixed transaction, so that mechanism and the stored-inverse rebasing are gone. The host now applies the feed handoff (drops what the feed delivered up to the copy's revision when it fetches a copy) and saves the `final` batch only after the in-flight batch's request was taken. Content Lake semantics distinguish a missing parent (no-op) from traversal into a primitive (`patch failed`). Keys: a local insert re-keys at action time when a sibling holds its key, repairs generate keys that collide with nothing, and an insert carrying the same key twice is a `duplicate key`. Liveness warnings, `ready` for an unclaimed editor (on `mount`), the resync warning for unsent parts that didn't apply, and rejecting inputs after unmount are in. The comparison helper reads named keys from the notation's own `_key` attributes. The steps observe delivered events only: the world subscribes to each editor at creation and records what listeners receive, and the server takes the request the host queued instead of the editor's own record. --- packages/io/package.json | 3 +- packages/io/src/content-lake.test.ts | 70 +++ packages/io/src/content-lake.ts | 54 +- packages/io/src/document.test.ts | 342 +++++++++---- packages/io/src/document.ts | 340 ++++++++++--- packages/io/src/editor.test.ts | 729 +++++++++++++++++++++++++-- packages/io/src/editor.ts | 432 +++++++++++----- packages/io/src/host.test.ts | 182 +++++++ packages/io/src/host.ts | 80 ++- packages/io/src/test/network.test.ts | 29 +- packages/io/src/test/network.ts | 4 + packages/io/src/test/steps.ts | 29 +- packages/io/src/test/world.ts | 134 +++-- pnpm-lock.yaml | 3 + 14 files changed, 2033 insertions(+), 398 deletions(-) create mode 100644 packages/io/src/content-lake.test.ts create mode 100644 packages/io/src/host.test.ts diff --git a/packages/io/package.json b/packages/io/package.json index e7e856b025..5ff20b65f9 100644 --- a/packages/io/package.json +++ b/packages/io/package.json @@ -17,7 +17,8 @@ "dependencies": { "@portabletext/patches": "workspace:^", "@portabletext/schema": "workspace:^", - "@portabletext/test": "workspace:^" + "@portabletext/test": "workspace:^", + "@textspec/notation": "^1.0.2" }, "devDependencies": { "@sanity/tsconfig": "catalog:tooling", diff --git a/packages/io/src/content-lake.test.ts b/packages/io/src/content-lake.test.ts new file mode 100644 index 0000000000..7cb9bd0997 --- /dev/null +++ b/packages/io/src/content-lake.test.ts @@ -0,0 +1,70 @@ +import {diffMatchPatch, insert, set, unset} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {applyWithContentLakeSemantics, hasTarget} from './content-lake' +import {parseTextspec} from './document' + +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 path through a primitive throws', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo') + + expect(() => + applyWithContentLakeSemantics(value, [ + set('x', [{_key: 'k0'}, 'style', 'name']), + ]), + ).toThrow(`Can't follow "name" into a string`) + expect(() => + applyWithContentLakeSemantics(value, [ + unset([{_key: 'k0'}, 'children', {_key: 'k1'}, 'text', 0]), + ]), + ).toThrow(`Can't follow 0 into a string`) + expect(() => + applyWithContentLakeSemantics(value, [ + set('x', [{_key: 'k0'}, 'style', 'name', 'first']), + ]), + ).toThrow(`Can't follow "name" into a string`) + }) +}) + +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) + }) +}) diff --git a/packages/io/src/content-lake.ts b/packages/io/src/content-lake.ts index 0e603d9a4f..a20d1973a7 100644 --- a/packages/io/src/content-lake.ts +++ b/packages/io/src/content-lake.ts @@ -10,7 +10,9 @@ 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. Duplicate keys are stored as sent. + * patches are skipped here. A path that runs through a string, number or + * boolean can't be evaluated at all, and throws. Duplicate keys are stored + * as sent. */ export function applyWithContentLakeSemantics( value: Array | undefined, @@ -39,14 +41,60 @@ export function resolvePath(value: unknown, path: Path): unknown { 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. + * Throws, like `applyWithContentLakeSemantics`, for a path through a + * primitive. + */ +export function hasTarget( + value: Array | undefined, + patch: Patch, +): boolean { + if (!hasContainer(value, patch.path)) { + return false + } + + const last = patch.path.at(-1) + + if (last === undefined || typeof last === 'string') { + return true + } + + return resolvePath(value, patch.path) !== undefined +} + function hasContainer(value: unknown, path: Path): boolean { if (path.length === 0) { return true } - const container = resolvePath(value, path.slice(0, -1)) + let container = value - return typeof container === 'object' && container !== null + for (const segment of path.slice(0, -1)) { + if (container === undefined || container === null) { + return false + } + + assertTraversable(container, segment) + container = resolveSegment(container, segment) + } + + if (container === undefined || container === null) { + return false + } + + assertTraversable(container, path[path.length - 1]) + + return true +} + +function assertTraversable(container: unknown, segment: PathSegment) { + if (typeof container !== 'object') { + throw new Error( + `Can't follow ${JSON.stringify(segment)} into a ${typeof container}`, + ) + } } function resolveSegment(container: unknown, segment: PathSegment): unknown { diff --git a/packages/io/src/document.test.ts b/packages/io/src/document.test.ts index 54ea8b0910..3c1273c8b3 100644 --- a/packages/io/src/document.test.ts +++ b/packages/io/src/document.test.ts @@ -102,11 +102,10 @@ describe(createDocument.name, () => { expect(result).toEqual({ patches: [set('h1', [{_key: 'k2'}, 'style'])], - inversePatches: [set('normal', [{_key: 'k2'}, 'style'])], + undoStep: {type: 'styled', blockKey: 'k2', previousStyle: 'normal'}, }) expect(document.toTextspec()).toEqual('B: foo;;H1: ba|r') expect(applyAll(before, result.patches)).toEqual(document.getValue()) - expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) }) test('setting an unknown style throws', () => { @@ -138,18 +137,16 @@ describe(createDocument.name, () => { 'text', ]), ], - inversePatches: [ - diffMatchPatch('foxyo', 'foo', [ - {_key: 'k0'}, - 'children', - {_key: 'k1'}, - 'text', - ]), - ], + undoStep: { + type: 'typed', + blockKey: 'k0', + spanKey: 'k1', + offset: 2, + text: 'xy', + }, }) expect(document.toTextspec()).toEqual('B: foxy|o;;B: bar') expect(applyAll(before, result.patches)).toEqual(document.getValue()) - expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) }) test('the caret is put after text found in one block', () => { @@ -208,13 +205,12 @@ describe(createDocument.name, () => { [{_key: 'k0'}], ), ], - inversePatches: [unset([{_key: 'k9'}])], + undoStep: {type: 'inserted', blockKey: 'k9'}, }) expect(document.toTextspec({keys: true})).toEqual( 'B _key="k0": foo;;B _key="k9": baz|;;B _key="k2": bar', ) expect(applyAll(before, result.patches)).toEqual(document.getValue()) - expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) }) test('an inserted block without a named key gets a generated one', () => { @@ -241,7 +237,7 @@ describe(createDocument.name, () => { [{_key: 'k0'}], ), ], - inversePatches: [unset([{_key: 'k2'}])], + undoStep: {type: 'inserted', blockKey: 'k2'}, }) expect(document.toTextspec()).toEqual('B: foo;;H2: baz|') }) @@ -258,27 +254,23 @@ describe(createDocument.name, () => { expect(result).toEqual({ patches: [unset([{_key: 'k2'}])], - inversePatches: [ - insert( - [ - { - _type: 'block', - _key: 'k2', - children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], - style: 'normal', - }, - ], - 'after', - [{_key: 'k0'}], - ), - ], + undoStep: { + type: 'deleted', + block: { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + style: 'normal', + }, + previousKey: 'k0', + nextKey: 'k4', + }, }) expect(document.toTextspec()).toEqual('B: foo|;;B: baz') expect(applyAll(before, result.patches)).toEqual(document.getValue()) - expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) }) - test('deleting the first block puts it back before its next sibling on undo', () => { + test('deleting the first block records that it had no previous sibling', () => { const keyGenerator = createTestKeyGenerator() const document = createDocument( {keyGenerator}, @@ -290,23 +282,20 @@ describe(createDocument.name, () => { expect(result).toEqual({ patches: [unset([{_key: 'k0'}])], - inversePatches: [ - insert( - [ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - style: 'normal', - }, - ], - 'before', - [{_key: 'k2'}], - ), - ], + undoStep: { + type: 'deleted', + block: { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + previousKey: undefined, + nextKey: 'k2', + }, }) expect(document.toTextspec()).toEqual('B: |bar') - expect(applyAll(document.getValue(), result.inversePatches)).toEqual(before) + expect(applyAll(before, result.patches)).toEqual(document.getValue()) }) test('deleting another block leaves the caret where it is', () => { @@ -336,6 +325,37 @@ describe(createDocument.name, () => { ) }) + test('an inserted block whose key a sibling has gets a new key', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B _key="k9": foo|'), + ) + + const result = document.insertBlock('B _key="k9": bar') + + expect(result).toEqual({ + patches: [ + insert( + [ + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k1', text: 'bar', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'k9'}], + ), + ], + undoStep: {type: 'inserted', blockKey: 'k2'}, + }) + 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 = createDocument( @@ -363,6 +383,146 @@ describe(createDocument.name, () => { }) }) +describe('reverting a change', () => { + test('typed text is deleted while it is still at its offset, and the caret moves back', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: fooxy|'), + ) + const typed = { + type: 'typed' as const, + blockKey: 'k0', + spanKey: 'k1', + offset: 3, + text: 'x', + } + + expect(document.deleteText(typed)).toEqual([ + diffMatchPatch('fooxy', 'fooy', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ]) + expect(document.toTextspec()).toEqual('B: fooy|') + expect(document.deleteText(typed)).toEqual([]) + expect(document.deleteText({...typed, spanKey: 'k8'})).toEqual([]) + expect(document.deleteText({...typed, blockKey: 'k9'})).toEqual([]) + expect(document.toTextspec()).toEqual('B: fooy|') + }) + + test("a block's style is set or removed, and a gone block is left alone", () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'H1: foo|'), + ) + + expect(document.setBlockStyle('k0', 'h2')).toEqual([ + set('h2', [{_key: 'k0'}, 'style']), + ]) + expect(document.setBlockStyle('k0', undefined)).toEqual([ + unset([{_key: 'k0'}, 'style']), + ]) + expect(document.getValue()).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + ]) + expect(document.setBlockStyle('k9', 'h1')).toEqual([]) + }) + + test('a block deleted by key empties the field when it was the last, and a gone block is left alone', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo|;;B: bar'), + ) + + expect(document.deleteBlockByKey('k9')).toEqual([]) + expect(document.deleteBlockByKey('k2')).toEqual([unset([{_key: 'k2'}])]) + expect(document.deleteBlockByKey('k0')).toEqual([ + unset([{_key: 'k0'}]), + unset([]), + ]) + expect(document.getPlaceholderKey()).toEqual('k4') + }) + + test('a deleted block goes back after its previous sibling, else before its next one, else first', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo;;B: bar;;B: baz') + const [fooBlock, barBlock, bazBlock] = value + const document = createDocument({keyGenerator}, {value: [bazBlock]}) + + expect( + document.restoreBlock({ + type: 'deleted', + block: barBlock, + previousKey: fooBlock._key, + nextKey: bazBlock._key, + }), + ).toEqual([insert([barBlock], 'before', [{_key: bazBlock._key}])]) + expect( + document.restoreBlock({ + type: 'deleted', + block: fooBlock, + previousKey: 'k9', + nextKey: 'k8', + }), + ).toEqual([insert([fooBlock], 'before', [{_key: barBlock._key}])]) + expect(document.toTextspec()).toEqual('B: foo;;B: bar;;B: |baz') + + document.deleteBlockByKey(bazBlock._key) + + expect( + document.restoreBlock({ + type: 'deleted', + block: bazBlock, + previousKey: barBlock._key, + nextKey: undefined, + }), + ).toEqual([insert([bazBlock], 'after', [{_key: barBlock._key}])]) + expect(document.toTextspec()).toEqual('B: foo;;B: bar|;;B: baz') + }) + + test('a deleted block is not put back while its key is on screen', () => { + const keyGenerator = createTestKeyGenerator() + const {value} = parseTextspec({keyGenerator}, 'B: foo|;;B: bar') + const document = createDocument({keyGenerator}, {value}) + + expect( + document.restoreBlock({ + type: 'deleted', + block: value[1], + previousKey: value[0]._key, + nextKey: undefined, + }), + ).toEqual([]) + expect(document.getValue()).toEqual(value) + }) + + test('a deleted block put back into an empty field replaces the placeholder', () => { + const keyGenerator = createTestKeyGenerator() + const [block] = parseTextspec({keyGenerator}, 'B: foo').value + const document = createDocument({keyGenerator}, {value: undefined}) + + expect( + document.restoreBlock({ + type: 'deleted', + block, + previousKey: undefined, + nextKey: undefined, + }), + ).toEqual([setIfMissing([], []), insert([block], 'before', [0])]) + expect(document.getPlaceholderKey()).toEqual(undefined) + expect(document.getValue()).toEqual([block]) + }) +}) + describe('the placeholder', () => { test('an empty field shows one empty block that is not content yet', () => { const keyGenerator = createTestKeyGenerator() @@ -421,16 +581,13 @@ describe('the placeholder', () => { 'text', ]), ], - inversePatches: [ - diffMatchPatch('x', '', [ - {_key: 'k0'}, - 'children', - {_key: 'k1'}, - 'text', - ]), - unset([{_key: 'k0'}]), - unset([]), - ], + undoStep: { + type: 'typed', + blockKey: 'k0', + spanKey: 'k1', + offset: 0, + text: 'x', + }, }) expect(createsBlock(firstResult.patches)).toEqual(true) expect(document.getPlaceholderKey()).toEqual(undefined) @@ -438,9 +595,6 @@ describe('the placeholder', () => { expect(applyAll(undefined, firstResult.patches)).toEqual( document.getValue(), ) - expect(applyAll(document.getValue(), firstResult.inversePatches)).toEqual( - undefined, - ) const secondResult = document.type('y') @@ -453,14 +607,13 @@ describe('the placeholder', () => { 'text', ]), ], - inversePatches: [ - diffMatchPatch('xy', 'x', [ - {_key: 'k0'}, - 'children', - {_key: 'k1'}, - 'text', - ]), - ], + undoStep: { + type: 'typed', + blockKey: 'k0', + spanKey: 'k1', + offset: 1, + text: 'y', + }, }) expect(createsBlock(secondResult.patches)).toEqual(false) }) @@ -556,27 +709,22 @@ describe('the placeholder', () => { expect(deleteResult).toEqual({ patches: [unset([{_key: 'k0'}]), unset([])], - inversePatches: [ - setIfMissing([], []), - insert( - [ - { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], - style: 'normal', - }, - ], - 'before', - [0], - ), - ], + undoStep: { + type: 'deleted', + block: { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + style: 'normal', + }, + previousKey: undefined, + nextKey: undefined, + }, }) expect(emptiesField(deleteResult.patches)).toEqual(true) expect(document.getPlaceholderKey()).toEqual('k2') expect(document.toTextspec({keys: true})).toEqual('B _key="k2": |') expect(applyAll(before, deleteResult.patches)).toEqual(undefined) - expect(applyAll(undefined, deleteResult.inversePatches)).toEqual(before) const typeResult = document.type('x') @@ -588,7 +736,7 @@ describe('the placeholder', () => { const keyGenerator = createTestKeyGenerator() const document = createDocument({keyGenerator}, {value: undefined}) - expect(document.deleteBlock('')).toEqual({patches: [], inversePatches: []}) + expect(document.deleteBlock('')).toEqual({patches: [], undoStep: undefined}) expect(document.getPlaceholderKey()).toEqual('k0') }) @@ -649,6 +797,18 @@ describe(comparableTextspec.name, () => { }) }) + 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( @@ -705,11 +865,8 @@ describe(comparableTextspec.name, () => { describe(createsBlock.name, () => { test('needs a whole-field setIfMissing followed by an insert', () => { - const block = { - _type: 'block', - _key: 'k0', - children: [{_type: 'span', _key: 'k1', text: '', marks: []}], - } + const keyGenerator = createTestKeyGenerator() + const [block] = parseTextspec({keyGenerator}, 'B: ').value expect( createsBlock([setIfMissing([], []), insert([block], 'before', [0])]), @@ -717,7 +874,7 @@ describe(createsBlock.name, () => { expect(createsBlock([insert([block], 'before', [0])])).toEqual(false) expect( createsBlock([ - setIfMissing([], [{_key: 'k0'}, 'children']), + setIfMissing([], [{_key: block._key}, 'children']), insert([block], 'before', [0]), ]), ).toEqual(false) @@ -727,7 +884,10 @@ describe(createsBlock.name, () => { describe(emptiesField.name, () => { test('needs a whole-field unset', () => { - expect(emptiesField([unset([{_key: 'k0'}]), unset([])])).toEqual(true) - expect(emptiesField([unset([{_key: 'k0'}])])).toEqual(false) + 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) }) }) diff --git a/packages/io/src/document.ts b/packages/io/src/document.ts index 0d47093367..6e1a2f2e55 100644 --- a/packages/io/src/document.ts +++ b/packages/io/src/document.ts @@ -16,10 +16,12 @@ import { type PortableTextTextBlock, } from '@portabletext/schema' import { + createTestKeyGenerator, fromTextspec, toTextspec, type TextspecSelection, } from '@portabletext/test' +import {parse} from '@textspec/notation' const schema = compileSchema( defineSchema({styles: [{name: 'h1'}, {name: 'h2'}]}), @@ -32,12 +34,35 @@ const schema = compileSchema( export type Caret = {blockKey: string; offset: number} /** - * What a user action produced: the patches the editor sends, and the patches - * that undo them, in the order they apply. + * What an action did, in terms undo can check against the content at undo + * time: text typed into a span at an offset, a block's style replaced, a + * block inserted, or a block deleted next to its siblings. + */ +export type UndoStep = + | { + type: 'typed' + blockKey: string + spanKey: string + offset: number + text: string + } + | {type: 'styled'; blockKey: string; previousStyle: string | undefined} + | {type: 'inserted'; blockKey: string} + | { + type: 'deleted' + block: PortableTextBlock + previousKey: string | undefined + nextKey: string | undefined + } + +/** + * What a user action produced: the patches the editor sends, and what undo + * needs to revert it. Creating the block from the placeholder isn't part of + * the undo step. */ export type ActionResult = { patches: Array - inversePatches: Array + undoStep: UndoStep | undefined } export type Document = { @@ -60,6 +85,20 @@ export type Document = { putCaretAfter: (text: string) => void insertBlock: (textspec: string) => ActionResult deleteBlock: (text: string) => ActionResult + /** + * Removes typed text if it is still in its span at its offset. Returns no + * patches when it isn't. + */ + deleteText: (typed: Extract) => Array + /** Returns no patches when the block is gone. */ + setBlockStyle: (blockKey: string, style: string | undefined) => Array + /** Returns no patches when the block is gone. */ + deleteBlockByKey: (blockKey: string) => Array + /** + * Puts a deleted block back after its previous sibling, or else before its + * next one, or else first. Returns no patches when its key is on screen. + */ + restoreBlock: (deleted: Extract) => Array } export function createDocument( @@ -137,47 +176,66 @@ export function createDocument( return block } - function withPlaceholderCreation(result: ActionResult): ActionResult { + function withPlaceholderCreation(patches: Array): Array { if (placeholderKey === undefined) { - return result + return patches } const placeholder = findBlock(placeholderKey) - const createdKey = placeholderKey placeholderKey = undefined + return [ + setIfMissing([], []), + insert([placeholder], 'before', [0]), + ...patches, + ] + } + + function setStyle(style: string): ActionResult { + const block = getTextBlock(findBlock(caret.blockKey)).block + return { - patches: [ - setIfMissing([], []), - insert([placeholder], 'before', [0]), - ...result.patches, - ], - inversePatches: [ - ...result.inversePatches, - unset([{_key: createdKey}]), - unset([]), - ], + patches: setBlockStyle(block._key, style), + undoStep: { + type: 'styled', + blockKey: block._key, + previousStyle: block.style, + }, } } - function setStyle(style: string): ActionResult { - if (!schema.styles.some((definition) => definition.name === style)) { + function setBlockStyle( + blockKey: string, + style: string | undefined, + ): Array { + if ( + style !== undefined && + !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 path = [{_key: block._key}, 'style'] - const result = withPlaceholderCreation({ - patches: [set(style, path)], - inversePatches: [ - block.style === undefined ? unset(path) : set(block.style, path), - ], - }) + const blockIndex = value.findIndex((block) => block._key === blockKey) - value = replaceAt(value, blockIndex, {...block, style}) + if (blockIndex === -1) { + return [] + } + + const {style: _previousStyle, ...block} = getTextBlock( + value[blockIndex], + ).block + const path = [{_key: blockKey}, 'style'] + const patches = withPlaceholderCreation([ + style === undefined ? unset(path) : set(style, path), + ]) + + value = replaceAt( + value, + blockIndex, + style === undefined ? block : {...block, style}, + ) - return result + return patches } function type(text: string): ActionResult { @@ -186,10 +244,9 @@ export function createDocument( 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 result = withPlaceholderCreation({ - patches: [diffMatchPatch(span.text, nextText, path)], - inversePatches: [diffMatchPatch(nextText, span.text, path)], - }) + const patches = withPlaceholderCreation([ + diffMatchPatch(span.text, nextText, path), + ]) value = replaceAt(value, blockIndex, { ...block, @@ -199,7 +256,76 @@ export function createDocument( }) caret = {blockKey: block._key, offset: caret.offset + text.length} - return result + return { + patches, + undoStep: { + type: 'typed', + blockKey: block._key, + spanKey: span._key, + offset, + text, + }, + } + } + + function deleteText(typed: Extract): Array { + const blockIndex = value.findIndex((block) => block._key === typed.blockKey) + + if (blockIndex === -1) { + return [] + } + + const block = getTextBlock(value[blockIndex]).block + const spanIndex = block.children.findIndex( + (child) => child._key === typed.spanKey && isSpan({schema}, child), + ) + const span = block.children[spanIndex] + + if ( + spanIndex === -1 || + !isSpan({schema}, span) || + span.text.slice(typed.offset, typed.offset + typed.text.length) !== + typed.text + ) { + return [] + } + + const nextText = + span.text.slice(0, typed.offset) + + span.text.slice(typed.offset + typed.text.length) + const spanStart = block.children + .slice(0, spanIndex) + .reduce( + (length, child) => + length + (isSpan({schema}, child) ? child.text.length : 0), + 0, + ) + const deletionStart = spanStart + typed.offset + + value = replaceAt(value, blockIndex, { + ...block, + children: block.children.map((child) => + child._key === span._key ? {...span, text: nextText} : child, + ), + }) + + if (caret.blockKey === block._key && caret.offset > deletionStart) { + caret = { + blockKey: block._key, + offset: + deletionStart + + Math.max(0, caret.offset - deletionStart - typed.text.length), + } + } + + return [ + diffMatchPatch(span.text, nextText, [ + {_key: block._key}, + 'children', + {_key: span._key}, + 'text', + ]), + ] } function putCaretAfter(text: string) { @@ -237,13 +363,18 @@ export function createDocument( ) } - const newBlock = blocks[0] + 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 result = withPlaceholderCreation({ - patches: [insert([newBlock], 'after', [{_key: caretBlockKey}])], - inversePatches: [unset([{_key: newBlock._key}])], - }) + const patches = withPlaceholderCreation([ + insert([newBlock], 'after', [{_key: caretBlockKey}]), + ]) value = [ ...value.slice(0, blockIndex + 1), @@ -255,7 +386,7 @@ export function createDocument( offset: getTextBlock(newBlock).text.length, } - return result + return {patches, undoStep: {type: 'inserted', blockKey: newBlock._key}} } function deleteBlock(text: string): ActionResult { @@ -270,31 +401,38 @@ export function createDocument( const block = matches[0] if (block._key === placeholderKey) { - return {patches: [], inversePatches: []} + return {patches: [], undoStep: undefined} } const blockIndex = value.indexOf(block) + const undoStep: UndoStep = { + type: 'deleted', + block, + previousKey: value[blockIndex - 1]?._key, + nextKey: value[blockIndex + 1]?._key, + } + + return {patches: deleteBlockByKey(block._key), undoStep} + } + + function deleteBlockByKey(blockKey: string): Array { + const blockIndex = value.findIndex((block) => block._key === blockKey) + + if (blockIndex === -1 || blockKey === placeholderKey) { + return [] + } + const previousBlock = value[blockIndex - 1] const nextBlock = value[blockIndex + 1] - const reinsert = previousBlock - ? insert([block], 'after', [{_key: previousBlock._key}]) - : nextBlock - ? insert([block], 'before', [{_key: nextBlock._key}]) - : insert([block], 'before', [0]) - const remainingValue = value.filter( - (candidate) => candidate._key !== block._key, - ) + const remainingValue = value.filter((block) => block._key !== blockKey) if (remainingValue.length === 0) { setValue(undefined) - return { - patches: [unset([{_key: block._key}]), unset([])], - inversePatches: [setIfMissing([], []), reinsert], - } + return [unset([{_key: blockKey}]), unset([])] } - if (caret.blockKey === block._key) { + if (caret.blockKey === blockKey) { caret = previousBlock ? { blockKey: previousBlock._key, @@ -305,10 +443,50 @@ export function createDocument( value = remainingValue - return { - patches: [unset([{_key: block._key}])], - inversePatches: [reinsert], + return [unset([{_key: blockKey}])] + } + + function restoreBlock( + deleted: Extract, + ): Array { + const {block} = deleted + + if (value.some((candidate) => candidate._key === block._key)) { + return [] + } + + if (placeholderKey !== undefined) { + setValue([block]) + + return [setIfMissing([], []), insert([block], 'before', [0])] + } + + const previousIndex = value.findIndex( + (candidate) => candidate._key === deleted.previousKey, + ) + const nextIndex = value.findIndex( + (candidate) => candidate._key === deleted.nextKey, + ) + + if (previousIndex !== -1) { + value = [ + ...value.slice(0, previousIndex + 1), + block, + ...value.slice(previousIndex + 1), + ] + + return [insert([block], 'after', [{_key: value[previousIndex]._key}])] } + + const referenceIndex = nextIndex === -1 ? 0 : nextIndex + const reference = value[referenceIndex]._key + value = [ + ...value.slice(0, referenceIndex), + block, + ...value.slice(referenceIndex), + ] + + return [insert([block], 'before', [{_key: reference}])] } return { @@ -329,9 +507,32 @@ export function createDocument( putCaretAfter, insertBlock, deleteBlock, + deleteText, + setBlockStyle, + deleteBlockByKey, + restoreBlock, } } +/** + * Calls the key generator until it returns a key that isn't taken, and marks + * that key as taken. + */ +export 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. @@ -388,13 +589,15 @@ export function comparableTextspec( actual: {value: Array; selection: TextspecSelection}, expected: string, ): {actual: string; expected: string} { - const generatedKeys = new Set() - const keyGenerator = createRecordingKeyGenerator(generatedKeys) - const parsed = fromTextspec({schema, keyGenerator}, expected) + const parsed = fromTextspec( + {schema, keyGenerator: createTestKeyGenerator('expected-')}, + expected, + ) const namedKeys = new Set( - parsed.blocks - .map((block) => block._key) - .filter((key) => !generatedKeys.has(key)), + 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) @@ -495,17 +698,6 @@ function hasCaret(textspec: string): boolean { return /(?) { - let index = 0 - - return function keyGenerator() { - const key = `expected-k${index}` - index++ - generatedKeys.add(key) - return key - } -} - /** * Serializes single-line textspec, one block at a time so each block's prefix * can be written the way the scenarios write it: `H1: foo` rather than the diff --git a/packages/io/src/editor.test.ts b/packages/io/src/editor.test.ts index 5a8e0ff2f4..519081c386 100644 --- a/packages/io/src/editor.test.ts +++ b/packages/io/src/editor.test.ts @@ -1,13 +1,20 @@ -import {diffMatchPatch, insert, set, unset} from '@portabletext/patches' +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 {createIoEditor} from './editor' import {createNetwork} from './test/network' +import {listenTo} from './test/world' describe(createIoEditor.name, () => { test('held transactions are applied in chain order once the missing one arrives', () => { - const {editor, clock} = createLoadedEditor('B: foo') + const {editor, clock, heard} = createLoadedEditor('B: foo') const path = [{_key: 'd-k0'}, 'style'] const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] @@ -36,15 +43,15 @@ describe(createIoEditor.name, () => { expect(editor.document.toTextspec()).toEqual('H2: |foox') expect(editor.getBase().rev).toEqual('r4') - expect(editor.changes.length).toEqual(3) + expect(heard.changes.length).toEqual(3) clock.advance(10_000) - expect(editor.errors).toEqual([]) + expect(heard.errors).toEqual([]) }) test('a held echo lets the next batch go out and keeps its work on screen until it applies', () => { - const {editor} = createLoadedEditor('B: foo|') + const {editor, heard} = createLoadedEditor('B: foo|') const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] editor.type('x') @@ -53,12 +60,34 @@ describe(createIoEditor.name, () => { transactionId: 'A-1', previousRev: 'r2', resultRev: 'r3', - patches: editor.sentBatches[0].patches, + patches: heard.mutations[0].patches, }) - expect(editor.sentBatches.map((batch) => batch.patches)).toEqual([ - [diffMatchPatch('foo', 'foox', textPath)], - [diffMatchPatch('foox', 'fooxy', textPath)], + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [diffMatchPatch('foo', 'foox', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foox', marks: []}], + style: 'normal', + }, + ], + }, + { + id: 'A-2', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'fooxy', marks: []}], + style: 'normal', + }, + ], + }, ]) expect(editor.document.toTextspec()).toEqual('B: fooxy|') @@ -80,7 +109,7 @@ describe(createIoEditor.name, () => { }) test('a pending insert re-keyed on collision takes its later patches, its undo steps and the caret with it', () => { - const {editor} = createLoadedEditor('B: foo|') + const {editor, heard} = createLoadedEditor('B: foo|') const barBlock = parseTextspec( {keyGenerator: createTestKeyGenerator('b-')}, 'B _key="k9": bar', @@ -96,7 +125,7 @@ describe(createIoEditor.name, () => { patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], }) - expect(editor.errors).toEqual([]) + expect(heard.errors).toEqual([]) expect(editor.document.toTextspec({keys: true})).toEqual( 'B _key="d-k0": foox;;B _key="a-k3": bazq|;;B _key="k9": bar', ) @@ -105,40 +134,58 @@ describe(createIoEditor.name, () => { transactionId: 'A-1', previousRev: 'r2', resultRev: 'r3', - patches: editor.sentBatches[0].patches, + patches: heard.mutations[0].patches, }) - expect(editor.sentBatches[1].patches).toEqual([ - insert( - [ - { - _type: 'block', - _key: 'a-k3', - children: [{_key: 'a-k2', _type: 'span', text: 'baz', marks: []}], - style: 'normal', - }, - ], - 'after', - [{_key: 'd-k0'}], - ), - diffMatchPatch('baz', 'bazq', [ - {_key: 'a-k3'}, - 'children', - {_key: 'a-k2'}, - 'text', - ]), - ]) + expect(heard.mutations[1]).toEqual({ + id: 'A-2', + patches: [ + insert( + [ + { + _type: 'block', + _key: 'a-k3', + children: [{_key: 'a-k2', _type: 'span', text: 'baz', marks: []}], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k0'}], + ), + diffMatchPatch('baz', 'bazq', [ + {_key: 'a-k3'}, + 'children', + {_key: 'a-k2'}, + 'text', + ]), + ], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foox', marks: []}], + style: 'normal', + }, + { + _type: 'block', + _key: 'a-k3', + children: [{_type: 'span', _key: 'a-k2', text: 'bazq', marks: []}], + style: 'normal', + }, + barBlock, + ], + }) editor.undo() editor.undo() expect(editor.document.toTextspec({keys: true})).toEqual( - 'B _key="d-k0": |foox;;B _key="k9": bar', + 'B _key="d-k0": foox|;;B _key="k9": bar', ) }) test('undo puts back the style another writer set underneath', () => { - const {editor} = createLoadedEditor('B: foo|') + const {editor, heard} = createLoadedEditor('B: foo|') const path = [{_key: 'd-k0'}, 'style'] editor.setStyle('h2') @@ -159,17 +206,39 @@ describe(createIoEditor.name, () => { transactionId: 'A-1', previousRev: 'r2', resultRev: 'r3', - patches: editor.sentBatches[0].patches, + patches: heard.mutations[0].patches, }) - expect(editor.sentBatches.map((batch) => batch.patches)).toEqual([ - [set('h2', path)], - [set('h1', path)], + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [set('h2', path)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'h2', + }, + ], + }, + { + id: 'A-2', + patches: [set('h1', path)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'h1', + }, + ], + }, ]) }) test("undo isn't rebased over the editor's own echo", () => { - const {editor} = createLoadedEditor('B: foo|') + const {editor, heard} = createLoadedEditor('B: foo|') const path = [{_key: 'd-k0'}, 'style'] editor.setStyle('h2') @@ -177,19 +246,287 @@ describe(createIoEditor.name, () => { transactionId: 'A-1', previousRev: 'r1', resultRev: 'r2', - patches: editor.sentBatches[0].patches, + patches: heard.mutations[0].patches, }) editor.undo() expect(editor.document.toTextspec()).toEqual('B: foo|') - expect(editor.sentBatches.map((batch) => batch.patches)).toEqual([ - [set('h2', path)], - [set('normal', path)], + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [set('h2', path)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'h2', + }, + ], + }, + { + id: 'A-2', + patches: [set('normal', path)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'normal', + }, + ], + }, + ]) + }) + + test('undo after the echo puts back the style another writer saved just before it', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + editor.setStyle('h2') + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', path)], + }) + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('H1: foo|') + expect(heard.mutations[1]).toEqual({ + id: 'A-2', + patches: [set('h1', path)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'h1', + }, + ], + }) + }) + + test("undoing the first keystroke into an empty field deletes the text and keeps another writer's content", () => { + const {editor, heard} = createLoadedEditor(undefined) + const textPath = [{_key: 'a-k0'}, 'children', {_key: 'a-k1'}, 'text'] + const barBlock = parseTextspec( + {keyGenerator: createTestKeyGenerator('b-')}, + 'B: bar', + ).value[0] + + editor.type('x') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [insert([barBlock], 'after', [{_key: 'a-k0'}])], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: |;;B: bar') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [ + setIfMissing([], []), + insert( + [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: '', marks: []}], + }, + ], + 'before', + [0], + ), + diffMatchPatch('', 'x', textPath), + ], + value: [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: 'x', marks: []}], + }, + ], + }, + { + id: 'A-2', + patches: [diffMatchPatch('x', '', textPath)], + value: [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: '', marks: []}], + }, + barBlock, + ], + }, + ]) + }) + + test('undoing a delete puts the block back after its previous sibling', () => { + const {editor, heard} = createLoadedEditor('B: foo|;;B: bar') + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + + editor.deleteBlock('bar') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: foo|;;B: bar') + expect(heard.mutations).toEqual([ + {id: 'A-1', patches: [unset([{_key: 'd-k2'}])], value: [fooBlock]}, + { + id: 'A-2', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + value: [fooBlock, barBlock], + }, + ]) + }) + + test('undoing a delete does nothing once the block is back', () => { + const {editor, heard} = createLoadedEditor('B: foo|;;B: bar') + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + + editor.deleteBlock('bar') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: foo|;;B: bar') + expect(heard.mutations).toEqual([ + {id: 'A-1', patches: [unset([{_key: 'd-k2'}])], value: [fooBlock]}, + ]) + expect(heard.errors).toEqual([]) + }) + + test('undoing an insert deletes the block while it exists, and leaves the block made from the placeholder', () => { + const {editor, heard} = createLoadedEditor(undefined) + + editor.insertBlock('B: bar') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.undo() + + expect(editor.document.toTextspec({keys: true})).toEqual('B _key="a-k0": |') + expect(editor.document.getPlaceholderKey()).toEqual(undefined) + expect(heard.mutations[1]).toEqual({ + id: 'A-2', + patches: [unset([{_key: 'a-k2'}])], + value: [ + { + _type: 'block', + _key: 'a-k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'a-k1', text: '', marks: []}], + }, + ], + }) + }) + + test('undoing an insert does nothing once another writer deleted the block', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + + editor.insertBlock('B: bar') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [unset([{_key: 'a-k2'}])], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: |foo') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [ + insert( + [ + { + _type: 'block', + _key: 'a-k2', + children: [ + {_type: 'span', _key: 'a-k3', text: 'bar', marks: []}, + ], + style: 'normal', + }, + ], + 'after', + [{_key: 'd-k0'}], + ), + ], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'normal', + }, + { + _type: 'block', + _key: 'a-k2', + children: [{_type: 'span', _key: 'a-k3', text: 'bar', marks: []}], + style: 'normal', + }, + ], + }, ]) }) test('a rejected batch stays on screen while the feed keeps applying', () => { - const {editor} = createLoadedEditor('B: foo|;;B: bar') + const {editor, heard} = createLoadedEditor('B: foo|;;B: bar') editor.type('x') editor.mutationRejected({id: 'A-1'}) @@ -201,11 +538,299 @@ describe(createIoEditor.name, () => { }) expect(editor.document.toTextspec()).toEqual('B: foox|') - expect(editor.sentBatches.length).toEqual(1) + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [ + diffMatchPatch('foo', 'foox', [ + {_key: 'd-k0'}, + 'children', + {_key: 'd-k1'}, + 'text', + ]), + ], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foox', marks: []}], + style: 'normal', + }, + { + _type: 'block', + _key: 'd-k2', + children: [{_type: 'span', _key: 'd-k3', text: 'bar', marks: []}], + style: 'normal', + }, + ], + }, + ]) + }) + + test('a rejection for a batch that already came back is ignored with a warning', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.type('x') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.mutationRejected({id: 'A-1'}) + editor.type('y') + + expect(heard.warnings).toEqual([ + '`mutation rejected` for batch "A-1", not in flight', + ]) + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [diffMatchPatch('foo', 'foox', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foox', marks: []}], + style: 'normal', + }, + ], + }, + { + id: 'A-2', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'fooxy', marks: []}], + style: 'normal', + }, + ], + }, + ]) + }) + + test('a patch through a primitive puts the editor out of step, and a patch for a missing parent does nothing', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + const failingPatch = set('x', [{_key: 'd-k0'}, 'style', 'name']) + + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'k9'}, 'style'])], + }) + + expect(heard.errors).toEqual([]) + expect(editor.getBase().rev).toEqual('r2') + + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [failingPatch], + }) + + expect(heard.errors).toEqual([ + {reason: 'patch failed', transactionId: 't2', patch: failingPatch}, + ]) + expect(editor.getBase().rev).toEqual('r2') + expect(editor.document.toTextspec()).toEqual('B: foo|') + }) + + test('a remote insert that brings the same key twice puts the editor out of step', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + const keyGenerator = createTestKeyGenerator('b-') + const [firstBlock, secondBlock] = parseTextspec( + {keyGenerator}, + 'B _key="k7": bar;;B _key="k7": baz', + ).value + const duplicateInsert = insert([firstBlock, secondBlock], 'after', [ + {_key: 'd-k0'}, + ]) + + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [duplicateInsert], + }) + + expect(heard.errors).toEqual([ + {reason: 'duplicate key', transactionId: 't1', patch: duplicateInsert}, + ]) + expect(editor.document.toTextspec()).toEqual('B: foo|') + }) + + test('a key repair never picks a key the value already has', () => { + const {clock} = createNetwork() + const editor = createIoEditor({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + claimLoad: true, + }) + const heard = listenTo(editor) + const {value} = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B _key="a-k2": foo;;B _key="missing": bar', + ) + const keylessBlock = {...value[1]} + Reflect.deleteProperty(keylessBlock, '_key') + + editor.mount() + editor.load({value: [value[0], keylessBlock], rev: 'r1'}) + + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [set('a-k3', [1, '_key'])], + value: [value[0], {...keylessBlock, _key: 'a-k3'}], + }, + ]) + }) + + test('a batch in flight without its echo warns after 10 seconds, then with backoff', () => { + const {editor, clock, heard} = createLoadedEditor('B: foo|') + + editor.type('x') + clock.advance(9_999) + + expect(heard.warnings).toEqual([]) + + clock.advance(1) + clock.advance(20_000) + + expect(heard.warnings).toEqual([ + 'Batch "A-1" has been in flight for 10000 ms without coming back', + 'Batch "A-1" has been in flight for 30000 ms without coming back', + ]) + + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + clock.advance(100_000) + + expect(heard.warnings.length).toEqual(2) + }) + + test("a claimed load that hasn't arrived after 10 seconds warns", () => { + const {clock} = createNetwork() + const editor = createIoEditor({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + claimLoad: true, + }) + const heard = listenTo(editor) + + editor.mount() + clock.advance(10_000) + editor.load({value: undefined, rev: undefined}) + clock.advance(100_000) + + expect(heard.warnings).toEqual([ + "The claimed first load hasn't arrived after 10000 ms", + ]) + }) + + test('a resync warns about unsent changes that no longer have a target', () => { + const {editor, heard} = createLoadedEditor('B: foo;;B: bar|') + const [fooBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo', + ).value + + editor.type('x') + editor.type('y') + editor.mutationRejected({id: 'A-1'}) + editor.resync({value: [fooBlock], rev: 'r2'}) + + expect(heard.warnings).toEqual([ + '1 unsent patches had no target after the resync and did nothing', + ]) + expect(editor.document.toTextspec()).toEqual('B: |foo') + }) + + test('inputs after unmounting are ignored with a warning', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + + editor.close() + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k0'}, 'style'])], + }) + editor.resync({value: undefined, rev: 'r2'}) + editor.type('x') + editor.setStyle('h1') + editor.insertBlock('B: bar') + editor.deleteBlock('foo') + editor.undo() + editor.putCaretAfter('f') + + expect(heard.warnings).toEqual([ + 'Ignored transaction "t1" after the editor unmounted', + 'Ignored a resync after the editor unmounted', + 'Ignored an action after the editor unmounted', + 'Ignored an action after the editor unmounted', + 'Ignored an action after the editor unmounted', + 'Ignored an action after the editor unmounted', + 'Ignored an action after the editor unmounted', + 'Ignored an action after the editor unmounted', + ]) + expect(editor.document.toTextspec()).toEqual('B: foo|') + expect(editor.getBase().rev).toEqual('r1') + expect(heard.mutations).toEqual([]) + expect(heard.changes).toEqual([]) + }) + + test('`ready` fires once, whether the first load is claimed or not', () => { + const {clock} = createNetwork() + const readyCounts = [ + {claimLoad: false, finish: () => {}}, + { + claimLoad: true, + finish: (editor: ReturnType) => + editor.load({value: undefined, rev: undefined}), + }, + { + claimLoad: true, + finish: (editor: ReturnType) => + editor.releaseClaim(), + }, + ].map(({claimLoad, finish}) => { + const editor = createIoEditor({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + claimLoad, + }) + const statuses: Array = [] + + editor.on((event) => { + if (event.type === 'ready') { + statuses.push(editor.getStatus()) + } + }) + editor.mount() + finish(editor) + editor.releaseClaim() + + return statuses + }) + + expect(readyCounts).toEqual([['ready'], ['ready'], ['ready']]) }) }) -function createLoadedEditor(textspec: string) { +function createLoadedEditor(textspec: string | undefined) { const {clock} = createNetwork() const editor = createIoEditor({ id: 'A', @@ -213,21 +838,23 @@ function createLoadedEditor(textspec: string) { clock, claimLoad: true, }) - const {value, caret} = parseTextspec( - {keyGenerator: createTestKeyGenerator('d-')}, - textspec, - ) + const heard = listenTo(editor) + const {value, caret} = + textspec === undefined + ? {value: undefined, caret: undefined} + : parseTextspec({keyGenerator: createTestKeyGenerator('d-')}, textspec) editor.on((event) => { if (event.type === 'mutation' && !event.final) { editor.mutationSent({id: event.id, transactionId: event.id}) } }) + editor.mount() editor.load({value, rev: 'r1'}) if (caret) { editor.document.setCaret(caret) } - return {editor, clock} + return {editor, clock, heard} } diff --git a/packages/io/src/editor.ts b/packages/io/src/editor.ts index d65410d287..7c2a636077 100644 --- a/packages/io/src/editor.ts +++ b/packages/io/src/editor.ts @@ -1,7 +1,17 @@ import {set, type Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' -import {applyWithContentLakeSemantics, resolvePath} from './content-lake' -import {createDocument, type ActionResult, type Document} from './document' +import { + applyWithContentLakeSemantics, + hasTarget, + resolvePath, +} from './content-lake' +import { + createDocument, + generateUniqueKey, + type ActionResult, + type Document, + type UndoStep, +} from './document' import type { ChangeEvent, ErrorEvent, @@ -15,6 +25,7 @@ import type { } from './types' const heldTransactionTimeout = 10_000 +const livenessTimeout = 10_000 export type Clock = { now: () => number @@ -28,6 +39,7 @@ export type IoEditorEvent = | ({type: 'mutation'} & MutationBatch) | ({type: 'change'} & ChangeEvent) | ({type: 'error'} & ErrorEvent) + | {type: 'warning'; message: string} | {type: 'ready'} export type IoEditor = { @@ -37,6 +49,11 @@ export type IoEditor = { getBase: () => Load on: (listener: (event: IoEditorEvent) => void) => () => void + /** + * Ends the first commit. An editor that doesn't claim the first load + * becomes ready here. + */ + mount: () => void load: (load: Load) => void releaseClaim: () => void resync: (resync: Resync) => void @@ -53,11 +70,6 @@ export type IoEditor = { deleteBlock: (text: string) => void undo: () => void close: () => void - - sentBatches: Array - changes: Array - errors: Array - warnings: Array } type SentBatch = { @@ -68,6 +80,19 @@ type SentBatch = { type HeldTransaction = {transaction: Transaction; arrivedAt: number} +/** + * The base's style for a block, or `undefined` when the base has no such + * block. + */ +type BaseStyle = {style: string | undefined} | undefined + +type HistoryEntry = { + step: UndoStep + batchId: string | undefined + /** The base's style for the block at the action, reset at the change's echo. */ + baseStyle: BaseStyle +} + /** * The editor side of the pass-through protocol. Batch IDs are the editor's * `id` plus a counter, so editors with different IDs never share one. @@ -82,12 +107,9 @@ export function createIoEditor(options: { const listeners = new Set<(event: IoEditorEvent) => void>() const document = createDocument({keyGenerator}, {value: undefined}) const emittedBatchIds = new Set() - const sentBatches: Array = [] - const changes: Array = [] - const errors: Array = [] - const warnings: Array = [] - let status: IoEditorStatus = options.claimLoad ? 'loading' : 'ready' + let status: IoEditorStatus = 'loading' + let mounted = false let base: Load = {value: undefined, rev: undefined} let readOnly = false let outOfStep = false @@ -97,8 +119,10 @@ export function createIoEditor(options: { let echoedAwaitingBase: Array = [] let pending: Array> = [] let held: Array = [] + let history: Array = [] let cancelHeldTimeout: (() => void) | undefined - let undoSteps: Array> = [] + let cancelInFlightWarning: (() => void) | undefined + let cancelLoadWarning: (() => void) | undefined function emit(event: IoEditorEvent) { for (const listener of listeners) { @@ -107,11 +131,33 @@ export function createIoEditor(options: { } function warn(message: string) { - warnings.push(message) + emit({type: 'warning', message}) + } + + function mount() { + if (mounted) { + throw new Error('The editor is already mounted') + } + + mounted = true + + if (!options.claimLoad) { + becomeReady() + return + } + + if (status === 'loading') { + cancelLoadWarning = clock.schedule(livenessTimeout, () => { + cancelLoadWarning = undefined + warn( + `The claimed first load hasn't arrived after ${livenessTimeout} ms`, + ) + }) + } } function load(incoming: Load) { - if (status !== 'loading') { + if (!options.claimLoad || status !== 'loading') { throw new Error('`load` is only accepted while the first load is claimed') } @@ -122,18 +168,25 @@ export function createIoEditor(options: { } function releaseClaim() { - if (status === 'loading') { + if (options.claimLoad && status === 'loading') { becomeReady() } } function becomeReady() { + cancelLoadWarning?.() + cancelLoadWarning = undefined status = 'ready' emit({type: 'ready'}) flush() } function resync(incoming: Resync) { + if (status === 'unmounted') { + warn('Ignored a resync after the editor unmounted') + return + } + if (status === 'loading') { throw new Error('`resync` is not accepted before the editor is ready') } @@ -149,7 +202,7 @@ export function createIoEditor(options: { rejected = undefined echoedAwaitingBase = [] outOfStep = false - undoSteps = [] + history = [] releaseHeld() if (incoming.discardUnsent) { @@ -157,11 +210,38 @@ export function createIoEditor(options: { } queueKeyRepair(incoming.value) + warnAboutUnappliedPending() updateScreen() flush() } + function warnAboutUnappliedPending() { + let value = base.value + let unapplied = 0 + + for (const patch of pending.flat()) { + if (!hasTarget(value, patch)) { + unapplied++ + } + + value = applyWithContentLakeSemantics(value, [patch]) + } + + if (unapplied > 0) { + warn( + `${unapplied} unsent patches had no target after the resync and did nothing`, + ) + } + } + function transaction(incoming: Transaction) { + if (status === 'unmounted') { + warn( + `Ignored transaction "${incoming.transactionId}" after the editor unmounted`, + ) + return + } + if (status === 'loading') { throw new Error( '`transaction` is not accepted before the editor is ready', @@ -196,6 +276,7 @@ export function createIoEditor(options: { if (inFlight && inFlight.transactionId === transactionId) { echoedAwaitingBase = [...echoedAwaitingBase, inFlight] inFlight = undefined + stopInFlightWarning() } } @@ -214,18 +295,15 @@ export function createIoEditor(options: { } function applyTransaction(incoming: Transaction): boolean { - const ownBatches = echoedAwaitingBase.filter( - (batch) => batch.transactionId === incoming.transactionId, - ) - const ownPatchIndexes = findOwnPatchIndexes(incoming.patches, ownBatches) - const otherPatches = incoming.patches.filter( - (_patch, index) => !ownPatchIndexes.has(index), + const confirmedBatchIds = new Set( + echoedAwaitingBase + .filter((batch) => batch.transactionId === incoming.transactionId) + .map((batch) => batch.id), ) let nextValue = base.value - for (const [index, patch] of incoming.patches.entries()) { + for (const patch of incoming.patches) { if ( - !ownPatchIndexes.has(index) && patch.type === 'insert' && insertCollides(patch, keysAmongSiblings(nextValue, patch.path)) ) { @@ -249,12 +327,14 @@ export function createIoEditor(options: { const unconfirmedKeys = insertedBlockKeys( [ - ...echoedAwaitingBase.filter((batch) => !ownBatches.includes(batch)), + ...echoedAwaitingBase.filter( + (batch) => !confirmedBatchIds.has(batch.id), + ), ...(inFlight ? [inFlight] : []), ...(rejected ? [rejected] : []), ].flatMap((batch) => batch.patches), ) - const collidingPatch = otherPatches.find( + const collidingPatch = incoming.patches.find( (patch) => patch.type === 'insert' && insertCollides(patch, unconfirmedKeys), ) @@ -267,11 +347,11 @@ export function createIoEditor(options: { }) } + moveHistoryUnder(confirmedBatchIds, nextValue) base = {value: nextValue, rev: incoming.resultRev} echoedAwaitingBase = echoedAwaitingBase.filter( - (batch) => !ownBatches.includes(batch), + (batch) => !confirmedBatchIds.has(batch.id), ) - rebaseUndoSteps(otherPatches) if (incoming.patches.length > 0) { updateScreen() @@ -280,10 +360,41 @@ export function createIoEditor(options: { return true } + /** + * A confirmed change is now part of the base, so the base's style for its + * block is its own from here on. What the base held just before is what + * the change replaced, if another writer moved it since the action. + */ + function moveHistoryUnder( + confirmedBatchIds: Set, + nextValue: Array | undefined, + ) { + history = history.map((entry) => { + if ( + entry.step.type !== 'styled' || + entry.batchId === undefined || + !confirmedBatchIds.has(entry.batchId) + ) { + return entry + } + + const styleBefore = baseStyleOf(base.value, entry.step.blockKey) + const previousStyle = + styleBefore !== undefined && !isEqual(styleBefore, entry.baseStyle) + ? styleBefore.style + : entry.step.previousStyle + + return { + ...entry, + step: {...entry.step, previousStyle}, + baseStyle: baseStyleOf(nextValue, entry.step.blockKey), + } + }) + } + function fail(error: ErrorEvent): false { outOfStep = true releaseHeld() - errors.push(error) emit({type: 'error', ...error}) return false } @@ -345,6 +456,7 @@ export function createIoEditor(options: { rejected = inFlight inFlight = undefined + stopInFlightWarning() } function act(run: () => ActionResult) { @@ -353,28 +465,80 @@ export function createIoEditor(options: { } const result = run() - commitLocalChange(result.patches) - if (result.patches.length > 0) { - undoSteps = [...undoSteps, result.inversePatches] + if (result.patches.length === 0) { + return } + + if (result.undoStep) { + history = [ + ...history, + { + step: result.undoStep, + batchId: undefined, + baseStyle: baseStyleOf(base.value, stepBlockKey(result.undoStep)), + }, + ] + } + + commitLocalChange(result.patches) } function undo() { - const inversePatches = undoSteps.at(-1) + const entry = history.at(-1) - if (!canEdit() || !inversePatches) { + if (!canEdit() || !entry) { return } - undoSteps = undoSteps.slice(0, -1) - document.setValue( - applyWithContentLakeSemantics(getContent(), inversePatches), - ) - commitLocalChange(inversePatches) + history = history.slice(0, -1) + commitLocalChange(revert(entry)) + } + + function revert(entry: HistoryEntry): Array { + const {step} = entry + + switch (step.type) { + case 'typed': + return document.deleteText(step) + case 'styled': { + const style = styleUnder(entry, step) + const block = document + .getValue() + .find((candidate) => candidate._key === step.blockKey) + + return block === undefined || block.style === style + ? [] + : document.setBlockStyle(step.blockKey, style) + } + case 'inserted': + return document.deleteBlockByKey(step.blockKey) + case 'deleted': + return document.restoreBlock(step) + } + } + + /** + * The style the base holds under the change: the base's own when another + * writer moved it since the change, else what the change replaced. + */ + function styleUnder( + entry: HistoryEntry, + step: Extract, + ): string | undefined { + const current = baseStyleOf(base.value, step.blockKey) + + return current === undefined || isEqual(current, entry.baseStyle) + ? step.previousStyle + : current.style } function canEdit(): boolean { + if (status === 'unmounted') { + warn('Ignored an action after the editor unmounted') + return false + } + if (status !== 'ready') { throw new Error(`The editor is ${status}`) } @@ -388,7 +552,7 @@ export function createIoEditor(options: { } pending = [...pending, patches] - recordChange({operations: patches, origin: 'local'}) + emit({type: 'change', operations: patches, origin: 'local'}) flush() } @@ -403,6 +567,9 @@ export function createIoEditor(options: { status = 'unmounted' releaseHeld() + stopInFlightWarning() + cancelLoadWarning?.() + cancelLoadWarning = undefined } function flush() { @@ -425,7 +592,9 @@ export function createIoEditor(options: { pending = [] emittedBatchIds.add(batch.id) - sentBatches.push(batch) + history = history.map((entry) => + entry.batchId === undefined ? {...entry, batchId: batch.id} : entry, + ) if (!final) { inFlight = { @@ -433,11 +602,26 @@ export function createIoEditor(options: { patches: batch.patches, transactionId: undefined, } + startInFlightWarning(batch.id, livenessTimeout, clock.now()) } emit({type: 'mutation', ...batch}) } + function startInFlightWarning(batchId: string, delay: number, since: number) { + cancelInFlightWarning = clock.schedule(delay, () => { + warn( + `Batch "${batchId}" has been in flight for ${clock.now() - since} ms without coming back`, + ) + startInFlightWarning(batchId, delay * 2, since) + }) + } + + function stopInFlightWarning() { + cancelInFlightWarning?.() + cancelInFlightWarning = undefined + } + function updateScreen() { const newKeys = rekeyPendingInserts() const caret = document.getCaret() @@ -453,7 +637,7 @@ export function createIoEditor(options: { } if (!isEqual(before, after)) { - recordChange({operations: [set(after, [])], origin: 'remote'}) + emit({type: 'change', operations: [set(after, [])], origin: 'remote'}) } } @@ -472,15 +656,19 @@ export function createIoEditor(options: { function rekeyPendingInserts(): Map { const baseKeys = new Set( - (base.value ?? []).flatMap((block) => - typeof block._key === 'string' ? [block._key] : [], - ), + (base.value ?? []).flatMap((block) => itemKey(block)), ) + const pendingInsertKeys = insertedBlockKeys(pending.flat()) + const takenKeys = new Set([ + ...baseKeys, + ...pendingInsertKeys, + ...document.getValue().map((block) => block._key), + ]) const newKeys = new Map() - for (const key of insertedBlockKeys(pending.flat())) { + for (const key of pendingInsertKeys) { if (baseKeys.has(key)) { - newKeys.set(key, keyGenerator()) + newKeys.set(key, generateUniqueKey(keyGenerator, takenKeys)) } } @@ -491,30 +679,14 @@ export function createIoEditor(options: { pending = pending.map((patches) => patches.map((patch) => renameBlockKeys(patch, newKeys)), ) - undoSteps = undoSteps.map((patches) => - patches.map((patch) => renameBlockKeys(patch, newKeys)), - ) + history = history.map((entry) => ({ + ...entry, + step: renameStepKeys(entry.step, newKeys), + })) return newKeys } - function rebaseUndoSteps(otherPatches: Array) { - for (const otherPatch of otherPatches) { - if (otherPatch.type !== 'set' && otherPatch.type !== 'unset') { - continue - } - - undoSteps = undoSteps.map((patches) => - patches.map((patch) => - (patch.type === 'set' || patch.type === 'unset') && - isEqual(patch.path, otherPatch.path) - ? otherPatch - : patch, - ), - ) - } - } - function queueKeyRepair(value: Array | undefined) { const repairPatches = repairKeys(value, keyGenerator) @@ -524,17 +696,21 @@ export function createIoEditor(options: { } } - function recordChange(change: ChangeEvent) { - changes.push(change) - emit({type: 'change', ...change}) - } - function getContent(): Array | undefined { return document.getPlaceholderKey() === undefined ? document.getValue() : undefined } + function putCaretAfter(text: string) { + if (status === 'unmounted') { + warn('Ignored an action after the editor unmounted') + return + } + + document.putCaretAfter(text) + } + return { document, getStatus: () => status, @@ -545,6 +721,7 @@ export function createIoEditor(options: { listeners.delete(listener) } }, + mount, load, releaseClaim, resync, @@ -557,47 +734,31 @@ export function createIoEditor(options: { }, setStyle: (style) => act(() => document.setStyle(style)), type: (text) => act(() => document.type(text)), - putCaretAfter: (text) => document.putCaretAfter(text), + putCaretAfter, insertBlock: (textspec) => act(() => document.insertBlock(textspec)), deleteBlock: (text) => act(() => document.deleteBlock(text)), undo, close, - sentBatches, - changes, - errors, - warnings, } } -/** - * The indexes of a transaction's patches that are this editor's own batches: - * each batch's patches, found as one contiguous run. - */ -function findOwnPatchIndexes( - patches: Array, - ownBatches: Array, -): Set { - const indexes = new Set() - - for (const batch of ownBatches) { - const start = patches.findIndex( - (_patch, index) => - !indexes.has(index) && - batch.patches.every((batchPatch, offset) => - isEqual(patches[index + offset], batchPatch), - ), - ) +function stepBlockKey(step: UndoStep): string { + return step.type === 'deleted' ? step.block._key : step.blockKey +} - if (start === -1) { - continue - } +function baseStyleOf( + value: Array | undefined, + blockKey: string, +): BaseStyle { + const block = value?.find((candidate) => candidate._key === blockKey) - for (let offset = 0; offset < batch.patches.length; offset++) { - indexes.add(start + offset) - } + if (!block) { + return undefined } - return indexes + const style: unknown = block.style + + return {style: typeof style === 'string' ? style : undefined} } function keysAmongSiblings( @@ -611,10 +772,20 @@ function keysAmongSiblings( ) } +/** + * Whether an insert brings a key that is already taken, or brings the same + * key twice. + */ function insertCollides(patch: Patch, keys: Set): boolean { + if (patch.type !== 'insert') { + return false + } + + const insertedKeys = patch.items.flatMap((item) => itemKey(item)) + return ( - patch.type === 'insert' && - patch.items.some((item) => itemKey(item).some((key) => keys.has(key))) + new Set(insertedKeys).size < insertedKeys.length || + insertedKeys.some((key) => keys.has(key)) ) } @@ -655,39 +826,68 @@ function renameBlockKeys(patch: Patch, newKeys: Map): Patch { return {...patch, path: renamedPath} } +function renameStepKeys( + step: UndoStep, + newKeys: Map, +): UndoStep { + const rename = (key: string | undefined) => + key === undefined ? undefined : (newKeys.get(key) ?? key) + + if (step.type === 'deleted') { + return { + ...step, + block: {...step.block, _key: rename(step.block._key) ?? step.block._key}, + previousKey: rename(step.previousKey), + nextKey: rename(step.nextKey), + } + } + + return {...step, blockKey: rename(step.blockKey) ?? step.blockKey} +} + /** * Repairs missing and duplicate keys among blocks and among each block's * children, keeping the first of each duplicate. Index paths address the - * blocks, since a missing or duplicate key can't. + * nodes, since a missing or duplicate key can't. New keys collide with no + * key anywhere in the value. */ function repairKeys( value: Array | undefined, keyGenerator: () => string, ): Array { const patches: Array = [] + const takenKeys = new Set( + (value ?? []).flatMap((block) => [ + ...itemKey(block), + ...childrenOf(block).flatMap((child) => itemKey(child)), + ]), + ) const blockKeys = new Set() for (const [blockIndex, block] of (value ?? []).entries()) { const [blockKey] = itemKey(block) if (blockKey === undefined || blockKeys.has(blockKey)) { - patches.push(set(keyGenerator(), [blockIndex, '_key'])) + patches.push( + set(generateUniqueKey(keyGenerator, takenKeys), [blockIndex, '_key']), + ) } else { blockKeys.add(blockKey) } - const children: unknown = Reflect.get(block, 'children') const childKeys = new Set() - for (const [childIndex, child] of (Array.isArray(children) - ? children - : [] - ).entries()) { + for (const [childIndex, child] of childrenOf(block).entries()) { const [childKey] = itemKey(child) if (childKey === undefined || childKeys.has(childKey)) { patches.push( - set(keyGenerator(), [blockIndex, 'children', childIndex, '_key']), + set(generateUniqueKey(keyGenerator, takenKeys), [ + blockIndex, + 'children', + childIndex, + '_key', + ]), ) } else { childKeys.add(childKey) @@ -698,6 +898,12 @@ function repairKeys( return patches } +function childrenOf(block: PortableTextBlock): Array { + const children: unknown = Reflect.get(block, 'children') + + return Array.isArray(children) ? children : [] +} + function itemKey(item: unknown): Array { if (typeof item !== 'object' || item === null || !('_key' in item)) { return [] diff --git a/packages/io/src/host.test.ts b/packages/io/src/host.test.ts new file mode 100644 index 0000000000..46aec39249 --- /dev/null +++ b/packages/io/src/host.test.ts @@ -0,0 +1,182 @@ +import {diffMatchPatch, set} from '@portabletext/patches' +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test} from 'vitest' +import {parseTextspec} from './document' +import {createIoEditor} from './editor' +import {createPassThroughHost} from './host' +import {createNetwork} from './test/network' +import {listenTo} from './test/world' +import type {Load, MutationBatch, Transaction} from './types' + +describe(createPassThroughHost.name, () => { + test('a transaction the resync copy covers is dropped, and the next one is forwarded', () => { + const {editor, 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(editor.getBase().rev).toEqual('r2') + expect(editor.document.toTextspec()).toEqual('H1: foo|') + + host.forward(nextTransaction) + + expect(editor.getBase().rev).toEqual('r3') + expect(editor.document.toTextspec()).toEqual('H2: foo|') + }) + + test('a transaction that skips ahead after the load reaches the editor', () => { + const {editor, 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(editor.getBase().rev).toEqual('r3') + expect(editor.document.toTextspec()).toEqual('H2: foo|') + }) + + test('the final batch is saved once the save request of the batch in flight is taken', () => { + const {editor, host, saved} = createHostedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.type('x') + editor.type('y') + editor.close() + + expect(saved.length).toEqual(1) + + host.reportSaveTaken('A-1') + + expect(saved).toEqual([ + { + id: 'A-1', + patches: [diffMatchPatch('foo', 'foox', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foox', marks: []}], + style: 'normal', + }, + ], + }, + { + id: 'A-2', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'fooxy', marks: []}], + style: 'normal', + }, + ], + final: true, + }, + ]) + }) + + test('the final batch is saved at once when no save request is waiting', () => { + const {editor, host, saved} = createHostedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.type('x') + host.reportSaveTaken('A-1') + editor.type('y') + editor.close() + + expect(saved).toEqual([ + { + id: 'A-1', + patches: [diffMatchPatch('foo', 'foox', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foox', marks: []}], + style: 'normal', + }, + ], + }, + { + id: 'A-2', + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'fooxy', marks: []}], + style: 'normal', + }, + ], + final: true, + }, + ]) + }) +}) + +function createHostedEditor(textspec: string) { + const {clock} = createNetwork() + const editor = createIoEditor({ + id: 'A', + keyGenerator: createTestKeyGenerator('a-'), + clock, + claimLoad: true, + }) + const heard = listenTo(editor) + const {value, caret} = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + textspec, + ) + const serverCopy: {current: Load} = {current: {value, rev: 'r1'}} + const feed: Array = [] + const saved: Array = [] + const host = createPassThroughHost({ + editor, + save: (batch) => saved.push(batch), + fetchCopy: () => serverCopy.current, + subscription: () => feed, + }) + + editor.mount() + host.load() + + if (caret) { + editor.document.setCaret(caret) + } + + return {editor, host, heard, clock, feed, saved, serverCopy} +} diff --git a/packages/io/src/host.ts b/packages/io/src/host.ts index 85188a25dd..1acccf4132 100644 --- a/packages/io/src/host.ts +++ b/packages/io/src/host.ts @@ -10,6 +10,8 @@ export type PassThroughHost = { */ mapToTransaction: (batchId: string, transactionId: string) => void forward: (transaction: Transaction) => void + /** The server has taken the save request for a batch. */ + reportSaveTaken: (batchId: string) => void reportAccepted: (batchId: string) => void reportRejected: (batchId: string) => void load: () => void @@ -21,17 +23,30 @@ export type PassThroughHost = { * transaction named by its batch ID, and fetches the server's copy for `load` * and `resync`. Waiting for the batch in flight before a resync is the * caller's job. + * + * `subscription` returns what the host's feed subscription holds but hasn't + * delivered yet, in server order. The host subscribes before it fetches a + * copy, so at that moment those transactions run up to the one whose + * `resultRev` is the copy's revision, and the host drops them when they + * arrive. The model's feed can deliver out of order, so arrival order can't + * tell a covered transaction from one that skipped ahead. */ export function createPassThroughHost({ editor, save, fetchCopy, + subscription, }: { editor: IoEditor save: (batch: MutationBatch) => void fetchCopy: () => Load + subscription: () => Array> }): PassThroughHost { const transactionIds = new Map() + let inFlightBatchId: string | undefined + let untakenBatchId: string | undefined + let heldFinalBatch: MutationBatch | undefined + let coveredTransactionIds = new Set() editor.on((event) => { if (event.type !== 'mutation') { @@ -41,13 +56,42 @@ export function createPassThroughHost({ const {type: _type, ...batch} = event transactionIds.set(batch.id, batch.id) - if (!batch.final) { - editor.mutationSent({id: batch.id, transactionId: batch.id}) + if (batch.final) { + if (untakenBatchId === undefined) { + save(batch) + } else { + heldFinalBatch = batch + } + + return } + editor.mutationSent({id: batch.id, transactionId: batch.id}) + inFlightBatchId = batch.id + untakenBatchId = batch.id save(batch) }) + function fetchCoveredCopy(): Load { + const copy = fetchCopy() + + if (inFlightBatchId !== undefined) { + return copy + } + + const buffered = subscription() + const lastCoveredIndex = buffered.findLastIndex( + (transaction) => transaction.resultRev === copy.rev, + ) + coveredTransactionIds = new Set( + buffered + .slice(0, lastCoveredIndex + 1) + .map((transaction) => transaction.transactionId), + ) + + return copy + } + return { getTransactionId: (batchId) => { const transactionId = transactionIds.get(batchId) @@ -63,6 +107,17 @@ export function createPassThroughHost({ editor.mutationSent({id: batchId, transactionId}) }, forward: (transaction) => { + if (coveredTransactionIds.delete(transaction.transactionId)) { + return + } + + if ( + inFlightBatchId !== undefined && + transactionIds.get(inFlightBatchId) === transaction.transactionId + ) { + inFlightBatchId = undefined + } + editor.transaction({ transactionId: transaction.transactionId, previousRev: transaction.previousRev, @@ -70,18 +125,35 @@ export function createPassThroughHost({ patches: transaction.patches, }) }, + reportSaveTaken: (batchId) => { + if (batchId !== untakenBatchId) { + return + } + + untakenBatchId = undefined + + if (heldFinalBatch) { + const finalBatch = heldFinalBatch + heldFinalBatch = undefined + save(finalBatch) + } + }, reportAccepted: (batchId) => { editor.mutationAccepted({id: batchId}) }, reportRejected: (batchId) => { + if (batchId === inFlightBatchId) { + inFlightBatchId = undefined + } + editor.mutationRejected({id: batchId}) }, load: () => { - editor.load(fetchCopy()) + editor.load(fetchCoveredCopy()) }, resync: ({discardUnsent}) => { editor.resync({ - ...fetchCopy(), + ...fetchCoveredCopy(), ...(discardUnsent ? {discardUnsent: true as const} : {}), }) }, diff --git a/packages/io/src/test/network.test.ts b/packages/io/src/test/network.test.ts index 0ea028b9b7..ee383c81d9 100644 --- a/packages/io/src/test/network.test.ts +++ b/packages/io/src/test/network.test.ts @@ -36,6 +36,27 @@ describe(createNetwork.name, () => { ) }) + test('taking a save request tells the editor that sent it', () => { + const network = createNetwork() + const taken: Array<{editorId: string; batchId: string}> = [] + + for (const editorId of ['A', 'B']) { + network.connect(editorId, { + receiveTransaction: () => {}, + receiveReply: () => {}, + receiveSaveTaken: (batchId) => { + taken.push({editorId, batchId}) + }, + }) + } + + network.send('A', {id: 'a1', patches: []}) + network.send('B', {id: 'b1', patches: []}) + network.takeSaveRequest('b1') + + expect(taken).toEqual([{editorId: 'B', batchId: 'b1'}]) + }) + test('replies reach the sending editor in the order the caller delivers them', () => { const network = createNetwork() const received: Array<{editorId: string; reply: Reply}> = [] @@ -46,6 +67,7 @@ describe(createNetwork.name, () => { receiveReply: (reply) => { received.push({editorId, reply}) }, + receiveSaveTaken: () => {}, }) } @@ -96,6 +118,7 @@ describe(createNetwork.name, () => { received.push({editorId, transactionId: transaction.transactionId}) }, receiveReply: () => {}, + receiveSaveTaken: () => {}, }) } @@ -122,7 +145,11 @@ describe(createNetwork.name, () => { test('an editor that connects late gets only later transactions', () => { const network = createNetwork() - const receiver = {receiveTransaction: () => {}, receiveReply: () => {}} + const receiver = { + receiveTransaction: () => {}, + receiveReply: () => {}, + receiveSaveTaken: () => {}, + } const transaction: ServerTransaction = { transactionId: 't1', previousRev: 'r1', diff --git a/packages/io/src/test/network.ts b/packages/io/src/test/network.ts index 02baf1006f..d3942c043c 100644 --- a/packages/io/src/test/network.ts +++ b/packages/io/src/test/network.ts @@ -17,6 +17,8 @@ export type Reply = { export type NetworkReceiver = { receiveTransaction: (transaction: ServerTransaction) => void receiveReply: (reply: Reply) => void + /** The server has taken this editor's save request for a batch. */ + receiveSaveTaken: (batchId: string) => void } export type VirtualClock = { @@ -35,6 +37,7 @@ export type Network = { connect: (editorId: string, receiver: NetworkReceiver) => void send: (editorId: string, batch: TBatch) => void getSaveRequests: () => Array> + /** Removes the request and tells the sending editor, if it is connected. */ takeSaveRequest: (batchId: string) => SaveRequest queueReply: (reply: Reply) => void getReplies: () => Array @@ -87,6 +90,7 @@ export function createNetwork< } saveRequests = saveRequests.filter((candidate) => candidate !== request) + receivers.get(request.editorId)?.receiveSaveTaken(batchId) return request }, diff --git a/packages/io/src/test/steps.ts b/packages/io/src/test/steps.ts index a41aa22011..cbcb3db845 100644 --- a/packages/io/src/test/steps.ts +++ b/packages/io/src/test/steps.ts @@ -198,7 +198,7 @@ export const stepDefinitions = [ (context: Context, name: EditorName, batchNumber: number) => { const worldEditor = context.world.getEditor(name) - expect(worldEditor.editor.sentBatches.length).toEqual(batchNumber) + expect(worldEditor.heard.mutations.length).toEqual(batchNumber) worldEditor.checkedBatchCount = batchNumber }, ), @@ -207,7 +207,7 @@ export const stepDefinitions = [ (context: Context, name: EditorName) => { const worldEditor = context.world.getEditor(name) - expect(worldEditor.editor.sentBatches.length).toEqual( + expect(worldEditor.heard.mutations.length).toEqual( worldEditor.checkedBatchCount, ) }, @@ -215,9 +215,10 @@ export const stepDefinitions = [ Then( '{editor} has sent a final batch', (context: Context, name: EditorName) => { - expect( - context.world.getEditor(name).editor.sentBatches.at(-1)?.final, - ).toEqual(true) + const worldEditor = context.world.getEditor(name) + + expect(worldEditor.heard.mutations.at(-1)?.final).toEqual(true) + worldEditor.checkedBatchCount = worldEditor.heard.mutations.length }, ), Then( @@ -248,7 +249,7 @@ export const stepDefinitions = [ '{editor} reports that it is out of step', (context: Context, name: EditorName) => { const worldEditor = context.world.getEditor(name) - const errorCount = worldEditor.editor.errors.length + const errorCount = worldEditor.heard.errors.length expect(errorCount).toBeGreaterThan(worldEditor.checkedErrorCount) worldEditor.checkedErrorCount = errorCount @@ -258,16 +259,16 @@ export const stepDefinitions = [ const worldEditor = context.world.getEditor(name) expect( - worldEditor.editor.errors.slice(worldEditor.checkedErrorCount), + worldEditor.heard.errors.slice(worldEditor.checkedErrorCount), ).toEqual([]) }), Then('the resync is refused', (context: Context) => { const resync = context.world.getLastResync() - const {editor} = context.world.getEditor(resync.editorName) + const {editor, heard} = context.world.getEditor(resync.editorName) - expect(editor.warnings.length).toBeGreaterThan(resync.warningCount) + expect(heard.warnings.length).toBeGreaterThan(resync.warningCount) expect(editor.document.toTextspec({keys: true})).toEqual(resync.screen) - expect(editor.sentBatches.length).toEqual(resync.batchCount) + expect(heard.mutations.length).toEqual(resync.batchCount) }), Then( "{editor}'s status is {status}", @@ -278,21 +279,17 @@ export const stepDefinitions = [ Then( '{editor} has emitted {int} change(s)', (context: Context, name: EditorName, count: number) => { - expect(context.world.getEditor(name).editor.changes.length).toEqual(count) + expect(context.world.getEditor(name).heard.changes.length).toEqual(count) }, ), Then( '{editor} has emitted no change', (context: Context, name: EditorName) => { - expect(context.world.getEditor(name).editor.changes).toEqual([]) + expect(context.world.getEditor(name).heard.changes).toEqual([]) }, ), ] -/** - * Each user step twice: on Editor A when no editor is named, and on the named - * editor. - */ function userSteps() { const actions = [ { diff --git a/packages/io/src/test/world.ts b/packages/io/src/test/world.ts index 2c63ddddda..b256c25015 100644 --- a/packages/io/src/test/world.ts +++ b/packages/io/src/test/world.ts @@ -3,7 +3,7 @@ import {createTestKeyGenerator} from '@portabletext/test' import {parseTextspec} from '../document' import {createIoEditor, type IoEditor} from '../editor' import {createPassThroughHost, type PassThroughHost} from '../host' -import type {MutationBatch} from '../types' +import type {ChangeEvent, ErrorEvent, MutationBatch} from '../types' import {createNetwork, type Network} from './network' import {createServer, type Server} from './server' @@ -11,9 +11,21 @@ export type EditorName = 'Editor A' | 'Editor B' export type ServerCopyName = 'no document' | 'no field' | 'an empty list' +/** + * What the editor's listeners received, recorded from the moment it was + * created. + */ +export type Heard = { + mutations: Array + changes: Array + errors: Array + warnings: Array +} + export type WorldEditor = { editor: IoEditor host: PassThroughHost + heard: Heard /** The batch count at the previous `has sent` check. */ checkedBatchCount: number /** The error count at the previous out-of-step or in-step check. */ @@ -49,14 +61,6 @@ export function createWorld() { let lastResync: ResyncAttempt | undefined const namedTransactionIds = new Map() - function getSetup(): Setup { - if (!setup) { - throw new Error('No editors yet') - } - - return setup - } - function startEditors({claimLoad}: {claimLoad: boolean}): Setup { const server = createServer({ documentId: 'document', @@ -83,29 +87,14 @@ export function createWorld() { return setup } - function getEditor(name: EditorName): WorldEditor { - return getSetup().editors[name] - } - - function getBatch(name: EditorName, batchNumber: number): MutationBatch { - const batch = getEditor(name).editor.sentBatches[batchNumber - 1] - - if (!batch) { - throw new Error(`${name} has not sent batch ${batchNumber}`) - } - - return batch - } - function publishReceived( batches: Array<{name: EditorName; batch: MutationBatch}>, transactionId: string, ) { const {server, network} = getSetup() - const [first, second] = batches.map(({batch}) => { - network.takeSaveRequest(batch.id) - return batch - }) + const [first, second] = batches.map( + ({batch}) => network.takeSaveRequest(batch.id).batch, + ) const transaction = second ? server.receiveAsOne(first, second, transactionId) : server.receive(first, transactionId) @@ -121,18 +110,6 @@ export function createWorld() { } } - function transactionCarrying(batchId: string): string { - const transaction = getSetup() - .server.getTransactions() - .find((candidate) => candidate.batchIds.includes(batchId)) - - if (!transaction) { - throw new Error(`The server has not received batch "${batchId}"`) - } - - return transaction.transactionId - } - function deliverReply( name: EditorName, batchNumber: number, @@ -153,11 +130,45 @@ export function createWorld() { network.deliverReply(batch.id) } + function getBatch(name: EditorName, batchNumber: number): MutationBatch { + const batch = getEditor(name).heard.mutations[batchNumber - 1] + + if (!batch) { + throw new Error(`${name} has not sent batch ${batchNumber}`) + } + + return batch + } + + function getEditor(name: EditorName): WorldEditor { + return getSetup().editors[name] + } + + function transactionCarrying(batchId: string): string { + const transaction = getSetup() + .server.getTransactions() + .find((candidate) => candidate.batchIds.includes(batchId)) + + if (!transaction) { + throw new Error(`The server has not received batch "${batchId}"`) + } + + return transaction.transactionId + } + function recordNamedTransaction(name: string, transactionId: string) { namedTransactionIds.set(name, transactionId) return transactionId } + function getSetup(): Setup { + if (!setup) { + throw new Error('No editors yet') + } + + return setup + } + return { getEditor, getBatch, @@ -221,7 +232,7 @@ export function createWorld() { ) }, receiveFinal: (name: EditorName) => { - const batch = getEditor(name).editor.sentBatches.at(-1) + const batch = getEditor(name).heard.mutations.at(-1) if (!batch?.final) { throw new Error(`${name} has not sent a final batch`) @@ -319,12 +330,12 @@ export function createWorld() { reject: (name: EditorName, batchNumber: number) => deliverReply(name, batchNumber, 'rejected'), resync: (name: EditorName, {discardUnsent}: {discardUnsent: boolean}) => { - const {editor, host} = getEditor(name) + const {editor, host, heard} = getEditor(name) lastResync = { editorName: name, - warningCount: editor.warnings.length, + warningCount: heard.warnings.length, screen: editor.document.toTextspec({keys: true}), - batchCount: editor.sentBatches.length, + batchCount: heard.mutations.length, } host.resync({discardUnsent}) }, @@ -355,10 +366,12 @@ function createWorldEditor({ clock: network.clock, claimLoad, }) + const heard = listenTo(editor) const host = createPassThroughHost({ editor, save: (batch) => network.send(name, batch), fetchCopy: () => server.copy(), + subscription: () => network.getFeed(name), }) network.connect(name, { @@ -367,7 +380,40 @@ function createWorldEditor({ reply.outcome === 'accepted' ? host.reportAccepted(reply.batchId) : host.reportRejected(reply.batchId), + receiveSaveTaken: host.reportSaveTaken, + }) + editor.mount() + + return {editor, host, heard, checkedBatchCount: 0, checkedErrorCount: 0} +} + +export function listenTo(editor: IoEditor): Heard { + const heard: Heard = {mutations: [], changes: [], errors: [], warnings: []} + + editor.on((event) => { + switch (event.type) { + case 'mutation': { + const {type: _type, ...batch} = event + heard.mutations.push(batch) + break + } + case 'change': { + const {type: _type, ...change} = event + heard.changes.push(change) + break + } + case 'error': { + const {type: _type, ...error} = event + heard.errors.push(error) + break + } + case 'warning': + heard.warnings.push(event.message) + break + case 'ready': + break + } }) - return {editor, host, checkedBatchCount: 0, checkedErrorCount: 0} + return heard } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3684c19956..61f6d18da5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -740,6 +740,9 @@ importers: '@portabletext/test': specifier: workspace:^ version: link:../test + '@textspec/notation': + specifier: ^1.0.2 + version: 1.0.2 devDependencies: '@sanity/tsconfig': specifier: catalog:tooling From 51272209b502f30d47195bcb033d54f48900f719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 11:08:49 +0200 Subject: [PATCH 07/85] test(io): pin the holes the mutants found Four scenarios are new or extended. A transaction the resync copy already covers is dropped by the host. A received insert that reuses a key the base already has puts the editor out of step (the step-2 check, which no scenario reached). An echo that skips ahead is held, confirms its batch and keeps the typing on screen (the held-echo mechanism, which only unit tests caught). The undo scenario now has a non-empty undo stack when the resync clears it, and the rejected-batch outline types after the rejection to pin that Blocked means no sending. 27 scenarios, 32 runs, 120 tests. --- packages/io/gherkin-spec/keys.feature | 24 ++++++++++++++ .../io/gherkin-spec/other-editors.feature | 22 ++++++++----- .../out-of-step-and-resync.feature | 32 +++++++++++++++++++ .../sending-and-confirming.feature | 9 ++++-- packages/io/src/test/server.test.ts | 19 +++-------- packages/io/src/test/server.ts | 30 ++++++++--------- 6 files changed, 95 insertions(+), 41 deletions(-) diff --git a/packages/io/gherkin-spec/keys.feature b/packages/io/gherkin-spec/keys.feature index e399410da5..96d9b131a0 100644 --- a/packages/io/gherkin-spec/keys.feature +++ b/packages/io/gherkin-spec/keys.feature @@ -25,6 +25,30 @@ Feature: Keys When the server receives Editor A's batch 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 batch 1 + When the server receives Editor A's batch 1 + And Editor A's batch 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 batch 1 + When the server receives Editor B's batch 1 + Then the server has "B: foo;;B _key="k9": baz;;B _key="k9": bar" + When Editor A receives Editor B's batch 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 batch 2 + When the server receives Editor A's batch 2 + Then every block on the server has a unique key + Scenario: An unsent insert that would reuse a key another editor used gets a new key Given the document is "B: foo|" When "x" is typed diff --git a/packages/io/gherkin-spec/other-editors.feature b/packages/io/gherkin-spec/other-editors.feature index 9714804c4a..c481db074c 100644 --- a/packages/io/gherkin-spec/other-editors.feature +++ b/packages/io/gherkin-spec/other-editors.feature @@ -75,23 +75,29 @@ Feature: Other editors When "x" is typed Then Editor A shows "B: foox|" And Editor A has sent batch 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 batch 1 And Editor A's batch 1 comes back + Then Editor A has sent batch 2 + When the server receives Editor A's batch 2 + And Editor A's batch 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 batch 1 When the server receives Editor B's batch 1 - Then the server has "H1: foox" + Then the server has "H1: fooxy" When Editor A receives Editor B's batch 1 - Then Editor A shows "H1: foox|" + Then Editor A shows "H1: fooxy|" When undo is performed - Then Editor A shows "H1: foo|" - And Editor A has sent batch 2 - When the server receives Editor A's batch 2 - Then the server has "H1: foo" - When Editor A's batch 2 comes back + Then Editor A shows "H1: foox|" + And Editor A has sent batch 3 + When the server receives Editor A's batch 3 + Then the server has "H1: foox" + When Editor A's batch 3 comes back And Editor A is resynced And undo is performed - Then Editor A shows "H1: foo|" + Then Editor A shows "H1: foox|" And Editor A has sent nothing new diff --git a/packages/io/gherkin-spec/out-of-step-and-resync.feature b/packages/io/gherkin-spec/out-of-step-and-resync.feature index 609727f52c..967dc71d6e 100644 --- a/packages/io/gherkin-spec/out-of-step-and-resync.feature +++ b/packages/io/gherkin-spec/out-of-step-and-resync.feature @@ -40,6 +40,19 @@ Feature: Out of step and resync 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 batch 1 + When the server receives Editor B's batch 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 batch 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 @@ -114,3 +127,22 @@ Feature: Out of step and resync 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 batch, 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 batch 1 + When the style is set to "h1" in Editor B + Then Editor B has sent batch 1 + When the server receives Editor B's batch 1 + And the server receives Editor A's batch 1 + Then the server has "H1: foox" + When Editor A's batch 1 comes back + Then Editor A shows "B: foox|" + When "y" is typed + Then Editor A shows "B: fooxy|" + And Editor A has sent batch 2 + When Editor A receives Editor B's batch 1 + Then Editor A shows "H1: fooxy|" + And Editor A is in step diff --git a/packages/io/gherkin-spec/sending-and-confirming.feature b/packages/io/gherkin-spec/sending-and-confirming.feature index b79075587f..0c7dc4e0a1 100644 --- a/packages/io/gherkin-spec/sending-and-confirming.feature +++ b/packages/io/gherkin-spec/sending-and-confirming.feature @@ -61,11 +61,14 @@ Feature: Sending and confirming When Editor A's batch 1 is rejected Then Editor A has sent nothing new And Editor A shows "H1: fooy|" + When "z" is typed + Then Editor A shows "H1: fooyz|" + And Editor A has sent nothing new When Editor A Then Editor A shows "" And Editor A has sent Examples: - | keeps or discards | resync | state | sent | - | keeps | is resynced | B: fooy\| | batch 2 | - | discards | is resynced, discarding unsent changes | B: foo\| | nothing new | + | keeps or discards | resync | state | sent | + | keeps | is resynced | B: fooyz\| | batch 2 | + | discards | is resynced, discarding unsent changes | B: foo\| | nothing new | diff --git a/packages/io/src/test/server.test.ts b/packages/io/src/test/server.test.ts index 24c99da7dd..e9bcbf1e8a 100644 --- a/packages/io/src/test/server.test.ts +++ b/packages/io/src/test/server.test.ts @@ -115,12 +115,7 @@ describe(createServer.name, () => { const keyGenerator = createTestKeyGenerator() const {value} = parseTextspec({keyGenerator}, 'B: foo;;B _key="k9": baz') const server = createServer({documentId: 'document', document: {value}}) - const barBlock = { - _type: 'block', - _key: 'k9', - children: [{_type: 'span', _key: 'k4', text: 'bar', marks: []}], - style: 'normal', - } + const [barBlock] = parseTextspec({keyGenerator}, 'B _key="k9": bar').value server.receive( {id: 'b1', patches: [insert([barBlock], 'after', [{_key: 'k9'}])]}, @@ -149,13 +144,8 @@ describe(createServer.name, () => { test('a batch for a missing document creates it', () => { const server = createServer({documentId: 'document', document: undefined}) - const placeholder = { - _type: 'block', - _key: 'k0', - style: 'normal', - markDefs: [], - children: [{_type: 'span', _key: 'k1', text: '', marks: []}], - } + const keyGenerator = createTestKeyGenerator() + const [placeholder] = parseTextspec({keyGenerator}, 'B: ').value const patches = [ setIfMissing([], []), insert([placeholder], 'before', [0]), @@ -176,9 +166,8 @@ describe(createServer.name, () => { { _type: 'block', _key: 'k0', - style: 'normal', - markDefs: [], children: [{_type: 'span', _key: 'k1', text: 'x', marks: []}], + style: 'normal', }, ], rev: 'r1', diff --git a/packages/io/src/test/server.ts b/packages/io/src/test/server.ts index e829e5d064..a3bc9cecd4 100644 --- a/packages/io/src/test/server.ts +++ b/packages/io/src/test/server.ts @@ -68,21 +68,6 @@ export function createServer(initial: { rev = nextRevision() } - function nextRevision() { - revisionCounter++ - return `r${revisionCounter}` - } - - function record( - transaction: Omit, - resultRev: string | undefined, - ): ServerTransaction { - const recorded = {...transaction, previousRev: rev, resultRev} - transactions.push(recorded) - rev = resultRev - return recorded - } - function receiveBatches(batches: Array, transactionId: string) { const patches = batches.flatMap((batch) => batch.patches) value = applyWithContentLakeSemantics(value, patches) @@ -97,6 +82,21 @@ export function createServer(initial: { ) } + function record( + transaction: Omit, + resultRev: string | undefined, + ): ServerTransaction { + const recorded = {...transaction, previousRev: rev, resultRev} + transactions.push(recorded) + rev = resultRev + return recorded + } + + function nextRevision() { + revisionCounter++ + return `r${revisionCounter}` + } + return { documentId: initial.documentId, receive: (batch, transactionId) => receiveBatches([batch], transactionId), From 8d3776fbee611136f421f63a75f19f91df159c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 11:27:57 +0200 Subject: [PATCH 08/85] fix(io): undo puts back what the base held before the editor's own change Undo of a style compares the value the editor set with what the screen holds now. If they match, undo restores the style the base held right before the editor's change took effect, captured when the own transaction applied. If they differ, another writer changed it after, so undo leaves it alone. This needs no attribution of patches inside a mixed transaction, at the cost of one case: another writer's change under the editor's own inside the same transaction is restored to the pre-transaction value. Undo of a delete re-inserts the block as the base held it just before the delete took effect there, so a remote change to the block that arrived while the delete was in flight survives. Undo of typing finds the typed text nearest its recorded offset, so a remote insertion before it no longer disables the undo. The host's feed handoff learns the revision chain from every transaction it has seen, so a covered transaction that arrives after a resync is dropped instead of being held for 10 seconds. A late covered transaction with no known link can't be told from one that skipped ahead and is forwarded, which the editor holds as the protocol says. `load` after unmount warns instead of throwing. --- packages/io/src/document.test.ts | 4 +- packages/io/src/document.ts | 53 +++++++--- packages/io/src/editor.test.ts | 161 +++++++++++++++++++++++++++++++ packages/io/src/editor.ts | 155 +++++++++++++++++------------ packages/io/src/host.test.ts | 34 +++++++ packages/io/src/host.ts | 45 ++++++++- 6 files changed, 374 insertions(+), 78 deletions(-) diff --git a/packages/io/src/document.test.ts b/packages/io/src/document.test.ts index 3c1273c8b3..8aefbed651 100644 --- a/packages/io/src/document.test.ts +++ b/packages/io/src/document.test.ts @@ -102,7 +102,7 @@ describe(createDocument.name, () => { expect(result).toEqual({ patches: [set('h1', [{_key: 'k2'}, 'style'])], - undoStep: {type: 'styled', blockKey: 'k2', previousStyle: 'normal'}, + undoStep: {type: 'styled', blockKey: 'k2', style: 'h1'}, }) expect(document.toTextspec()).toEqual('B: foo;;H1: ba|r') expect(applyAll(before, result.patches)).toEqual(document.getValue()) @@ -384,7 +384,7 @@ describe(createDocument.name, () => { }) describe('reverting a change', () => { - test('typed text is deleted while it is still at its offset, and the caret moves back', () => { + test('typed text is deleted while its span still holds it, and the caret moves back', () => { const keyGenerator = createTestKeyGenerator() const document = createDocument( {keyGenerator}, diff --git a/packages/io/src/document.ts b/packages/io/src/document.ts index 6e1a2f2e55..f68a5767ba 100644 --- a/packages/io/src/document.ts +++ b/packages/io/src/document.ts @@ -35,7 +35,7 @@ export type Caret = {blockKey: string; offset: number} /** * What an action did, in terms undo can check against the content at undo - * time: text typed into a span at an offset, a block's style replaced, a + * time: text typed into a span at an offset, a block's style set, a * block inserted, or a block deleted next to its siblings. */ export type UndoStep = @@ -46,7 +46,7 @@ export type UndoStep = offset: number text: string } - | {type: 'styled'; blockKey: string; previousStyle: string | undefined} + | {type: 'styled'; blockKey: string; style: string} | {type: 'inserted'; blockKey: string} | { type: 'deleted' @@ -86,8 +86,8 @@ export type Document = { insertBlock: (textspec: string) => ActionResult deleteBlock: (text: string) => ActionResult /** - * Removes typed text if it is still in its span at its offset. Returns no - * patches when it isn't. + * Removes typed text from its span, at the occurrence nearest its offset. + * Returns no patches when the span no longer holds the text. */ deleteText: (typed: Extract) => Array /** Returns no patches when the block is gone. */ @@ -199,7 +199,7 @@ export function createDocument( undoStep: { type: 'styled', blockKey: block._key, - previousStyle: block.style, + style, }, } } @@ -281,18 +281,19 @@ export function createDocument( ) const span = block.children[spanIndex] - if ( - spanIndex === -1 || - !isSpan({schema}, span) || - span.text.slice(typed.offset, typed.offset + typed.text.length) !== - typed.text - ) { + if (spanIndex === -1 || !isSpan({schema}, span)) { + return [] + } + + const typedOffset = findNearest(span.text, typed.text, typed.offset) + + if (typedOffset === undefined) { return [] } const nextText = - span.text.slice(0, typed.offset) + - span.text.slice(typed.offset + typed.text.length) + span.text.slice(0, typedOffset) + + span.text.slice(typedOffset + typed.text.length) const spanStart = block.children .slice(0, spanIndex) .reduce( @@ -300,7 +301,7 @@ export function createDocument( length + (isSpan({schema}, child) ? child.text.length : 0), 0, ) - const deletionStart = spanStart + typed.offset + const deletionStart = spanStart + typedOffset value = replaceAt(value, blockIndex, { ...block, @@ -684,6 +685,30 @@ function locateSpan( ) } +/** + * The start of the occurrence of `search` in `text` nearest `offset`, looking + * after the offset first at each distance. + */ +function findNearest( + text: string, + search: string, + offset: number, +): number | undefined { + for ( + let distance = 0; + distance <= Math.max(offset, text.length); + distance++ + ) { + for (const candidate of [offset + distance, offset - distance]) { + if (candidate >= 0 && text.startsWith(search, candidate)) { + return candidate + } + } + } + + return undefined +} + function replaceAt( items: Array, index: number, diff --git a/packages/io/src/editor.test.ts b/packages/io/src/editor.test.ts index 519081c386..a63f43a676 100644 --- a/packages/io/src/editor.test.ts +++ b/packages/io/src/editor.test.ts @@ -313,6 +313,97 @@ describe(createIoEditor.name, () => { }) }) + test('undo leaves a style another writer set after the editor in the same transaction', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + const path = [{_key: 'd-k0'}, 'style'] + + editor.setStyle('h2') + editor.mutationSent({id: 'A-1', transactionId: 'A-1+B-1'}) + editor.transaction({ + transactionId: 'A-1+B-1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h2', path), set('h1', path)], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('H1: foo|') + expect(heard.mutations).toEqual([ + { + id: 'A-1', + patches: [set('h2', path)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'foo', marks: []}], + style: 'h2', + }, + ], + }, + ]) + }) + + test('undoing two confirmed style changes puts back each style in turn while the first undo is in flight', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + + editor.setStyle('h2') + editor.setStyle('h1') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.transaction({ + transactionId: 'A-2', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[1].patches, + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('H2: foo|') + + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: foo|') + }) + + test('undoing typing deletes the typed text where another writer moved it', () => { + const {editor, heard} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + + editor.type('x') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [diffMatchPatch('foox', 'yfoox', textPath)], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: yfoo|') + expect(heard.mutations[1]).toEqual({ + id: 'A-2', + patches: [diffMatchPatch('yfoox', 'yfoo', textPath)], + value: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_type: 'span', _key: 'd-k1', text: 'yfoo', marks: []}], + style: 'normal', + }, + ], + }) + }) + test("undoing the first keystroke into an empty field deletes the text and keeps another writer's content", () => { const {editor, heard} = createLoadedEditor(undefined) const textPath = [{_key: 'a-k0'}, 'children', {_key: 'a-k1'}, 'text'] @@ -411,6 +502,74 @@ describe(createIoEditor.name, () => { ]) }) + test('undoing a delete puts the block back as another writer changed it while the delete was in flight', () => { + const {editor, heard} = createLoadedEditor('B: foo|;;B: bar') + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;H1: bar', + ).value + + editor.deleteBlock('bar') + editor.transaction({ + transactionId: 't1', + previousRev: 'r1', + resultRev: 'r2', + patches: [set('h1', [{_key: 'd-k2'}, 'style'])], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: foo|;;H1: bar') + + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r2', + resultRev: 'r3', + patches: heard.mutations[0].patches, + }) + + expect(heard.mutations).toEqual([ + {id: 'A-1', patches: [unset([{_key: 'd-k2'}])], value: [fooBlock]}, + { + id: 'A-2', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + value: [fooBlock, barBlock], + }, + ]) + }) + + test('undoing a confirmed delete puts the block back as the base held it just before the delete', () => { + const {editor, heard} = createLoadedEditor('B: foo|;;B: bar') + const [fooBlock, barBlock] = parseTextspec( + {keyGenerator: createTestKeyGenerator('d-')}, + 'B: foo;;B: bar', + ).value + + editor.deleteBlock('bar') + editor.transaction({ + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patches: heard.mutations[0].patches, + }) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [set('h1', [{_key: 'd-k2'}, 'style'])], + }) + editor.undo() + + expect(editor.document.toTextspec()).toEqual('B: foo|;;B: bar') + expect(heard.mutations).toEqual([ + {id: 'A-1', patches: [unset([{_key: 'd-k2'}])], value: [fooBlock]}, + { + id: 'A-2', + patches: [insert([barBlock], 'after', [{_key: 'd-k0'}])], + value: [fooBlock, barBlock], + }, + ]) + }) + test('undoing a delete does nothing once the block is back', () => { const {editor, heard} = createLoadedEditor('B: foo|;;B: bar') const [fooBlock, barBlock] = parseTextspec( @@ -768,6 +927,7 @@ describe(createIoEditor.name, () => { patches: [set('h1', [{_key: 'd-k0'}, 'style'])], }) editor.resync({value: undefined, rev: 'r2'}) + editor.load({value: undefined, rev: 'r2'}) editor.type('x') editor.setStyle('h1') editor.insertBlock('B: bar') @@ -778,6 +938,7 @@ describe(createIoEditor.name, () => { expect(heard.warnings).toEqual([ 'Ignored transaction "t1" after the editor unmounted', 'Ignored a resync after the editor unmounted', + 'Ignored a load after the editor unmounted', 'Ignored an action after the editor unmounted', 'Ignored an action after the editor unmounted', 'Ignored an action after the editor unmounted', diff --git a/packages/io/src/editor.ts b/packages/io/src/editor.ts index 7c2a636077..53cbe25965 100644 --- a/packages/io/src/editor.ts +++ b/packages/io/src/editor.ts @@ -80,17 +80,20 @@ type SentBatch = { type HeldTransaction = {transaction: Transaction; arrivedAt: number} -/** - * The base's style for a block, or `undefined` when the base has no such - * block. - */ -type BaseStyle = {style: string | undefined} | undefined +/** A block's style, or `undefined` when there is no such block. */ +type BlockStyle = {style: string | undefined} | undefined type HistoryEntry = { step: UndoStep batchId: string | undefined - /** The base's style for the block at the action, reset at the change's echo. */ - baseStyle: BaseStyle + /** How many patches of the entry's batch come before the action's own. */ + patchOffset: number + /** + * The base's version of the step's block just before the change took + * effect there, captured when the change's transaction applied. `undefined` + * while the change is unconfirmed. + */ + confirmed: {blockBefore: PortableTextBlock | undefined} | undefined } /** @@ -157,6 +160,11 @@ export function createIoEditor(options: { } function load(incoming: Load) { + if (status === 'unmounted') { + warn('Ignored a load after the editor unmounted') + return + } + if (!options.claimLoad || status !== 'loading') { throw new Error('`load` is only accepted while the first load is claimed') } @@ -347,7 +355,7 @@ export function createIoEditor(options: { }) } - moveHistoryUnder(confirmedBatchIds, nextValue) + captureBlocksBefore(confirmedBatchIds) base = {value: nextValue, rev: incoming.resultRev} echoedAwaitingBase = echoedAwaitingBase.filter( (batch) => !confirmedBatchIds.has(batch.id), @@ -360,36 +368,40 @@ export function createIoEditor(options: { return true } + /** Runs before the base takes the transaction that confirms the batches. */ + function captureBlocksBefore(confirmedBatchIds: Set) { + history = history.map((entry) => + entry.batchId !== undefined && confirmedBatchIds.has(entry.batchId) + ? {...entry, confirmed: {blockBefore: blockUnder(entry)}} + : entry, + ) + } + /** - * A confirmed change is now part of the base, so the base's style for its - * block is its own from here on. What the base held just before is what - * the change replaced, if another writer moved it since the action. + * The step's block in the base with the editor's own unconfirmed changes + * from before the action applied: what the action changed, as far as the + * base goes. */ - function moveHistoryUnder( - confirmedBatchIds: Set, - nextValue: Array | undefined, - ) { - history = history.map((entry) => { - if ( - entry.step.type !== 'styled' || - entry.batchId === undefined || - !confirmedBatchIds.has(entry.batchId) - ) { - return entry - } + function blockUnder(entry: HistoryEntry): PortableTextBlock | undefined { + const batches = [ + ...unconfirmedBatches(), + {id: undefined, patches: pending.flat()}, + ] + const batchIndex = batches.findIndex((batch) => batch.id === entry.batchId) + + if (batchIndex === -1) { + return undefined + } - const styleBefore = baseStyleOf(base.value, entry.step.blockKey) - const previousStyle = - styleBefore !== undefined && !isEqual(styleBefore, entry.baseStyle) - ? styleBefore.style - : entry.step.previousStyle + const patchesBefore = [ + ...batches.slice(0, batchIndex).flatMap((batch) => batch.patches), + ...batches[batchIndex].patches.slice(0, entry.patchOffset), + ] - return { - ...entry, - step: {...entry.step, previousStyle}, - baseStyle: baseStyleOf(nextValue, entry.step.blockKey), - } - }) + return findBlock( + applyWithContentLakeSemantics(base.value, patchesBefore), + stepBlockKey(entry.step), + ) } function fail(error: ErrorEvent): false { @@ -476,7 +488,8 @@ export function createIoEditor(options: { { step: result.undoStep, batchId: undefined, - baseStyle: baseStyleOf(base.value, stepBlockKey(result.undoStep)), + patchOffset: pending.flat().length, + confirmed: undefined, }, ] } @@ -502,35 +515,50 @@ export function createIoEditor(options: { case 'typed': return document.deleteText(step) case 'styled': { - const style = styleUnder(entry, step) - const block = document - .getValue() - .find((candidate) => candidate._key === step.blockKey) + const block = findBlock(document.getValue(), step.blockKey) + + if (block === undefined) { + return [] + } - return block === undefined || block.style === style + const restored = styleToRestore(entry, step, block) + + return restored === undefined || block.style === restored.style ? [] - : document.setBlockStyle(step.blockKey, style) + : document.setBlockStyle(step.blockKey, restored.style) } case 'inserted': return document.deleteBlockByKey(step.blockKey) - case 'deleted': - return document.restoreBlock(step) + case 'deleted': { + const block = entry.confirmed + ? entry.confirmed.blockBefore + : blockUnder(entry) + + return block === undefined + ? [] + : document.restoreBlock({...step, block}) + } } } /** - * The style the base holds under the change: the base's own when another - * writer moved it since the change, else what the change replaced. + * Once the change is confirmed, a style on screen other than the one it set + * means another writer changed the style after it, and undo leaves it. The + * screen, not the base alone, since the editor's own later changes are + * undone first and may still be unconfirmed. */ - function styleUnder( + function styleToRestore( entry: HistoryEntry, step: Extract, - ): string | undefined { - const current = baseStyleOf(base.value, step.blockKey) + screenBlock: PortableTextBlock, + ): BlockStyle { + if (!entry.confirmed) { + return styleOf(blockUnder(entry)) + } - return current === undefined || isEqual(current, entry.baseStyle) - ? step.previousStyle - : current.style + return styleOf(screenBlock)?.style === step.style + ? styleOf(entry.confirmed.blockBefore) + : undefined } function canEdit(): boolean { @@ -642,18 +670,21 @@ export function createIoEditor(options: { } function deriveScreen(): Array | undefined { - const unconfirmedPatches = [ - ...echoedAwaitingBase, - ...(inFlight ? [inFlight] : []), - ...(rejected ? [rejected] : []), - ].flatMap((batch) => batch.patches) - return applyWithContentLakeSemantics(base.value, [ - ...unconfirmedPatches, + ...unconfirmedBatches().flatMap((batch) => batch.patches), ...pending.flat(), ]) } + /** Sent batches the base doesn't hold yet, in the order they were sent. */ + function unconfirmedBatches(): Array { + return [ + ...echoedAwaitingBase, + ...(inFlight ? [inFlight] : []), + ...(rejected ? [rejected] : []), + ] + } + function rekeyPendingInserts(): Map { const baseKeys = new Set( (base.value ?? []).flatMap((block) => itemKey(block)), @@ -746,12 +777,14 @@ function stepBlockKey(step: UndoStep): string { return step.type === 'deleted' ? step.block._key : step.blockKey } -function baseStyleOf( +function findBlock( value: Array | undefined, blockKey: string, -): BaseStyle { - const block = value?.find((candidate) => candidate._key === blockKey) +): PortableTextBlock | undefined { + return value?.find((candidate) => candidate._key === blockKey) +} +function styleOf(block: PortableTextBlock | undefined): BlockStyle { if (!block) { return undefined } diff --git a/packages/io/src/host.test.ts b/packages/io/src/host.test.ts index 46aec39249..f1827586c6 100644 --- a/packages/io/src/host.test.ts +++ b/packages/io/src/host.test.ts @@ -47,6 +47,40 @@ describe(createPassThroughHost.name, () => { expect(editor.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, 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(editor.getBase().rev).toEqual('r3') + expect(editor.document.toTextspec()).toEqual('H2: foo|') + }) + test('a transaction that skips ahead after the load reaches the editor', () => { const {editor, host, heard} = createHostedEditor('B: foo|') diff --git a/packages/io/src/host.ts b/packages/io/src/host.ts index 1acccf4132..653aac31c7 100644 --- a/packages/io/src/host.ts +++ b/packages/io/src/host.ts @@ -30,6 +30,15 @@ export type PassThroughHost = { * `resultRev` is the copy's revision, and the host drops them when they * arrive. The model's feed can deliver out of order, so arrival order can't * tell a covered transaction from one that skipped ahead. + * + * The host also remembers every transaction it has seen, forwarded or + * dropped, as a link from its `previousRev` to its `resultRev`. A copy at + * revision `R` covers `R` and every revision reachable backwards from it + * through known links. A transaction arriving later whose `resultRev` is + * covered is dropped, and its `previousRev` becomes covered too. A late + * covered transaction that neither the subscription nor a known link ties to + * the copy can't be told from one that skipped ahead, so it is forwarded, and + * the editor holds it for 10 s before it reports `out of order`. */ export function createPassThroughHost({ editor, @@ -47,6 +56,8 @@ export function createPassThroughHost({ let untakenBatchId: string | undefined let heldFinalBatch: MutationBatch | undefined let coveredTransactionIds = new Set() + let coveredRevs = new Set() + const previousRevs = new Map() editor.on((event) => { if (event.type !== 'mutation') { @@ -79,6 +90,8 @@ export function createPassThroughHost({ return copy } + coveredRevs = revsUpTo(copy.rev) + const buffered = subscription() const lastCoveredIndex = buffered.findLastIndex( (transaction) => transaction.resultRev === copy.rev, @@ -92,6 +105,28 @@ export function createPassThroughHost({ return copy } + function revsUpTo(rev: string | undefined): Set { + const revs = new Set() + let current = rev + + while (current !== undefined && !revs.has(current)) { + revs.add(current) + current = previousRevs.get(current) + } + + return revs + } + + function isCovered(transaction: Transaction): boolean { + const coveredById = coveredTransactionIds.delete(transaction.transactionId) + + return ( + coveredById || + (transaction.resultRev !== undefined && + coveredRevs.has(transaction.resultRev)) + ) + } + return { getTransactionId: (batchId) => { const transactionId = transactionIds.get(batchId) @@ -107,7 +142,15 @@ export function createPassThroughHost({ editor.mutationSent({id: batchId, transactionId}) }, forward: (transaction) => { - if (coveredTransactionIds.delete(transaction.transactionId)) { + if (transaction.resultRev !== undefined) { + previousRevs.set(transaction.resultRev, transaction.previousRev) + } + + if (isCovered(transaction)) { + if (transaction.previousRev !== undefined) { + coveredRevs.add(transaction.previousRev) + } + return } From f5ba2877ab9ddf0ad2095d915b123985ff4eb176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 11:28:30 +0200 Subject: [PATCH 09/85] docs(io): describe what the model proves, what is fake, and how to run it --- packages/io/README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/io/README.md diff --git a/packages/io/README.md b/packages/io/README.md new file mode 100644 index 0000000000..dc5ecbddee --- /dev/null +++ b/packages/io/README.md @@ -0,0 +1,36 @@ +# `@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 batch in flight, pending changes, held transactions, confirmation by echo, rejection, resync, load and status, key rules, undo | `src/editor.ts` | Real. Plain TypeScript, no React, no XState, no dependency on `@portabletext/editor`, so it can move into the editor | +| The host adapter: maps a batch to its transaction, reports `mutation sent` and `mutation rejected`, forwards the feed, fetches the server copy for `load` and `resync` | `src/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 batch | `src/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 path through a primitive fails | +| State notation | `@portabletext/test` | Real. Fixtures and assertions are textspec | +| The document and the user's actions | `src/document.ts` | Fake. Blocks, spans and a caret. Setting a style is a `set`, typing a `diffMatchPatch`, inserting and deleting blocks `insert` and `unset`, the placeholder becoming content `setIfMissing` plus `insert`, emptying the field `unset([])` | +| The server | `src/test/server.ts` | Fake. One document with a revision counter. Records a transaction for every batch it receives, changed or not, as Content Lake does | +| The network | `src/test/network.ts` | Fake. Queues that only the test steps drain, and a virtual clock | + +## 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. + +The step vocabulary lives in `src/test/steps.ts`. Happenings are `When` steps (a user types, the server receives a batch, the feed delivers a transaction, the host resyncs). Checks are `Then` steps, and every check observes the editor from the outside: what it shows, what it has sent, what listeners heard, what the server has. + +The same feature files are meant to run later against the real editor, with a fake host and server and a different set of step definitions. + +## Running + +```sh +pnpm --filter @portabletext/io test:unit +``` + +## Not modeled + +Batching by time (a change is sent as soon as nothing is in flight), the key-matched reconciler (the screen is recomputed wholesale), operations in `change` events (the model carries patches as a stand-in), redo, several load claims, and selection beyond a caret in one block. From 5cc034ea310ebf8925a3e34e84383ba7c499dd37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 12:04:25 +0200 Subject: [PATCH 10/85] refactor(io): expose the world, the steps and a scenario compiler The fakes move to `src/fakes/` and the world, steps and parameter types to `src/scenario/`, all exported, so something other than vitest can drive the model. The steps check with plain `Error`s instead of vitest's `expect`, which a browser page can't import. The world gains `snapshot()`, plain data for every part (each editor's screen, base, ledger, sent batches and heard events, the server's value, revision and log, the network's queues and clock), and one method per vocabulary action so free play and the steps share the same code. `compileScenarios(featureText)` turns a feature into scenarios whose steps run one at a time against a world, with the keyword and text of each step, built on racejar's `compileFeature` and `@cucumber/gherkin`. --- packages/io/README.md | 8 +- packages/io/package.json | 11 +- packages/io/src/document.test.ts | 14 + packages/io/src/document.ts | 15 + packages/io/src/editor.test.ts | 49 +- packages/io/src/editor.ts | 52 ++ .../io/src/{test => fakes}/network.test.ts | 0 packages/io/src/{test => fakes}/network.ts | 0 .../io/src/{test => fakes}/server.test.ts | 24 + packages/io/src/{test => fakes}/server.ts | 20 +- packages/io/src/host.test.ts | 4 +- packages/io/src/index.ts | 54 +- packages/io/src/scenario/check.ts | 45 ++ packages/io/src/scenario/compile.test.ts | 83 ++ packages/io/src/scenario/compile.ts | 97 +++ .../src/{test => scenario}/parameter-types.ts | 0 packages/io/src/{test => scenario}/steps.ts | 145 ++-- packages/io/src/scenario/world.test.ts | 156 ++++ packages/io/src/scenario/world.ts | 730 ++++++++++++++++++ packages/io/src/test/scenarios.test.ts | 6 +- packages/io/src/test/world.ts | 419 ---------- pnpm-lock.yaml | 48 +- 22 files changed, 1500 insertions(+), 480 deletions(-) rename packages/io/src/{test => fakes}/network.test.ts (100%) rename packages/io/src/{test => fakes}/network.ts (100%) rename packages/io/src/{test => fakes}/server.test.ts (91%) rename packages/io/src/{test => fakes}/server.ts (87%) create mode 100644 packages/io/src/scenario/check.ts create mode 100644 packages/io/src/scenario/compile.test.ts create mode 100644 packages/io/src/scenario/compile.ts rename packages/io/src/{test => scenario}/parameter-types.ts (100%) rename packages/io/src/{test => scenario}/steps.ts (74%) create mode 100644 packages/io/src/scenario/world.test.ts create mode 100644 packages/io/src/scenario/world.ts delete mode 100644 packages/io/src/test/world.ts diff --git a/packages/io/README.md b/packages/io/README.md index dc5ecbddee..ff7ef580bc 100644 --- a/packages/io/README.md +++ b/packages/io/README.md @@ -14,14 +14,14 @@ This package is private. It exists to prove the host contract before it lands in | Content Lake semantics for applying a batch | `src/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 path through a primitive fails | | State notation | `@portabletext/test` | Real. Fixtures and assertions are textspec | | The document and the user's actions | `src/document.ts` | Fake. Blocks, spans and a caret. Setting a style is a `set`, typing a `diffMatchPatch`, inserting and deleting blocks `insert` and `unset`, the placeholder becoming content `setIfMissing` plus `insert`, emptying the field `unset([])` | -| The server | `src/test/server.ts` | Fake. One document with a revision counter. Records a transaction for every batch it receives, changed or not, as Content Lake does | -| The network | `src/test/network.ts` | Fake. Queues that only the test steps drain, and a virtual clock | +| The server | `src/fakes/server.ts` | Fake. One document with a revision counter. Records a transaction for every batch it receives, changed or not, as Content Lake does | +| The network | `src/fakes/network.ts` | Fake. Queues that only the test steps drain, and a virtual clock | ## 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. -The step vocabulary lives in `src/test/steps.ts`. Happenings are `When` steps (a user types, the server receives a batch, the feed delivers a transaction, the host resyncs). Checks are `Then` steps, and every check observes the editor from the outside: what it shows, what it has sent, what listeners heard, what the server has. +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. Happenings are `When` steps (a user types, the server receives a batch, the feed delivers a transaction, the host resyncs). Checks are `Then` steps, and every check observes the editor from the outside: what it shows, what it has sent, what listeners heard, what the server has. The same feature files are meant to run later against the real editor, with a fake host and server and a different set of step definitions. @@ -31,6 +31,8 @@ The same feature files are meant to run later against the real editor, with a fa 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 Batching by time (a change is sent as soon as nothing is in flight), the key-matched reconciler (the screen is recomputed wholesale), operations in `change` events (the model carries patches as a stand-in), redo, several load claims, and selection beyond a caret in one block. diff --git a/packages/io/package.json b/packages/io/package.json index 5ff20b65f9..93e0a1bb32 100644 --- a/packages/io/package.json +++ b/packages/io/package.json @@ -7,6 +7,11 @@ "author": "Sanity.io ", "type": "module", "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./gherkin-spec/*": "./gherkin-spec/*", + "./package.json": "./package.json" + }, "scripts": { "check:types": "tsc", "check:types:watch": "tsc --watch", @@ -15,14 +20,16 @@ "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:^", - "@textspec/notation": "^1.0.2" + "@textspec/notation": "^1.0.2", + "racejar": "workspace:^" }, "devDependencies": { "@sanity/tsconfig": "catalog:tooling", - "racejar": "workspace:*", "typescript": "catalog:tooling", "vite": "catalog:tooling", "vitest": "catalog:tooling" diff --git a/packages/io/src/document.test.ts b/packages/io/src/document.test.ts index 8aefbed651..822fb1421d 100644 --- a/packages/io/src/document.test.ts +++ b/packages/io/src/document.test.ts @@ -13,6 +13,7 @@ import { createDocument, createsBlock, emptiesField, + formatTextspec, parseTextspec, } from './document' @@ -760,6 +761,19 @@ describe('the placeholder', () => { }) }) +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() diff --git a/packages/io/src/document.ts b/packages/io/src/document.ts index f68a5767ba..57136e8a26 100644 --- a/packages/io/src/document.ts +++ b/packages/io/src/document.ts @@ -617,6 +617,21 @@ export function comparableTextspec( } } +/** + * 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 batch turns the placeholder into content: it starts with a * whole-field `setIfMissing` followed by an `insert`. diff --git a/packages/io/src/editor.test.ts b/packages/io/src/editor.test.ts index a63f43a676..70968a6576 100644 --- a/packages/io/src/editor.test.ts +++ b/packages/io/src/editor.test.ts @@ -9,8 +9,8 @@ import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test} from 'vitest' import {parseTextspec} from './document' import {createIoEditor} from './editor' -import {createNetwork} from './test/network' -import {listenTo} from './test/world' +import {createNetwork} from './fakes/network' +import {listenTo} from './scenario/world' describe(createIoEditor.name, () => { test('held transactions are applied in chain order once the missing one arrives', () => { @@ -726,6 +726,51 @@ describe(createIoEditor.name, () => { ]) }) + test('`inspect` reports the batch in flight, the rejected one, pending changes and held transactions', () => { + const {editor, clock} = createLoadedEditor('B: foo|') + + editor.type('x') + editor.type('y') + clock.advance(500) + editor.transaction({ + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + patches: [], + }) + + expect(editor.inspect()).toEqual({ + inFlight: {id: 'A-1', transactionId: 'A-1', patchCount: 1}, + rejected: undefined, + echoed: [], + pending: [{patchCount: 1}], + held: [ + { + transactionId: 't2', + previousRev: 'r2', + resultRev: 'r3', + arrivedAt: 500, + }, + ], + outOfStep: false, + readOnly: false, + }) + + editor.mutationRejected({id: 'A-1'}) + editor.updateReadOnly(true) + clock.advance(10_000) + + expect(editor.inspect()).toEqual({ + inFlight: undefined, + rejected: {id: 'A-1', transactionId: 'A-1', patchCount: 1}, + echoed: [], + pending: [{patchCount: 1}], + held: [], + outOfStep: true, + readOnly: true, + }) + }) + test('a rejection for a batch that already came back is ignored with a warning', () => { const {editor, heard} = createLoadedEditor('B: foo|') const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] diff --git a/packages/io/src/editor.ts b/packages/io/src/editor.ts index 53cbe25965..7febdb47bf 100644 --- a/packages/io/src/editor.ts +++ b/packages/io/src/editor.ts @@ -42,11 +42,41 @@ export type IoEditorEvent = | {type: 'warning'; message: string} | {type: 'ready'} +/** + * A batch the editor has sent and the base doesn't hold yet. `transactionId` + * is `undefined` until the host reports `mutation sent`. + */ +export type IoEditorSentBatch = { + id: string + transactionId: string | undefined + patchCount: number +} + +/** + * The editor's protocol state, for display. `echoed` are batches whose + * transaction came back but waits in `held` behind a missing one, and + * `pending` holds one entry per local change not sent yet. + */ +export type IoEditorLedger = { + inFlight: IoEditorSentBatch | undefined + rejected: IoEditorSentBatch | undefined + echoed: Array + pending: Array<{patchCount: number}> + held: Array< + Pick & { + arrivedAt: number + } + > + outOfStep: boolean + readOnly: boolean +} + export type IoEditor = { /** The content on screen and the caret. */ document: Document getStatus: () => IoEditorStatus getBase: () => Load + inspect: () => IoEditorLedger on: (listener: (event: IoEditorEvent) => void) => () => void /** @@ -746,6 +776,20 @@ export function createIoEditor(options: { document, getStatus: () => status, getBase: () => base, + inspect: () => ({ + inFlight: inFlight ? describeSentBatch(inFlight) : undefined, + rejected: rejected ? describeSentBatch(rejected) : undefined, + echoed: echoedAwaitingBase.map(describeSentBatch), + pending: pending.map((patches) => ({patchCount: patches.length})), + held: held.map(({transaction, arrivedAt}) => ({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev, + resultRev: transaction.resultRev, + arrivedAt, + })), + outOfStep, + readOnly, + }), on: (listener) => { listeners.add(listener) return () => { @@ -773,6 +817,14 @@ export function createIoEditor(options: { } } +function describeSentBatch(batch: SentBatch): IoEditorSentBatch { + return { + id: batch.id, + transactionId: batch.transactionId, + patchCount: batch.patches.length, + } +} + function stepBlockKey(step: UndoStep): string { return step.type === 'deleted' ? step.block._key : step.blockKey } diff --git a/packages/io/src/test/network.test.ts b/packages/io/src/fakes/network.test.ts similarity index 100% rename from packages/io/src/test/network.test.ts rename to packages/io/src/fakes/network.test.ts diff --git a/packages/io/src/test/network.ts b/packages/io/src/fakes/network.ts similarity index 100% rename from packages/io/src/test/network.ts rename to packages/io/src/fakes/network.ts diff --git a/packages/io/src/test/server.test.ts b/packages/io/src/fakes/server.test.ts similarity index 91% rename from packages/io/src/test/server.test.ts rename to packages/io/src/fakes/server.test.ts index e9bcbf1e8a..0e4ee0264a 100644 --- a/packages/io/src/test/server.test.ts +++ b/packages/io/src/fakes/server.test.ts @@ -293,6 +293,30 @@ describe(createServer.name, () => { 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 = createServer({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') diff --git a/packages/io/src/test/server.ts b/packages/io/src/fakes/server.ts similarity index 87% rename from packages/io/src/test/server.ts rename to packages/io/src/fakes/server.ts index a3bc9cecd4..f663dc9666 100644 --- a/packages/io/src/test/server.ts +++ b/packages/io/src/fakes/server.ts @@ -46,6 +46,11 @@ export type Server = { ) => ServerTransaction copy: () => ServerCopy 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 } @@ -61,6 +66,7 @@ export function createServer(initial: { let value: Array | undefined let rev: string | undefined const transactions: Array = [] + const log: Array<{transaction: ServerTransaction; changesField: boolean}> = [] const refusedBatchIds = new Set() if (initial.document) { @@ -70,6 +76,7 @@ export function createServer(initial: { function receiveBatches(batches: Array, transactionId: string) { const patches = batches.flatMap((batch) => batch.patches) + const valueBefore = value value = applyWithContentLakeSemantics(value, patches) return record( @@ -79,15 +86,18 @@ export function createServer(initial: { batchIds: batches.map((batch) => batch.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) + log.push({transaction: recorded, changesField}) rev = resultRev return recorded } @@ -111,18 +121,24 @@ export function createServer(initial: { throw new Error('The document does not exist') } - return record({transactionId, patches: [], batchIds: []}, nextRevision()) + return record( + {transactionId, patches: [], batchIds: []}, + nextRevision(), + false, + ) }, deleteDocument: (transactionId) => { if (rev === undefined) { throw new Error('The document does not exist') } + const valueBefore = value value = undefined return record( {transactionId, patches: [unset([])], batchIds: []}, undefined, + valueBefore !== undefined, ) }, recreate: (nextValue, transactionId) => { @@ -135,10 +151,12 @@ export function createServer(initial: { return record( {transactionId, patches: [set(nextValue, [])], batchIds: []}, nextRevision(), + true, ) }, copy: () => ({value: structuredClone(value), rev}), getTransactions: () => transactions, + getLog: () => log, getTransaction: (transactionId) => { const transaction = transactions.find( (candidate) => candidate.transactionId === transactionId, diff --git a/packages/io/src/host.test.ts b/packages/io/src/host.test.ts index f1827586c6..a4ca8ccab5 100644 --- a/packages/io/src/host.test.ts +++ b/packages/io/src/host.test.ts @@ -3,9 +3,9 @@ import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test} from 'vitest' import {parseTextspec} from './document' import {createIoEditor} from './editor' +import {createNetwork} from './fakes/network' import {createPassThroughHost} from './host' -import {createNetwork} from './test/network' -import {listenTo} from './test/world' +import {listenTo} from './scenario/world' import type {Load, MutationBatch, Transaction} from './types' describe(createPassThroughHost.name, () => { diff --git a/packages/io/src/index.ts b/packages/io/src/index.ts index fb09bc6cff..d41166a29a 100644 --- a/packages/io/src/index.ts +++ b/packages/io/src/index.ts @@ -1,5 +1,12 @@ export {createIoEditor} from './editor' -export type {Clock, IoEditor, IoEditorEvent, IoEditorStatus} from './editor' +export type { + Clock, + IoEditor, + IoEditorEvent, + IoEditorLedger, + IoEditorSentBatch, + IoEditorStatus, +} from './editor' export {createPassThroughHost} from './host' export type {PassThroughHost} from './host' export type { @@ -13,3 +20,48 @@ export type { Resync, Transaction, } from './types' + +export type { + Network, + NetworkReceiver, + Reply, + SaveRequest, + VirtualClock, +} from './fakes/network' +export type { + SavedBatch, + Server, + ServerCopy, + ServerTransaction, +} from './fakes/server' + +export { + createWorld, + editorNames, + heldTransactionTimeout, +} from './scenario/world' +export type { + BatchSnapshot, + EditorName, + EditorSnapshot, + Heard, + HeardEvent, + NamedTransaction, + NetworkSnapshot, + ServerCopyName, + ServerSnapshot, + TransactionSource, + World, + WorldEditor, + WorldSnapshot, +} from './scenario/world' +export {stepDefinitions} from './scenario/steps' +export type {Context} from './scenario/steps' +export {parameterTypes} from './scenario/parameter-types' +export type {BatchReference} from './scenario/parameter-types' +export {compileScenarios} from './scenario/compile' +export type { + CompiledScenario, + CompiledScenarios, + CompiledStep, +} from './scenario/compile' diff --git a/packages/io/src/scenario/check.ts b/packages/io/src/scenario/check.ts new file mode 100644 index 0000000000..f122dd503a --- /dev/null +++ b/packages/io/src/scenario/check.ts @@ -0,0 +1,45 @@ +/** + * The checks the `Then` steps make. They throw a plain `Error` naming what + * was checked, so the steps run outside a test framework as well as in one. + */ +export function checkEqual( + what: string, + actual: string | number | boolean | undefined, + expected: string | number | boolean | undefined, +): void { + if (!Object.is(actual, expected)) { + throw new Error( + `${what}: expected ${format(expected)}, got ${format(actual)}`, + ) + } +} + +export function checkNotEqual( + what: string, + actual: string | number | boolean | undefined, + unexpected: string | number | boolean | undefined, +): void { + if (Object.is(actual, unexpected)) { + throw new Error(`${what}: expected anything but ${format(unexpected)}`) + } +} + +export function checkGreaterThan( + what: string, + actual: number, + bound: number, +): void { + if (!(actual > bound)) { + throw new Error(`${what}: expected more than ${bound}, got ${actual}`) + } +} + +export function checkEmpty(what: string, actual: Array): void { + if (actual.length > 0) { + throw new Error(`${what}: expected none, got ${format(actual)}`) + } +} + +function format(value: unknown): string { + return value === undefined ? 'undefined' : JSON.stringify(value) +} diff --git a/packages/io/src/scenario/compile.test.ts b/packages/io/src/scenario/compile.test.ts new file mode 100644 index 0000000000..f8d0c2fd38 --- /dev/null +++ b/packages/io/src/scenario/compile.test.ts @@ -0,0 +1,83 @@ +import {describe, expect, test} from 'vitest' +import listenersFeature from '../../gherkin-spec/listeners.feature?raw' +import loadingAndEmptyFeature from '../../gherkin-spec/loading-and-empty.feature?raw' +import {compileScenarios} from './compile' +import {createWorld} from './world' + +describe(compileScenarios.name, () => { + test('lists every scenario and every row of an outline', () => { + const {feature, scenarios} = compileScenarios(loadingAndEmptyFeature) + + expect({ + feature, + names: scenarios.map((scenario) => scenario.name), + }).toEqual({ + feature: 'Loading and empty', + names: [ + "An editor that doesn't claim the first load starts ready and empty, and a resync fills it", + 'An editor that claims the first load waits for it', + 'Releasing the claim makes the editor ready and empty', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has no document)', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has no field)', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has an empty list)', + 'An empty field shows the placeholder, and a lone empty block is real content (the server has one empty block "b1")', + 'Emptying the field sends a whole-field unset', + ], + }) + }) + + test('a scenario runs one step at a time to the end', async () => { + const [scenario] = compileScenarios(listenersFeature).scenarios + const world = createWorld() + + for (const step of scenario.steps) { + await step.run(world) + } + + expect( + scenario.steps.map((step) => `${step.keyword} ${step.text}`), + ).toEqual([ + 'Given the document is "B: foo|"', + 'Then Editor A has emitted no change', + 'When "x" is typed', + 'Then Editor A has emitted 1 change', + 'And Editor A has sent batch 1', + "When the server receives Editor A's batch 1", + "And Editor A's batch 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 batch 1', + "When the server receives Editor B's batch 1", + "And Editor A receives Editor B's batch 1", + 'Then Editor A shows "H1: foox|"', + 'And Editor A has emitted 2 changes', + ]) + expect(world.snapshot().editors?.['Editor A'].screen).toEqual('H1: foox|') + }) + + test('a wrong expectation fails at its own step', async () => { + const [scenario] = compileScenarios( + listenersFeature.replace( + 'Then Editor A shows "H1: foox|"', + 'Then Editor A shows "H1: foo|"', + ), + ).scenarios + const world = createWorld() + const outcomes: Array = [] + + for (const step of scenario.steps) { + try { + await step.run(world) + outcomes.push('passed') + } catch (error) { + outcomes.push(error instanceof Error ? error.message : String(error)) + break + } + } + + expect(outcomes).toEqual([ + ...Array.from({length: 12}, () => 'passed'), + 'What Editor A shows: expected "H1: foo|", got "H1: foox|"', + ]) + }) +}) diff --git a/packages/io/src/scenario/compile.ts b/packages/io/src/scenario/compile.ts new file mode 100644 index 0000000000..9ab328e238 --- /dev/null +++ b/packages/io/src/scenario/compile.ts @@ -0,0 +1,97 @@ +import * as Gherkin from '@cucumber/gherkin' +import * as Messages from '@cucumber/messages' +import {compileFeature} from 'racejar' +import {parameterTypes} from './parameter-types' +import {stepDefinitions, type Context} from './steps' +import type {World} from './world' + +export type CompiledStep = { + /** The step's keyword as written: `Given`, `When`, `Then`, `And`, `But`. */ + keyword: string + text: string + run: (world: World) => void | Promise +} + +export type CompiledScenario = { + name: string + steps: Array +} + +export type CompiledScenarios = { + feature: string + scenarios: Array +} + +/** + * Compiles a feature file against the package's step definitions, one entry + * per scenario and per row of a scenario outline. Each step runs on its own + * against the world it is given, so a caller can run a scenario one step at + * a time and catch the step that fails. + */ +export function compileScenarios(featureText: string): CompiledScenarios { + const compiled = compileFeature({ + featureText, + stepDefinitions, + parameterTypes, + }) + const pickles = parsePickles(featureText) + + if (pickles.scenarios.length !== compiled.scenarios.length) { + throw new Error( + `Expected ${compiled.scenarios.length} scenarios in "${compiled.name}", parsed ${pickles.scenarios.length}`, + ) + } + + return { + feature: compiled.name, + scenarios: compiled.scenarios.map((scenario, scenarioIndex) => { + const parsedSteps = pickles.scenarios[scenarioIndex].steps + + return { + name: scenario.name, + steps: scenario.steps.map((runStep, stepIndex) => ({ + keyword: parsedSteps[stepIndex].keyword, + text: parsedSteps[stepIndex].text, + run: (world) => runStep({world}), + })), + } + }), + } +} + +function parsePickles(featureText: string): { + scenarios: Array<{steps: Array<{keyword: string; text: string}>}> +} { + const newId = Messages.IdGenerator.incrementing() + const parser = new Gherkin.Parser( + new Gherkin.AstBuilder(newId), + new Gherkin.GherkinClassicTokenMatcher(), + ) + const gherkinDocument = parser.parse(featureText) + const keywords = new Map() + + for (const step of astSteps(gherkinDocument.feature?.children ?? [])) { + keywords.set(step.id, step.keyword.trim()) + } + + const pickles = Gherkin.compile(gherkinDocument, '', newId) + + return { + scenarios: pickles.map((pickle) => ({ + steps: pickle.steps.map((step) => ({ + keyword: keywords.get(step.astNodeIds[0] ?? '') ?? '', + text: step.text, + })), + })), + } +} + +function astSteps( + children: ReadonlyArray, +): Array { + return children.flatMap((child) => [ + ...(child.background?.steps ?? []), + ...(child.scenario?.steps ?? []), + ...('rule' in child && child.rule ? astSteps(child.rule.children) : []), + ]) +} diff --git a/packages/io/src/test/parameter-types.ts b/packages/io/src/scenario/parameter-types.ts similarity index 100% rename from packages/io/src/test/parameter-types.ts rename to packages/io/src/scenario/parameter-types.ts diff --git a/packages/io/src/test/steps.ts b/packages/io/src/scenario/steps.ts similarity index 74% rename from packages/io/src/test/steps.ts rename to packages/io/src/scenario/steps.ts index cbcb3db845..f89b964a5a 100644 --- a/packages/io/src/test/steps.ts +++ b/packages/io/src/scenario/steps.ts @@ -1,10 +1,20 @@ import type {PortableTextBlock} from '@portabletext/schema' import {Given, Then, When} from 'racejar' -import {expect} from 'vitest' -import {comparableTextspec, createsBlock, emptiesField} from '../document' +import { + comparableTextspec, + createsBlock, + emptiesField, + formatTextspec, +} from '../document' import type {IoEditorStatus} from '../editor' +import {checkEmpty, checkEqual, checkGreaterThan, checkNotEqual} from './check' import type {BatchReference} from './parameter-types' -import type {EditorName, ServerCopyName, World} from './world' +import { + heldTransactionTimeout, + type EditorName, + type ServerCopyName, + type World, +} from './world' export type Context = {world: World} @@ -111,7 +121,7 @@ export const stepDefinitions = [ }, ), When('the wait for the missing transaction runs out', (context: Context) => { - context.world.runOutHeldTransactionWait() + context.world.advanceClock(heldTransactionTimeout) }), When( @@ -136,16 +146,16 @@ export const stepDefinitions = [ }, ), When('{editor} is loaded', (context: Context, name: EditorName) => { - context.world.getEditor(name).host.load() + context.world.load(name) }), When('the claim is released', (context: Context) => { - context.world.getEditor('Editor A').editor.releaseClaim() + context.world.releaseClaim('Editor A') }), When('{editor} becomes read-only', (context: Context, name: EditorName) => { - context.world.getEditor(name).editor.updateReadOnly(true) + context.world.becomeReadOnly(name) }), When('{editor} is closed', (context: Context, name: EditorName) => { - context.world.getEditor(name).editor.close() + context.world.close(name) }), Then( @@ -157,7 +167,7 @@ export const stepDefinitions = [ textspec, ) - expect(actual).toEqual(expected) + checkEqual(`What ${name} shows`, actual, expected) }, ), Then('the server has {textspec}', (context: Context, textspec: string) => { @@ -166,39 +176,43 @@ export const stepDefinitions = [ textspec, ) - expect(actual).toEqual(expected) + checkEqual('What the server has', actual, expected) }), Then('the server has no document', (context: Context) => { - expect(context.world.getServer().copy()).toEqual({ - value: undefined, - rev: undefined, - }) + const copy = context.world.getServer().copy() + + checkEqual("The server's field", describeField(copy.value), 'no field') + checkEqual("The server's revision", copy.rev, undefined) }), Then('the server has no field', (context: Context) => { const copy = context.world.getServer().copy() - expect(copy.value).toEqual(undefined) - expect(copy.rev).not.toEqual(undefined) + checkEqual("The server's field", describeField(copy.value), 'no field') + checkNotEqual("The server's revision", copy.rev, undefined) }), Then( 'every block in {editor} has a unique key', (context: Context, name: EditorName) => { const value = context.world.getEditor(name).editor.document.getValue() - expect(duplicateOrMissingKeys(value)).toEqual([]) + checkEmpty(`Key problems in ${name}`, duplicateOrMissingKeys(value)) }, ), Then('every block on the server has a unique key', (context: Context) => { const value = context.world.getServer().copy().value ?? [] - expect(duplicateOrMissingKeys(value)).toEqual([]) + checkEmpty('Key problems on the server', duplicateOrMissingKeys(value)) }), Then( '{editor} has sent batch {int}', (context: Context, name: EditorName, batchNumber: number) => { const worldEditor = context.world.getEditor(name) - expect(worldEditor.heard.mutations.length).toEqual(batchNumber) + checkEqual( + `The batches ${name} has sent`, + worldEditor.heard.mutations.length, + batchNumber, + ) worldEditor.checkedBatchCount = batchNumber }, ), @@ -207,7 +221,9 @@ export const stepDefinitions = [ (context: Context, name: EditorName) => { const worldEditor = context.world.getEditor(name) - expect(worldEditor.heard.mutations.length).toEqual( + checkEqual( + `The batches ${name} has sent`, + worldEditor.heard.mutations.length, worldEditor.checkedBatchCount, ) }, @@ -217,32 +233,42 @@ export const stepDefinitions = [ (context: Context, name: EditorName) => { const worldEditor = context.world.getEditor(name) - expect(worldEditor.heard.mutations.at(-1)?.final).toEqual(true) + checkEqual( + `Whether ${name}'s last batch is final`, + worldEditor.heard.mutations.at(-1)?.final, + true, + ) worldEditor.checkedBatchCount = worldEditor.heard.mutations.length }, ), Then( "{editor}'s batch {int} creates the block", (context: Context, name: EditorName, batchNumber: number) => { - expect( + checkEqual( + `Whether ${name}'s batch ${batchNumber} creates the block`, createsBlock(context.world.getBatch(name, batchNumber).patches), - ).toEqual(true) + true, + ) }, ), Then( "{editor}'s batch {int} does not create a block", (context: Context, name: EditorName, batchNumber: number) => { - expect( + checkEqual( + `Whether ${name}'s batch ${batchNumber} creates a block`, createsBlock(context.world.getBatch(name, batchNumber).patches), - ).toEqual(false) + false, + ) }, ), Then( "{editor}'s batch {int} empties the field", (context: Context, name: EditorName, batchNumber: number) => { - expect( + checkEqual( + `Whether ${name}'s batch ${batchNumber} empties the field`, emptiesField(context.world.getBatch(name, batchNumber).patches), - ).toEqual(true) + true, + ) }, ), Then( @@ -251,41 +277,70 @@ export const stepDefinitions = [ const worldEditor = context.world.getEditor(name) const errorCount = worldEditor.heard.errors.length - expect(errorCount).toBeGreaterThan(worldEditor.checkedErrorCount) + checkGreaterThan( + `The errors ${name} has reported`, + errorCount, + worldEditor.checkedErrorCount, + ) worldEditor.checkedErrorCount = errorCount }, ), Then('{editor} is in step', (context: Context, name: EditorName) => { const worldEditor = context.world.getEditor(name) - expect( + checkEmpty( + `New errors from ${name}`, worldEditor.heard.errors.slice(worldEditor.checkedErrorCount), - ).toEqual([]) + ) }), Then('the resync is refused', (context: Context) => { const resync = context.world.getLastResync() const {editor, heard} = context.world.getEditor(resync.editorName) - expect(heard.warnings.length).toBeGreaterThan(resync.warningCount) - expect(editor.document.toTextspec({keys: true})).toEqual(resync.screen) - expect(heard.mutations.length).toEqual(resync.batchCount) + checkGreaterThan( + `The warnings ${resync.editorName} has given`, + heard.warnings.length, + resync.warningCount, + ) + checkEqual( + `What ${resync.editorName} shows`, + editor.document.toTextspec({keys: true}), + resync.screen, + ) + checkEqual( + `The batches ${resync.editorName} has sent`, + heard.mutations.length, + resync.batchCount, + ) }), Then( "{editor}'s status is {status}", (context: Context, name: EditorName, status: IoEditorStatus) => { - expect(context.world.getEditor(name).editor.getStatus()).toEqual(status) + checkEqual( + `${name}'s status`, + context.world.getEditor(name).editor.getStatus(), + status, + ) }, ), Then( '{editor} has emitted {int} change(s)', (context: Context, name: EditorName, count: number) => { - expect(context.world.getEditor(name).heard.changes.length).toEqual(count) + checkEqual( + `The changes ${name} has emitted`, + context.world.getEditor(name).heard.changes.length, + count, + ) }, ), Then( '{editor} has emitted no change', (context: Context, name: EditorName) => { - expect(context.world.getEditor(name).heard.changes).toEqual([]) + checkEqual( + `The changes ${name} has emitted`, + context.world.getEditor(name).heard.changes.length, + 0, + ) }, ), ] @@ -295,27 +350,27 @@ function userSteps() { { text: '{string} is typed', run: (context: Context, name: EditorName, text: string) => - context.world.getEditor(name).editor.type(text), + context.world.type(name, text), }, { text: 'the caret is put after {string}', run: (context: Context, name: EditorName, text: string) => - context.world.getEditor(name).editor.putCaretAfter(text), + context.world.putCaretAfter(name, text), }, { text: 'the style is set to {style}', run: (context: Context, name: EditorName, style: string) => - context.world.getEditor(name).editor.setStyle(style), + context.world.setStyle(name, style), }, { text: 'the block {textspec} is inserted', run: (context: Context, name: EditorName, textspec: string) => - context.world.getEditor(name).editor.insertBlock(textspec), + context.world.insertBlock(name, textspec), }, { text: 'the block {string} is deleted', run: (context: Context, name: EditorName, text: string) => - context.world.getEditor(name).editor.deleteBlock(text), + context.world.deleteBlock(name, text), }, ] @@ -331,17 +386,21 @@ function userSteps() { ), ]), When('undo is performed', (context: Context) => { - context.world.getEditor('Editor A').editor.undo() + context.world.undo('Editor A') }), When( 'undo is performed in {editor}', (context: Context, name: EditorName) => { - context.world.getEditor(name).editor.undo() + context.world.undo(name) }, ), ] } +function describeField(value: Array | undefined): string { + return value === undefined ? 'no field' : formatTextspec(value) +} + function duplicateOrMissingKeys( value: Array, ): Array { diff --git a/packages/io/src/scenario/world.test.ts b/packages/io/src/scenario/world.test.ts new file mode 100644 index 0000000000..51427938aa --- /dev/null +++ b/packages/io/src/scenario/world.test.ts @@ -0,0 +1,156 @@ +import {describe, expect, test} from 'vitest' +import {createWorld} from './world' + +describe(createWorld.name, () => { + test('a snapshot before the editors exist has no parts', () => { + const world = createWorld() + + world.serverHas('B: foo') + + expect(world.snapshot()).toEqual({ + editors: null, + server: null, + network: null, + }) + }) + + test('a snapshot shows the editors, the server and the network as plain data', () => { + const world = createWorld() + + world.documentIs('B: foo|') + world.type('Editor A', 'x') + world.setStyle('Editor B', 'h1') + world.receive('Editor A', 1) + world.changeOtherField() + world.deliverNamed('Editor B', 'the other field') + world.type('Editor A', 'y') + + expect(world.snapshot()).toEqual({ + editors: { + 'Editor A': { + id: 'A', + status: 'ready', + screen: 'B: fooxy|', + base: {textspec: 'B: foo', rev: 'r1'}, + inFlight: {batchNumber: 1, transactionId: 'A-1', patchCount: 1}, + rejected: null, + echoed: [], + pending: [{patchCount: 1}], + held: [], + outOfStep: false, + readOnly: false, + sentBatches: [ + {number: 1, transactionId: 'A-1', patchCount: 1, final: false}, + ], + events: [ + {type: 'change', origin: 'local', patchCount: 1}, + {type: 'change', origin: 'local', patchCount: 1}, + ], + }, + 'Editor B': { + id: 'B', + status: 'ready', + screen: 'H1: foo|', + base: {textspec: 'B: foo', rev: 'r1'}, + inFlight: {batchNumber: 1, transactionId: 'B-1', patchCount: 1}, + rejected: null, + echoed: [], + pending: [], + held: [ + { + transactionId: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + }, + ], + outOfStep: false, + readOnly: false, + sentBatches: [ + {number: 1, transactionId: 'B-1', patchCount: 1, final: false}, + ], + events: [{type: 'change', origin: 'local', patchCount: 1}], + }, + }, + server: { + value: 'B: foox', + rev: 'r3', + transactions: [ + { + id: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + batchIds: ['A-1'], + patchCount: 1, + noop: false, + source: { + type: 'batches', + batches: [{name: 'Editor A', batchNumber: 1}], + }, + }, + { + id: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + batchIds: [], + patchCount: 0, + noop: true, + source: {type: 'named', name: 'the other field'}, + }, + ], + }, + network: { + saveRequests: [ + { + editor: 'Editor B', + batchId: 'B-1', + batchNumber: 1, + final: false, + patchCount: 1, + }, + ], + replies: [ + { + editor: 'Editor A', + batchId: 'A-1', + batchNumber: 1, + outcome: 'accepted', + }, + ], + feeds: { + 'Editor A': [ + { + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patchCount: 1, + source: { + type: 'batches', + batches: [{name: 'Editor A', batchNumber: 1}], + }, + }, + { + transactionId: 'other-field-1', + previousRev: 'r2', + resultRev: 'r3', + patchCount: 0, + source: {type: 'named', name: 'the other field'}, + }, + ], + 'Editor B': [ + { + transactionId: 'A-1', + previousRev: 'r1', + resultRev: 'r2', + patchCount: 1, + source: { + type: 'batches', + batches: [{name: 'Editor A', batchNumber: 1}], + }, + }, + ], + }, + now: 0, + }, + }) + }) +}) diff --git a/packages/io/src/scenario/world.ts b/packages/io/src/scenario/world.ts new file mode 100644 index 0000000000..b731bcc737 --- /dev/null +++ b/packages/io/src/scenario/world.ts @@ -0,0 +1,730 @@ +import type {PortableTextBlock} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {formatTextspec, parseTextspec} from '../document' +import { + createIoEditor, + type IoEditor, + type IoEditorSentBatch, + type IoEditorStatus, +} from '../editor' +import {createNetwork, type Network} from '../fakes/network' +import { + createServer, + type Server, + type ServerTransaction, +} from '../fakes/server' +import {createPassThroughHost, type PassThroughHost} from '../host' +import type {ChangeEvent, ErrorEvent, MutationBatch} from '../types' + +export type EditorName = 'Editor A' | 'Editor B' + +export type ServerCopyName = 'no document' | 'no field' | 'an empty list' + +/** + * What the editor's listeners received, recorded from the moment it was + * created. + */ +export type Heard = { + mutations: Array + changes: Array + errors: Array + warnings: Array + /** Changes, errors and warnings in the order they were heard. */ + events: Array +} + +export type HeardEvent = + | {type: 'change'; origin: ChangeEvent['origin']; patchCount: number} + | ({type: 'error'} & Pick) + | {type: 'warning'; message: string} + +export type NamedTransaction = + | 'the other field' + | 'the deletion' + | 'the recreation' + +/** + * What a transaction carries: batches sent by the editors, or a change made + * on the server. + */ +export type TransactionSource = + | {type: 'batches'; batches: Array<{name: EditorName; batchNumber: number}>} + | {type: 'named'; name: NamedTransaction} + +export type BatchSnapshot = { + batchNumber: number + transactionId: string | null + patchCount: number +} + +export type EditorSnapshot = { + id: string + status: IoEditorStatus + /** What the editor shows, with the caret. */ + screen: string + base: {textspec: string | null; rev: string | null} + inFlight: BatchSnapshot | null + rejected: BatchSnapshot | null + /** Batches that came back and wait behind a held transaction. */ + echoed: Array + pending: Array<{patchCount: number}> + held: Array<{ + transactionId: string + previousRev: string | null + resultRev: string | null + }> + outOfStep: boolean + readOnly: boolean + sentBatches: Array<{ + number: number + transactionId: string + patchCount: number + final: boolean + }> + events: Array +} + +export type ServerSnapshot = { + /** The field as textspec, `null` when there is no field. */ + value: string | null + rev: string | null + transactions: Array<{ + id: string + previousRev: string | null + resultRev: string | null + batchIds: Array + patchCount: number + noop: boolean + source: TransactionSource + }> +} + +export type NetworkSnapshot = { + saveRequests: Array<{ + editor: EditorName + batchId: string + batchNumber: number + final: boolean + patchCount: number + }> + replies: Array<{ + editor: EditorName + batchId: string + batchNumber: number + outcome: 'accepted' | 'rejected' + }> + feeds: Record< + EditorName, + Array<{ + transactionId: string + previousRev: string | null + resultRev: string | null + patchCount: number + source: TransactionSource + }> + > + now: number +} + +/** + * The world as plain data. `null` parts before the editors exist. + */ +export type WorldSnapshot = { + editors: Record | null + server: ServerSnapshot | null + network: NetworkSnapshot | null +} + +export type WorldEditor = { + editor: IoEditor + host: PassThroughHost + heard: Heard + /** The batch count at the previous `has sent` check. */ + checkedBatchCount: number + /** The error count at the previous out-of-step or in-step check. */ + checkedErrorCount: number +} + +type Setup = { + server: Server + network: Network + editors: Record +} + +type ResyncAttempt = { + editorName: EditorName + warningCount: number + screen: string + batchCount: number +} + +export type World = ReturnType + +export const editorNames: ReadonlyArray = ['Editor A', 'Editor B'] + +/** How long an editor holds a transaction before it reports `out of order`. */ +export const heldTransactionTimeout = 10_000 + +/** + * One server, one network and two editors, each with a pass-through host. + * The server's initial document is set up first, and the editors are created + * on demand, so steps can shape the document before anyone loads it. + */ +export function createWorld() { + const documentKeyGenerator = createTestKeyGenerator('d-') + let initialDocument: {value: Array | undefined} | undefined + let setup: Setup | undefined + let lastResync: ResyncAttempt | undefined + const namedTransactions = new Map() + const namedTransactionCounts = new Map() + + function startEditors({claimLoad}: {claimLoad: boolean}): Setup { + const server = createServer({ + documentId: 'document', + document: initialDocument, + }) + const network = createNetwork() + const editors = { + 'Editor A': createWorldEditor({ + name: 'Editor A', + server, + network, + claimLoad, + }), + 'Editor B': createWorldEditor({ + name: 'Editor B', + server, + network, + claimLoad, + }), + } + + setup = {server, network, editors} + + return setup + } + + function publishReceived( + batches: Array<{name: EditorName; batch: MutationBatch}>, + transactionId: string, + ) { + const {server, network} = getSetup() + const [first, second] = batches.map( + ({batch}) => network.takeSaveRequest(batch.id).batch, + ) + const transaction = second + ? server.receiveAsOne(first, second, transactionId) + : server.receive(first, transactionId) + + network.publish(transaction) + + for (const {name, batch} of batches) { + network.queueReply({ + editorId: name, + batchId: batch.id, + outcome: 'accepted', + }) + } + } + + function deliverReply( + name: EditorName, + batchNumber: number, + outcome: 'accepted' | 'rejected', + ) { + const {network} = getSetup() + const batch = getBatch(name, batchNumber) + const reply = network + .getReplies() + .find((candidate) => candidate.batchId === batch.id) + + if (reply?.outcome !== outcome) { + throw new Error( + `No "${outcome}" reply is waiting for ${name}'s batch ${batchNumber}`, + ) + } + + network.deliverReply(batch.id) + } + + function getBatch(name: EditorName, batchNumber: number): MutationBatch { + const batch = getEditor(name).heard.mutations[batchNumber - 1] + + if (!batch) { + throw new Error(`${name} has not sent batch ${batchNumber}`) + } + + return batch + } + + function getEditor(name: EditorName): WorldEditor { + return getSetup().editors[name] + } + + function transactionCarrying(batchId: string): string { + const transaction = getSetup() + .server.getTransactions() + .find((candidate) => candidate.batchIds.includes(batchId)) + + if (!transaction) { + throw new Error(`The server has not received batch "${batchId}"`) + } + + return transaction.transactionId + } + + function nameTransaction(name: NamedTransaction) { + const count = (namedTransactionCounts.get(name) ?? 0) + 1 + const transactionId = `${namedTransactionPrefixes[name]}-${count}` + namedTransactionCounts.set(name, count) + namedTransactions.set(transactionId, name) + return transactionId + } + + function describeSource(transaction: ServerTransaction): TransactionSource { + if (transaction.batchIds.length === 0) { + const name = namedTransactions.get(transaction.transactionId) + + if (!name) { + throw new Error( + `Transaction "${transaction.transactionId}" carries no batch`, + ) + } + + return {type: 'named', name} + } + + return { + type: 'batches', + batches: transaction.batchIds.map((batchId) => locateBatch(batchId)), + } + } + + function locateBatch(batchId: string): { + name: EditorName + batchNumber: number + } { + for (const name of editorNames) { + const index = getEditor(name).heard.mutations.findIndex( + (batch) => batch.id === batchId, + ) + + if (index !== -1) { + return {name, batchNumber: index + 1} + } + } + + throw new Error(`No editor has sent batch "${batchId}"`) + } + + function snapshotEditor(name: EditorName): EditorSnapshot { + const {editor, host, heard} = getEditor(name) + const ledger = editor.inspect() + const base = editor.getBase() + const describeBatch = (batch: IoEditorSentBatch): BatchSnapshot => ({ + batchNumber: locateBatch(batch.id).batchNumber, + transactionId: batch.transactionId ?? null, + patchCount: batch.patchCount, + }) + + return { + id: name === 'Editor A' ? 'A' : 'B', + status: editor.getStatus(), + screen: editor.document.toTextspec(), + base: { + textspec: base.value === undefined ? null : formatTextspec(base.value), + rev: base.rev ?? null, + }, + inFlight: ledger.inFlight ? describeBatch(ledger.inFlight) : null, + rejected: ledger.rejected ? describeBatch(ledger.rejected) : null, + echoed: ledger.echoed.map(describeBatch), + pending: ledger.pending, + held: ledger.held.map((transaction) => ({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev ?? null, + resultRev: transaction.resultRev ?? null, + })), + outOfStep: ledger.outOfStep, + readOnly: ledger.readOnly, + sentBatches: heard.mutations.map((batch, index) => ({ + number: index + 1, + transactionId: host.getTransactionId(batch.id), + patchCount: batch.patches.length, + final: batch.final === true, + })), + events: [...heard.events], + } + } + + function snapshot(): WorldSnapshot { + if (!setup) { + return {editors: null, server: null, network: null} + } + + const {server, network} = setup + const copy = server.copy() + const describeFeedItem = (transaction: ServerTransaction) => ({ + transactionId: transaction.transactionId, + previousRev: transaction.previousRev ?? null, + resultRev: transaction.resultRev ?? null, + patchCount: transaction.patches.length, + source: describeSource(transaction), + }) + + return { + editors: { + 'Editor A': snapshotEditor('Editor A'), + 'Editor B': snapshotEditor('Editor B'), + }, + server: { + value: copy.value === undefined ? null : formatTextspec(copy.value), + rev: copy.rev ?? null, + transactions: server.getLog().map(({transaction, changesField}) => ({ + id: transaction.transactionId, + previousRev: transaction.previousRev ?? null, + resultRev: transaction.resultRev ?? null, + batchIds: [...transaction.batchIds], + patchCount: transaction.patches.length, + noop: !changesField, + source: describeSource(transaction), + })), + }, + network: { + saveRequests: network.getSaveRequests().map(({editorId, batch}) => ({ + editor: toEditorName(editorId), + batchId: batch.id, + batchNumber: locateBatch(batch.id).batchNumber, + final: batch.final === true, + patchCount: batch.patches.length, + })), + replies: network.getReplies().map((reply) => ({ + editor: toEditorName(reply.editorId), + batchId: reply.batchId, + batchNumber: locateBatch(reply.batchId).batchNumber, + outcome: reply.outcome, + })), + feeds: { + 'Editor A': network.getFeed('Editor A').map(describeFeedItem), + 'Editor B': network.getFeed('Editor B').map(describeFeedItem), + }, + now: network.clock.now(), + }, + } + } + + function getSetup(): Setup { + if (!setup) { + throw new Error('No editors yet') + } + + return setup + } + + return { + getEditor, + getBatch, + getServer: () => getSetup().server, + snapshot, + + documentIs: (textspec: string) => { + const {value, caret} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + initialDocument = {value} + + for (const {editor, host} of Object.values( + startEditors({claimLoad: true}).editors, + )) { + host.load() + + if (caret) { + editor.document.setCaret(caret) + } + } + }, + serverHas: (textspec: string) => { + initialDocument = { + value: parseTextspec({keyGenerator: documentKeyGenerator}, textspec) + .value, + } + }, + serverHasCopy: (copy: ServerCopyName) => { + initialDocument = + copy === 'no document' + ? undefined + : {value: copy === 'no field' ? undefined : []} + }, + serverHasEmptyBlock: (key: string) => { + initialDocument = { + value: parseTextspec( + {keyGenerator: documentKeyGenerator}, + `B _key="${key}": |`, + ).value, + } + }, + removeServerBlockKey: () => { + const [block, ...rest] = initialDocument?.value ?? [] + + if (!block || rest.length > 0) { + throw new Error('Expected the server to have one block') + } + + const keylessBlock = {...block} + Reflect.deleteProperty(keylessBlock, '_key') + initialDocument = {value: [keylessBlock]} + }, + startEditors, + + receive: (name: EditorName, batchNumber: number) => { + const batch = getBatch(name, batchNumber) + publishReceived( + [{name, batch}], + getEditor(name).host.getTransactionId(batch.id), + ) + }, + receiveFinal: (name: EditorName) => { + const batch = getEditor(name).heard.mutations.at(-1) + + if (!batch?.final) { + throw new Error(`${name} has not sent a final batch`) + } + + publishReceived( + [{name, batch}], + getEditor(name).host.getTransactionId(batch.id), + ) + }, + receiveAsOne: ( + first: {name: EditorName; batchNumber: number}, + second: {name: EditorName; batchNumber: number}, + ) => { + const batches = [first, second].map(({name, batchNumber}) => ({ + name, + batch: getBatch(name, batchNumber), + })) + const transactionId = batches.map(({batch}) => batch.id).join('+') + + for (const {name, batch} of batches) { + getEditor(name).host.mapToTransaction(batch.id, transactionId) + } + + publishReceived(batches, transactionId) + }, + refuse: (name: EditorName, batchNumber: number) => { + const {server, network} = getSetup() + const batch = getBatch(name, batchNumber) + network.takeSaveRequest(batch.id) + server.refuse(batch.id) + network.queueReply({ + editorId: name, + batchId: batch.id, + outcome: 'rejected', + }) + }, + changeOtherField: () => { + const {server, network} = getSetup() + network.publish( + server.changeOtherField(nameTransaction('the other field')), + ) + }, + deleteDocument: () => { + const {server, network} = getSetup() + network.publish(server.deleteDocument(nameTransaction('the deletion'))) + }, + recreateDocument: (textspec: string) => { + const {server, network} = getSetup() + const {value} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + network.publish(server.recreate(value, nameTransaction('the recreation'))) + }, + deliverBatch: ( + receiverName: EditorName, + senderName: EditorName, + batchNumber: number, + ) => { + getSetup().network.deliver( + receiverName, + transactionCarrying(getBatch(senderName, batchNumber).id), + ) + }, + deliverNamed: (receiverName: EditorName, name: NamedTransaction) => { + const {network} = getSetup() + const transaction = network + .getFeed(receiverName) + .find( + (candidate) => + namedTransactions.get(candidate.transactionId) === name, + ) + + if (!transaction) { + throw new Error( + `No transaction for ${name} is waiting for ${receiverName}`, + ) + } + + network.deliver(receiverName, transaction.transactionId) + }, + advanceClock: (milliseconds: number) => { + getSetup().network.clock.advance(milliseconds) + }, + + accept: (name: EditorName, batchNumber: number) => + deliverReply(name, batchNumber, 'accepted'), + reject: (name: EditorName, batchNumber: number) => + deliverReply(name, batchNumber, 'rejected'), + resync: (name: EditorName, {discardUnsent}: {discardUnsent: boolean}) => { + const {editor, host, heard} = getEditor(name) + lastResync = { + editorName: name, + warningCount: heard.warnings.length, + screen: editor.document.toTextspec({keys: true}), + batchCount: heard.mutations.length, + } + host.resync({discardUnsent}) + }, + load: (name: EditorName) => { + getEditor(name).host.load() + }, + releaseClaim: (name: EditorName) => { + getEditor(name).editor.releaseClaim() + }, + + type: (name: EditorName, text: string) => { + getEditor(name).editor.type(text) + }, + putCaretAfter: (name: EditorName, text: string) => { + getEditor(name).editor.putCaretAfter(text) + }, + setStyle: (name: EditorName, style: string) => { + getEditor(name).editor.setStyle(style) + }, + insertBlock: (name: EditorName, textspec: string) => { + getEditor(name).editor.insertBlock(textspec) + }, + deleteBlock: (name: EditorName, text: string) => { + getEditor(name).editor.deleteBlock(text) + }, + undo: (name: EditorName) => { + getEditor(name).editor.undo() + }, + becomeReadOnly: (name: EditorName) => { + getEditor(name).editor.updateReadOnly(true) + }, + close: (name: EditorName) => { + getEditor(name).editor.close() + }, + + getLastResync: () => { + if (!lastResync) { + throw new Error('No resync yet') + } + + return lastResync + }, + } +} + +function createWorldEditor({ + name, + server, + network, + claimLoad, +}: { + name: EditorName + server: Server + network: Network + claimLoad: boolean +}): WorldEditor { + const editor = createIoEditor({ + id: name === 'Editor A' ? 'A' : 'B', + keyGenerator: createTestKeyGenerator(name === 'Editor A' ? 'a-' : 'b-'), + clock: network.clock, + claimLoad, + }) + const heard = listenTo(editor) + const host = createPassThroughHost({ + editor, + save: (batch) => network.send(name, batch), + fetchCopy: () => server.copy(), + subscription: () => network.getFeed(name), + }) + + network.connect(name, { + receiveTransaction: host.forward, + receiveReply: (reply) => + reply.outcome === 'accepted' + ? host.reportAccepted(reply.batchId) + : host.reportRejected(reply.batchId), + receiveSaveTaken: host.reportSaveTaken, + }) + editor.mount() + + return {editor, host, heard, checkedBatchCount: 0, checkedErrorCount: 0} +} + +export function listenTo(editor: IoEditor): Heard { + const heard: Heard = { + mutations: [], + changes: [], + errors: [], + warnings: [], + events: [], + } + + editor.on((event) => { + switch (event.type) { + case 'mutation': { + const {type: _type, ...batch} = event + heard.mutations.push(batch) + break + } + case 'change': { + const {type: _type, ...change} = event + heard.changes.push(change) + heard.events.push({ + type: 'change', + origin: change.origin, + patchCount: change.operations.length, + }) + break + } + case 'error': { + const {type: _type, ...error} = event + heard.errors.push(error) + heard.events.push({ + type: 'error', + reason: error.reason, + ...(error.transactionId === undefined + ? {} + : {transactionId: error.transactionId}), + }) + break + } + case 'warning': + heard.warnings.push(event.message) + heard.events.push({type: 'warning', message: event.message}) + break + case 'ready': + break + } + }) + + return heard +} + +const namedTransactionPrefixes: Record = { + 'the other field': 'other-field', + 'the deletion': 'deletion', + 'the recreation': 'recreation', +} + +function toEditorName(editorId: string): EditorName { + if (editorId === 'Editor A' || editorId === 'Editor B') { + return editorId + } + + throw new Error(`No editor "${editorId}"`) +} diff --git a/packages/io/src/test/scenarios.test.ts b/packages/io/src/test/scenarios.test.ts index e77c34d0e7..70627a6fe0 100644 --- a/packages/io/src/test/scenarios.test.ts +++ b/packages/io/src/test/scenarios.test.ts @@ -7,9 +7,9 @@ import loadingAndEmptyFeature from '../../gherkin-spec/loading-and-empty.feature import otherEditorsFeature from '../../gherkin-spec/other-editors.feature?raw' import outOfStepAndResyncFeature from '../../gherkin-spec/out-of-step-and-resync.feature?raw' import sendingAndConfirmingFeature from '../../gherkin-spec/sending-and-confirming.feature?raw' -import {parameterTypes} from './parameter-types' -import {stepDefinitions, type Context} from './steps' -import {createWorld} from './world' +import {parameterTypes} from '../scenario/parameter-types' +import {stepDefinitions, type Context} from '../scenario/steps' +import {createWorld} from '../scenario/world' const features = [ keysFeature, diff --git a/packages/io/src/test/world.ts b/packages/io/src/test/world.ts deleted file mode 100644 index b256c25015..0000000000 --- a/packages/io/src/test/world.ts +++ /dev/null @@ -1,419 +0,0 @@ -import type {PortableTextBlock} from '@portabletext/schema' -import {createTestKeyGenerator} from '@portabletext/test' -import {parseTextspec} from '../document' -import {createIoEditor, type IoEditor} from '../editor' -import {createPassThroughHost, type PassThroughHost} from '../host' -import type {ChangeEvent, ErrorEvent, MutationBatch} from '../types' -import {createNetwork, type Network} from './network' -import {createServer, type Server} from './server' - -export type EditorName = 'Editor A' | 'Editor B' - -export type ServerCopyName = 'no document' | 'no field' | 'an empty list' - -/** - * What the editor's listeners received, recorded from the moment it was - * created. - */ -export type Heard = { - mutations: Array - changes: Array - errors: Array - warnings: Array -} - -export type WorldEditor = { - editor: IoEditor - host: PassThroughHost - heard: Heard - /** The batch count at the previous `has sent` check. */ - checkedBatchCount: number - /** The error count at the previous out-of-step or in-step check. */ - checkedErrorCount: number -} - -type Setup = { - server: Server - network: Network - editors: Record -} - -type ResyncAttempt = { - editorName: EditorName - warningCount: number - screen: string - batchCount: number -} - -export type World = ReturnType - -const heldTransactionTimeout = 10_000 - -/** - * One server, one network and two editors, each with a pass-through host. - * The server's initial document is set up first, and the editors are created - * on demand, so steps can shape the document before anyone loads it. - */ -export function createWorld() { - const documentKeyGenerator = createTestKeyGenerator('d-') - let initialDocument: {value: Array | undefined} | undefined - let setup: Setup | undefined - let lastResync: ResyncAttempt | undefined - const namedTransactionIds = new Map() - - function startEditors({claimLoad}: {claimLoad: boolean}): Setup { - const server = createServer({ - documentId: 'document', - document: initialDocument, - }) - const network = createNetwork() - const editors = { - 'Editor A': createWorldEditor({ - name: 'Editor A', - server, - network, - claimLoad, - }), - 'Editor B': createWorldEditor({ - name: 'Editor B', - server, - network, - claimLoad, - }), - } - - setup = {server, network, editors} - - return setup - } - - function publishReceived( - batches: Array<{name: EditorName; batch: MutationBatch}>, - transactionId: string, - ) { - const {server, network} = getSetup() - const [first, second] = batches.map( - ({batch}) => network.takeSaveRequest(batch.id).batch, - ) - const transaction = second - ? server.receiveAsOne(first, second, transactionId) - : server.receive(first, transactionId) - - network.publish(transaction) - - for (const {name, batch} of batches) { - network.queueReply({ - editorId: name, - batchId: batch.id, - outcome: 'accepted', - }) - } - } - - function deliverReply( - name: EditorName, - batchNumber: number, - outcome: 'accepted' | 'rejected', - ) { - const {network} = getSetup() - const batch = getBatch(name, batchNumber) - const reply = network - .getReplies() - .find((candidate) => candidate.batchId === batch.id) - - if (reply?.outcome !== outcome) { - throw new Error( - `No "${outcome}" reply is waiting for ${name}'s batch ${batchNumber}`, - ) - } - - network.deliverReply(batch.id) - } - - function getBatch(name: EditorName, batchNumber: number): MutationBatch { - const batch = getEditor(name).heard.mutations[batchNumber - 1] - - if (!batch) { - throw new Error(`${name} has not sent batch ${batchNumber}`) - } - - return batch - } - - function getEditor(name: EditorName): WorldEditor { - return getSetup().editors[name] - } - - function transactionCarrying(batchId: string): string { - const transaction = getSetup() - .server.getTransactions() - .find((candidate) => candidate.batchIds.includes(batchId)) - - if (!transaction) { - throw new Error(`The server has not received batch "${batchId}"`) - } - - return transaction.transactionId - } - - function recordNamedTransaction(name: string, transactionId: string) { - namedTransactionIds.set(name, transactionId) - return transactionId - } - - function getSetup(): Setup { - if (!setup) { - throw new Error('No editors yet') - } - - return setup - } - - return { - getEditor, - getBatch, - getServer: () => getSetup().server, - - documentIs: (textspec: string) => { - const {value, caret} = parseTextspec( - {keyGenerator: documentKeyGenerator}, - textspec, - ) - initialDocument = {value} - - for (const {editor, host} of Object.values( - startEditors({claimLoad: true}).editors, - )) { - host.load() - - if (caret) { - editor.document.setCaret(caret) - } - } - }, - serverHas: (textspec: string) => { - initialDocument = { - value: parseTextspec({keyGenerator: documentKeyGenerator}, textspec) - .value, - } - }, - serverHasCopy: (copy: ServerCopyName) => { - initialDocument = - copy === 'no document' - ? undefined - : {value: copy === 'no field' ? undefined : []} - }, - serverHasEmptyBlock: (key: string) => { - initialDocument = { - value: parseTextspec( - {keyGenerator: documentKeyGenerator}, - `B _key="${key}": |`, - ).value, - } - }, - removeServerBlockKey: () => { - const [block, ...rest] = initialDocument?.value ?? [] - - if (!block || rest.length > 0) { - throw new Error('Expected the server to have one block') - } - - const keylessBlock = {...block} - Reflect.deleteProperty(keylessBlock, '_key') - initialDocument = {value: [keylessBlock]} - }, - startEditors, - - receive: (name: EditorName, batchNumber: number) => { - const batch = getBatch(name, batchNumber) - publishReceived( - [{name, batch}], - getEditor(name).host.getTransactionId(batch.id), - ) - }, - receiveFinal: (name: EditorName) => { - const batch = getEditor(name).heard.mutations.at(-1) - - if (!batch?.final) { - throw new Error(`${name} has not sent a final batch`) - } - - publishReceived( - [{name, batch}], - getEditor(name).host.getTransactionId(batch.id), - ) - }, - receiveAsOne: ( - first: {name: EditorName; batchNumber: number}, - second: {name: EditorName; batchNumber: number}, - ) => { - const batches = [first, second].map(({name, batchNumber}) => ({ - name, - batch: getBatch(name, batchNumber), - })) - const transactionId = batches.map(({batch}) => batch.id).join('+') - - for (const {name, batch} of batches) { - getEditor(name).host.mapToTransaction(batch.id, transactionId) - } - - publishReceived(batches, transactionId) - }, - refuse: (name: EditorName, batchNumber: number) => { - const {server, network} = getSetup() - const batch = getBatch(name, batchNumber) - network.takeSaveRequest(batch.id) - server.refuse(batch.id) - network.queueReply({ - editorId: name, - batchId: batch.id, - outcome: 'rejected', - }) - }, - changeOtherField: () => { - const {server, network} = getSetup() - network.publish( - server.changeOtherField( - recordNamedTransaction('the other field', 'other-field-1'), - ), - ) - }, - deleteDocument: () => { - const {server, network} = getSetup() - network.publish( - server.deleteDocument( - recordNamedTransaction('the deletion', 'deletion-1'), - ), - ) - }, - recreateDocument: (textspec: string) => { - const {server, network} = getSetup() - const {value} = parseTextspec( - {keyGenerator: documentKeyGenerator}, - textspec, - ) - network.publish( - server.recreate( - value, - recordNamedTransaction('the recreation', 'recreation-1'), - ), - ) - }, - deliverBatch: ( - receiverName: EditorName, - senderName: EditorName, - batchNumber: number, - ) => { - getSetup().network.deliver( - receiverName, - transactionCarrying(getBatch(senderName, batchNumber).id), - ) - }, - deliverNamed: ( - receiverName: EditorName, - name: 'the other field' | 'the deletion' | 'the recreation', - ) => { - const transactionId = namedTransactionIds.get(name) - - if (transactionId === undefined) { - throw new Error(`No transaction for ${name} yet`) - } - - getSetup().network.deliver(receiverName, transactionId) - }, - runOutHeldTransactionWait: () => { - getSetup().network.clock.advance(heldTransactionTimeout) - }, - - accept: (name: EditorName, batchNumber: number) => - deliverReply(name, batchNumber, 'accepted'), - reject: (name: EditorName, batchNumber: number) => - deliverReply(name, batchNumber, 'rejected'), - resync: (name: EditorName, {discardUnsent}: {discardUnsent: boolean}) => { - const {editor, host, heard} = getEditor(name) - lastResync = { - editorName: name, - warningCount: heard.warnings.length, - screen: editor.document.toTextspec({keys: true}), - batchCount: heard.mutations.length, - } - host.resync({discardUnsent}) - }, - getLastResync: () => { - if (!lastResync) { - throw new Error('No resync yet') - } - - return lastResync - }, - } -} - -function createWorldEditor({ - name, - server, - network, - claimLoad, -}: { - name: EditorName - server: Server - network: Network - claimLoad: boolean -}): WorldEditor { - const editor = createIoEditor({ - id: name === 'Editor A' ? 'A' : 'B', - keyGenerator: createTestKeyGenerator(name === 'Editor A' ? 'a-' : 'b-'), - clock: network.clock, - claimLoad, - }) - const heard = listenTo(editor) - const host = createPassThroughHost({ - editor, - save: (batch) => network.send(name, batch), - fetchCopy: () => server.copy(), - subscription: () => network.getFeed(name), - }) - - network.connect(name, { - receiveTransaction: host.forward, - receiveReply: (reply) => - reply.outcome === 'accepted' - ? host.reportAccepted(reply.batchId) - : host.reportRejected(reply.batchId), - receiveSaveTaken: host.reportSaveTaken, - }) - editor.mount() - - return {editor, host, heard, checkedBatchCount: 0, checkedErrorCount: 0} -} - -export function listenTo(editor: IoEditor): Heard { - const heard: Heard = {mutations: [], changes: [], errors: [], warnings: []} - - editor.on((event) => { - switch (event.type) { - case 'mutation': { - const {type: _type, ...batch} = event - heard.mutations.push(batch) - break - } - case 'change': { - const {type: _type, ...change} = event - heard.changes.push(change) - break - } - case 'error': { - const {type: _type, ...error} = event - heard.errors.push(error) - break - } - case 'warning': - heard.warnings.push(event.message) - break - case 'ready': - break - } - }) - - return heard -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61f6d18da5..36259721c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,40 @@ importers: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' + apps/io-playground: + dependencies: + '@portabletext/io': + specifier: workspace:^ + version: link:../../packages/io + react: + specifier: 'catalog:' + version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) + devDependencies: + '@tailwindcss/vite': + specifier: catalog:tooling + version: 4.3.3(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: catalog:tooling + version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.145.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + tailwindcss: + specifier: catalog:tooling + version: 4.3.3 + typescript: + specifier: catalog:tooling + version: 7.0.2 + vite: + specifier: catalog:tooling + version: 8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1) + apps/playground: dependencies: '@floating-ui/react-dom': @@ -731,6 +765,12 @@ importers: packages/io: dependencies: + '@cucumber/gherkin': + specifier: ^41.0.0 + version: 41.0.0 + '@cucumber/messages': + specifier: ^34.0.1 + version: 34.0.1 '@portabletext/patches': specifier: workspace:* version: link:../patches @@ -743,13 +783,13 @@ importers: '@textspec/notation': specifier: ^1.0.2 version: 1.0.2 + racejar: + specifier: workspace:^ + version: link:../racejar devDependencies: '@sanity/tsconfig': specifier: catalog:tooling version: 2.1.0 - racejar: - specifier: workspace:* - version: link:../racejar typescript: specifier: catalog:tooling version: 7.0.2 @@ -22846,7 +22886,7 @@ snapshots: dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) + vitest: 4.1.11(@types/node@20.19.25)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@27.2.0)(vite@8.3.0(@types/node@20.19.25)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) From dc7a5911be2c9b3735d9c44dc9d66ae96f004bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 12:04:39 +0200 Subject: [PATCH 11/85] feat(io-playground): play the protocol scenarios step by step in a browser A Vite app that drives `@portabletext/io` the way the test steps do, with the state on screen: Editor A and Editor B (screen, base, ledger, sent batches, heard events), the server (value, revision, transaction log) and the network (save requests, replies, one feed queue per editor with a deliver button on each item, so a race is played by choosing the order). Scenario mode lists the 32 runs compiled from the feature files and runs them one step at a time, with the current step highlighted and a failing check shown in place. Free play offers every vocabulary action as a button, runs it through the real step definitions, and appends the matching Gherkin line to a log that copies out as a new scenario. --- apps/io-playground/README.md | 7 + apps/io-playground/index.html | 12 + apps/io-playground/package.json | 28 ++ apps/io-playground/src/App.tsx | 78 +++++ apps/io-playground/src/editor-panel.tsx | 163 ++++++++++ apps/io-playground/src/features.ts | 18 ++ apps/io-playground/src/free-play-tab.tsx | 361 +++++++++++++++++++++++ apps/io-playground/src/gherkin.ts | 50 ++++ apps/io-playground/src/index.css | 5 + apps/io-playground/src/main.tsx | 10 + apps/io-playground/src/network-panel.tsx | 251 ++++++++++++++++ apps/io-playground/src/scenarios-tab.tsx | 172 +++++++++++ apps/io-playground/src/server-panel.tsx | 115 ++++++++ apps/io-playground/src/ui.tsx | 107 +++++++ apps/io-playground/src/vite-env.d.ts | 1 + apps/io-playground/tsconfig.app.json | 25 ++ apps/io-playground/tsconfig.json | 11 + apps/io-playground/tsconfig.node.json | 23 ++ apps/io-playground/vite.config.ts | 7 + package.json | 2 + 20 files changed, 1446 insertions(+) create mode 100644 apps/io-playground/README.md create mode 100644 apps/io-playground/index.html create mode 100644 apps/io-playground/package.json create mode 100644 apps/io-playground/src/App.tsx create mode 100644 apps/io-playground/src/editor-panel.tsx create mode 100644 apps/io-playground/src/features.ts create mode 100644 apps/io-playground/src/free-play-tab.tsx create mode 100644 apps/io-playground/src/gherkin.ts create mode 100644 apps/io-playground/src/index.css create mode 100644 apps/io-playground/src/main.tsx create mode 100644 apps/io-playground/src/network-panel.tsx create mode 100644 apps/io-playground/src/scenarios-tab.tsx create mode 100644 apps/io-playground/src/server-panel.tsx create mode 100644 apps/io-playground/src/ui.tsx create mode 100644 apps/io-playground/src/vite-env.d.ts create mode 100644 apps/io-playground/tsconfig.app.json create mode 100644 apps/io-playground/tsconfig.json create mode 100644 apps/io-playground/tsconfig.node.json create mode 100644 apps/io-playground/vite.config.ts diff --git a/apps/io-playground/README.md b/apps/io-playground/README.md new file mode 100644 index 0000000000..1bfb2f7c80 --- /dev/null +++ b/apps/io-playground/README.md @@ -0,0 +1,7 @@ +# I/O protocol playground + +An interactive view of `@portabletext/io`: two editors, one server and the network between them. The Scenarios tab steps through the Gherkin scenarios one step at a time, and the Free play tab drives the same world by hand while it writes down the steps taken. + +```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..d3b1bb085c --- /dev/null +++ b/apps/io-playground/package.json @@ -0,0 +1,28 @@ +{ + "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" + }, + "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" + } +} diff --git a/apps/io-playground/src/App.tsx b/apps/io-playground/src/App.tsx new file mode 100644 index 0000000000..de495e15ef --- /dev/null +++ b/apps/io-playground/src/App.tsx @@ -0,0 +1,78 @@ +import {useState, type ReactNode} from 'react' +import {EditorPanel} from './editor-panel' +import {FreePlayTab, useFreePlay} from './free-play-tab' +import {NetworkPanel} from './network-panel' +import {ScenariosTab, useScenarioRunner} from './scenarios-tab' +import {ServerPanel} from './server-panel' + +type Tab = 'scenarios' | 'free play' + +export function App() { + const [tab, setTab] = useState('scenarios') + const scenarioRunner = useScenarioRunner() + const freePlay = useFreePlay() + const world = tab === 'scenarios' ? scenarioRunner.world : freePlay.world + const snapshot = world.snapshot() + const onStep = + tab === 'free play' + ? (text: string) => freePlay.perform('When', text) + : undefined + + return ( +
+
+ + + + + +
+ +
+ + + +
+ +
+ +
+ {tab === 'scenarios' ? ( + + ) : ( + + )} +
+
+
+ ) +} + +function Column({children}: {children: ReactNode}) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/io-playground/src/editor-panel.tsx b/apps/io-playground/src/editor-panel.tsx new file mode 100644 index 0000000000..8056db3a27 --- /dev/null +++ b/apps/io-playground/src/editor-panel.tsx @@ -0,0 +1,163 @@ +import type { + BatchSnapshot, + EditorName, + EditorSnapshot, + HeardEvent, +} from '@portabletext/io' +import {Badge, Empty, ItemList, Notation, plural, Section} from './ui' + +export function EditorPanel({ + name, + editor, +}: { + name: EditorName + editor: EditorSnapshot | undefined +}) { + return ( +
+
+

{name}

+ {editor ? ( + <> + + {editor.status} + + {editor.readOnly ? read-only : null} + {editor.outOfStep ? out of step : null} + + ) : null} +
+ + {editor ? ( + + ) : ( + No editors yet + )} +
+ ) +} + +function EditorDetails({editor}: {editor: EditorSnapshot}) { + const pendingPatchCount = editor.pending.reduce( + (count, change) => count + change.patchCount, + 0, + ) + + return ( + <> +
+ {editor.screen} +
+ +
+
+ {editor.base.textspec === null ? ( + no field + ) : ( + {editor.base.textspec} + )} + + @ {editor.base.rev ?? 'no document'} + +
+
+ +
+ +
  • + in flight:{' '} + {editor.inFlight ? describeBatch(editor.inFlight) : 'nothing'} +
  • + {editor.echoed.map((batch) => ( +
  • + came back, waiting: {describeBatch(batch)} +
  • + ))} +
  • + pending:{' '} + {editor.pending.length === 0 + ? 'nothing' + : `${plural(editor.pending.length, 'change')} (${plural(pendingPatchCount, 'patch')})`} +
  • +
  • + rejected:{' '} + {editor.rejected ? describeBatch(editor.rejected) : 'nothing'} +
  • +
  • + held: {editor.held.length === 0 ? 'nothing' : null} + {editor.held.map((transaction) => ( +
    + {transaction.transactionId}: {transaction.previousRev ?? '∅'} →{' '} + {transaction.resultRev ?? '∅'} +
    + ))} +
  • +
    +
    + +
    + {editor.sentBatches.length === 0 ? ( + none + ) : ( + + {editor.sentBatches.map((batch) => ( +
  • + batch {batch.number} → {batch.transactionId} ·{' '} + {plural(batch.patchCount, 'patch')} + {batch.final ? ' · final' : null} +
  • + ))} +
    + )} +
    + +
    + {editor.events.length === 0 ? ( + none + ) : ( + + {editor.events.map((event, index) => ( +
  • + {describeEvent(event)} +
  • + ))} +
    + )} +
    + + ) +} + +function describeBatch(batch: BatchSnapshot): string { + return `batch ${batch.batchNumber} → ${batch.transactionId ?? '?'} (${plural(batch.patchCount, 'patch')})` +} + +function describeEvent(event: HeardEvent): string { + switch (event.type) { + case 'change': + return `change · ${event.origin} · ${plural(event.patchCount, 'patch')}` + case 'error': + return `error · ${event.reason}${event.transactionId === undefined ? '' : ` · ${event.transactionId}`}` + case 'warning': + return `warning · ${event.message}` + } +} + +function eventTone(event: HeardEvent): string { + switch (event.type) { + case 'change': + return 'text-gray-700' + case 'error': + return 'text-red-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..a101464a08 --- /dev/null +++ b/apps/io-playground/src/features.ts @@ -0,0 +1,18 @@ +import {compileScenarios} from '@portabletext/io' +import keysFeature from '@portabletext/io/gherkin-spec/keys.feature?raw' +import lifecycleFeature from '@portabletext/io/gherkin-spec/lifecycle.feature?raw' +import listenersFeature from '@portabletext/io/gherkin-spec/listeners.feature?raw' +import loadingAndEmptyFeature from '@portabletext/io/gherkin-spec/loading-and-empty.feature?raw' +import otherEditorsFeature from '@portabletext/io/gherkin-spec/other-editors.feature?raw' +import outOfStepAndResyncFeature from '@portabletext/io/gherkin-spec/out-of-step-and-resync.feature?raw' +import sendingAndConfirmingFeature from '@portabletext/io/gherkin-spec/sending-and-confirming.feature?raw' + +export const features = [ + sendingAndConfirmingFeature, + otherEditorsFeature, + outOfStepAndResyncFeature, + keysFeature, + loadingAndEmptyFeature, + listenersFeature, + lifecycleFeature, +].map((featureText) => compileScenarios(featureText)) 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..0f9363766a --- /dev/null +++ b/apps/io-playground/src/free-play-tab.tsx @@ -0,0 +1,361 @@ +import { + createWorld, + editorNames, + type EditorName, + type ServerCopyName, + type World, +} from '@portabletext/io' +import {useState} from 'react' +import { + formatScenario, + formatSteps, + inEditor, + quoted, + runStep, + type LoggedStep, + type StepKeyword, +} from './gherkin' +import {Button, Section, TextInput} from './ui' + +type Setup = { + mode: + | 'the document is' + | 'editors claim the first load' + | "editors don't claim" + textspec: string + serverCopy: 'textspec' | ServerCopyName +} + +type FreePlay = { + world: World + log: Array + error: string | null +} + +const setupModes: Array = [ + 'the document is', + 'editors claim the first load', + "editors don't claim", +] + +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: 'the document is', + textspec: 'B: foo|', + serverCopy: 'textspec', + }) + const [freePlay, setFreePlay] = useState(() => startFreePlay(setup)) + + function perform(keyword: StepKeyword, text: string) { + try { + runStep(freePlay.world, {keyword, text}) + setFreePlay((current) => ({ + ...current, + log: [...current.log, {keyword, text}], + error: null, + })) + } catch (error) { + setFreePlay((current) => ({ + ...current, + error: `${keyword} ${text}: ${error instanceof Error ? error.message : String(error)}`, + })) + } + } + + 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 + + if (server) { + if (server.value === null) { + checks.push( + server.rev === null + ? 'the server has no document' + : 'the server has no field', + ) + } else if (server.value !== '') { + checks.push(`the server has ${quoted(server.value)}`) + } + } + + for (const check of checks) { + perform('Then', check) + } + } + + return { + world: freePlay.world, + log: freePlay.log, + error: freePlay.error, + setup, + setSetup, + reset: () => setFreePlay(startFreePlay(setup)), + 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) + + return ( +
    + {editorNames.map((name) => ( + freePlay.perform('When', text)} + /> + ))} + +
    +
    +
    + + {setup.mode === 'the document is' ? null : ( + + )} + {setup.mode === 'the document is' || + setup.serverCopy === 'textspec' ? ( + setSetup({...setup, textspec})} + width="w-40" + /> + ) : null} + +
    +
    + +
    +
    + + + +
    + {freePlay.error ? ( +

    {freePlay.error}

    + ) : null} +
    +            {formatSteps(freePlay.log).join('\n')}
    +          
    +
    +
    +
    + ) +} + +function EditorControls({ + name, + onStep, +}: { + name: EditorName + onStep: (text: string) => 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 suffix = inEditor(name) + + return ( +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + + + {name === 'Editor A' ? ( + + ) : null} +
    +
    +
    + ) +} + +function startFreePlay(setup: Setup): FreePlay { + const world = createWorld() + const log: Array = [] + + try { + for (const text of setupSteps(setup)) { + runStep(world, {keyword: 'Given', text}) + log.push({keyword: 'Given', text}) + } + + return {world, log, error: null} + } catch (error) { + return { + world, + log, + error: `Setup: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +function setupSteps(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}`, + setup.mode === 'editors claim the first load' + ? 'an editor that claims the first load' + : "an editor that doesn't claim the first load", + ] +} diff --git a/apps/io-playground/src/gherkin.ts b/apps/io-playground/src/gherkin.ts new file mode 100644 index 0000000000..260fc95698 --- /dev/null +++ b/apps/io-playground/src/gherkin.ts @@ -0,0 +1,50 @@ +import {compileScenarios, type EditorName, type World} from '@portabletext/io' + +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}"` +} 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/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/network-panel.tsx b/apps/io-playground/src/network-panel.tsx new file mode 100644 index 0000000000..778edef1db --- /dev/null +++ b/apps/io-playground/src/network-panel.tsx @@ -0,0 +1,251 @@ +import { + editorNames, + type EditorName, + type NetworkSnapshot, + type TransactionSource, +} from '@portabletext/io' +import {useState} from 'react' +import {describeSource} from './server-panel' +import {Badge, Button, Empty, ItemList, plural, Section} from './ui' + +type FeedItem = NetworkSnapshot['feeds'][EditorName][number] + +export function NetworkPanel({ + network, + onStep, +}: { + network: NetworkSnapshot | null + /** Present in free play: runs a `When` step. */ + onStep: ((text: string) => void) | undefined +}) { + const [selectedBatchIds, setSelectedBatchIds] = useState>([]) + + if (!network) { + return ( +
    +

    Network

    + No network yet +
    + ) + } + + const waitingBatchIds = network.saveRequests.map((request) => request.batchId) + const selectedRequests = network.saveRequests.filter((request) => + selectedBatchIds.includes(request.batchId), + ) + + function toggleSelected(batchId: string) { + setSelectedBatchIds((current) => + current.includes(batchId) + ? current.filter((candidate) => candidate !== batchId) + : [ + ...current.filter((candidate) => + waitingBatchIds.includes(candidate), + ), + batchId, + ], + ) + } + + return ( +
    +
    +

    Network

    + + t = {network.now / 1000} s + + {onStep ? ( + + ) : null} +
    + +
    + {network.saveRequests.length === 0 ? ( + none waiting + ) : ( + + {network.saveRequests.map((request) => ( +
  • + {onStep ? ( + toggleSelected(request.batchId)} + /> + ) : null} + + {request.editor}'s batch {request.batchNumber} ·{' '} + {plural(request.patchCount, 'patch')} + {request.final ? ' · final' : null} + + {onStep ? ( + <> + + + + ) : null} +
  • + ))} +
    + )} + {onStep && network.saveRequests.length > 1 ? ( +
    + +
    + ) : null} +
    + +
    + {network.replies.length === 0 ? ( + none waiting + ) : ( + + {network.replies.map((reply) => ( +
  • + + {reply.editor}'s batch {reply.batchNumber}{' '} + + {reply.outcome} + + + {onStep ? ( + + ) : null} +
  • + ))} +
    + )} +
    + + {editorNames.map((receiver) => ( +
    + {network.feeds[receiver].length === 0 ? ( + none waiting + ) : ( + + {network.feeds[receiver].map((item, index, feed) => { + const blockedBy = earlierNamedItem(feed, index) + + return ( +
  • + + {item.transactionId}: {item.previousRev ?? '∅'} →{' '} + {item.resultRev ?? '∅'}{' '} + + · {describeSource(item.source)} + + + {onStep ? ( + + ) : null} +
  • + ) + })} +
    + )} +
    + ))} +
    + ) +} + +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 batch = + source.batches.find((candidate) => candidate.name === receiver) ?? + source.batches[0] + + return batch.name === receiver + ? `${receiver}'s batch ${batch.batchNumber} comes back` + : `${receiver} receives ${batch.name}'s batch ${batch.batchNumber}` +} + +/** + * 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 +} diff --git a/apps/io-playground/src/scenarios-tab.tsx b/apps/io-playground/src/scenarios-tab.tsx new file mode 100644 index 0000000000..e250d86929 --- /dev/null +++ b/apps/io-playground/src/scenarios-tab.tsx @@ -0,0 +1,172 @@ +import {createWorld, type World} from '@portabletext/io' +import {useState} from 'react' +import {features} from './features' +import {Button} from './ui' + +type StepResult = {status: 'passed'} | {status: 'failed'; message: string} + +type ScenarioRun = { + featureIndex: number + scenarioIndex: number + world: World + results: Array + running: boolean +} + +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 publish = (running: boolean) => + setRun((current) => + current.world === world + ? {...current, results: [...results], running} + : current, + ) + + for (const step of scenario.steps.slice(results.length)) { + try { + await step.run(world) + results.push({status: 'passed'}) + } catch (error) { + results.push({ + status: 'failed', + message: error instanceof Error ? error.message : String(error), + }) + break + } + + if (!all) { + break + } + + publish(true) + await new Promise((resolve) => requestAnimationFrame(resolve)) + } + + publish(false) + } + + return { + world: run.world, + featureIndex: run.featureIndex, + scenarioIndex: run.scenarioIndex, + scenario, + results: run.results, + running: run.running, + finished, + select: (selection: {featureIndex: number; scenarioIndex: number}) => + setRun(freshRun(selection)), + reset: () => + setRun((current) => + freshRun({ + featureIndex: current.featureIndex, + scenarioIndex: current.scenarioIndex, + }), + ), + nextStep: () => runSteps({all: false}), + runAll: () => runSteps({all: true}), + } +} + +export function ScenariosTab({ + runner, +}: { + runner: ReturnType +}) { + const {scenario, results, running, finished} = runner + + return ( +
    +
    + + + + + {finished ? ( + results.at(-1)?.status === 'failed' ? ( + failed + ) : ( + passed + ) + ) : null} +
    + +
      + {scenario.steps.map((step, index) => { + const result = results[index] + const current = index === results.length && !finished + + return ( +
    1. + {step.keyword} {step.text} + {result?.status === 'failed' ? ( +
      {result.message}
      + ) : null} +
    2. + ) + })} +
    +
    + ) +} + +function freshRun(selection: { + featureIndex: number + scenarioIndex: number +}): ScenarioRun { + return {...selection, world: createWorld(), results: [], running: false} +} diff --git a/apps/io-playground/src/server-panel.tsx b/apps/io-playground/src/server-panel.tsx new file mode 100644 index 0000000000..a74f0c5a24 --- /dev/null +++ b/apps/io-playground/src/server-panel.tsx @@ -0,0 +1,115 @@ +import type {ServerSnapshot, TransactionSource} from '@portabletext/io' +import {useState} from 'react' +import {quoted} from './gherkin' +import { + Badge, + Button, + Empty, + ItemList, + Notation, + plural, + Section, + TextInput, +} from './ui' + +export function ServerPanel({ + server, + onStep, +}: { + server: ServerSnapshot | null + /** Present in free play: runs a `When` step. */ + onStep: ((text: string) => void) | undefined +}) { + const [recreatedAs, setRecreatedAs] = useState('B: bar') + + return ( +
    +
    +

    Server

    + {server ? ( + + @ {server.rev ?? 'no document'} + + ) : null} +
    + + {server ? ( + <> +
    + {server.value === null ? ( + {server.rev === null ? 'no document' : 'no field'} + ) : ( + + {server.value === '' ? 'an empty list' : server.value} + + )} +
    + +
    + {server.transactions.length === 0 ? ( + none + ) : ( + + {server.transactions.map((transaction, index) => ( +
  • + {transaction.id} + + {transaction.previousRev ?? '∅'} →{' '} + {transaction.resultRev ?? '∅'} + + + · {describeSource(transaction.source)} ·{' '} + {plural(transaction.patchCount, 'patch')} + + {transaction.noop ? ( + no change + ) : null} +
  • + ))} +
    + )} +
    + + {onStep ? ( +
    +
    + + + + +
    +
    + ) : null} + + ) : ( + No server yet + )} +
    + ) +} + +export function describeSource(source: TransactionSource): string { + return source.type === 'named' + ? source.name + : source.batches + .map((batch) => `${batch.name}'s batch ${batch.batchNumber}`) + .join(' + ') +} diff --git a/apps/io-playground/src/ui.tsx b/apps/io-playground/src/ui.tsx new file mode 100644 index 0000000000..6e263e6683 --- /dev/null +++ b/apps/io-playground/src/ui.tsx @@ -0,0 +1,107 @@ +import type {ReactNode} from 'react' + +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, + children, +}: { + title: string + children: ReactNode +}) { + return ( +
    +

    + {title} +

    + {children} +
    + ) +} + +export function Notation({children}: {children: ReactNode}) { + return ( + + {children} + + ) +} + +export function Empty({children}: {children: ReactNode}) { + return

    {children}

    +} + +export function ItemList({children}: {children: ReactNode}) { + return
      {children}
    +} + +export function Button({ + onClick, + disabled, + title, + children, +}: { + onClick: () => void + disabled?: boolean + title?: string + children: ReactNode +}) { + return ( + + ) +} + +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}`} + /> + ) +} + +export function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} 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/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...", From a7a9cdac7467741b7c19f8b6b35b4b7acfa913cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 12:17:44 +0200 Subject: [PATCH 12/85] feat(io-playground): show waiting feed items per editor and deliver them all at once When a scenario ends, the other editor's feed queue often still holds transactions, and the editor looks frozen. Each editor's header now shows an amber "N waiting" badge while its feed queue is non-empty, and each feed section has a "deliver all" button that delivers the queue in order through the same path as a single deliver, so free play logs one Gherkin line per transaction. In scenario mode the buttons appear once the scenario has finished, and delivering then leaves the recorded step results untouched. --- apps/io-playground/src/App.tsx | 14 +++++++++- apps/io-playground/src/editor-panel.tsx | 6 +++++ apps/io-playground/src/free-play-tab.tsx | 4 ++- apps/io-playground/src/network-panel.tsx | 26 +++++++++++++++++-- apps/io-playground/src/scenarios-tab.tsx | 33 +++++++++++++++++++++++- 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/io-playground/src/App.tsx b/apps/io-playground/src/App.tsx index de495e15ef..6fd9329a4c 100644 --- a/apps/io-playground/src/App.tsx +++ b/apps/io-playground/src/App.tsx @@ -17,6 +17,12 @@ export function App() { 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 return (
    @@ -25,17 +31,23 @@ export function App() {
    - +
    diff --git a/apps/io-playground/src/editor-panel.tsx b/apps/io-playground/src/editor-panel.tsx index 8056db3a27..e3802fb742 100644 --- a/apps/io-playground/src/editor-panel.tsx +++ b/apps/io-playground/src/editor-panel.tsx @@ -9,9 +9,12 @@ import {Badge, Empty, ItemList, Notation, plural, Section} from './ui' export function EditorPanel({ name, editor, + waitingCount, }: { name: EditorName editor: EditorSnapshot | undefined + /** Transactions waiting in this editor's feed. */ + waitingCount: number }) { return (
    @@ -30,6 +33,9 @@ export function EditorPanel({ > {editor.status} + {waitingCount > 0 ? ( + {waitingCount} waiting + ) : null} {editor.readOnly ? read-only : null} {editor.outOfStep ? out of step : null} diff --git a/apps/io-playground/src/free-play-tab.tsx b/apps/io-playground/src/free-play-tab.tsx index 0f9363766a..ab592dd72f 100644 --- a/apps/io-playground/src/free-play-tab.tsx +++ b/apps/io-playground/src/free-play-tab.tsx @@ -55,7 +55,7 @@ export function useFreePlay() { }) const [freePlay, setFreePlay] = useState(() => startFreePlay(setup)) - function perform(keyword: StepKeyword, text: string) { + function perform(keyword: StepKeyword, text: string): boolean { try { runStep(freePlay.world, {keyword, text}) setFreePlay((current) => ({ @@ -63,11 +63,13 @@ export function useFreePlay() { log: [...current.log, {keyword, text}], error: null, })) + return true } catch (error) { setFreePlay((current) => ({ ...current, error: `${keyword} ${text}: ${error instanceof Error ? error.message : String(error)}`, })) + return false } } diff --git a/apps/io-playground/src/network-panel.tsx b/apps/io-playground/src/network-panel.tsx index 778edef1db..3ff4c70e72 100644 --- a/apps/io-playground/src/network-panel.tsx +++ b/apps/io-playground/src/network-panel.tsx @@ -13,10 +13,16 @@ type FeedItem = NetworkSnapshot['feeds'][EditorName][number] export function NetworkPanel({ network, onStep, + onDeliver, }: { network: NetworkSnapshot | null /** Present in free play: runs a `When` step. */ onStep: ((text: string) => void) | 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 }) { const [selectedBatchIds, setSelectedBatchIds] = useState>([]) @@ -164,6 +170,22 @@ export function NetworkPanel({ {editorNames.map((receiver) => (
    + {onDeliver ? ( +
    + +
    + ) : null} {network.feeds[receiver].length === 0 ? ( none waiting ) : ( @@ -183,7 +205,7 @@ export function NetworkPanel({ · {describeSource(item.source)} - {onStep ? ( + {onDeliver ? (
    + {runner.deliveryError ? ( +

    {runner.deliveryError}

    + ) : null} +
      {scenario.steps.map((step, index) => { const result = results[index] @@ -168,5 +193,11 @@ function freshRun(selection: { featureIndex: number scenarioIndex: number }): ScenarioRun { - return {...selection, world: createWorld(), results: [], running: false} + return { + ...selection, + world: createWorld(), + results: [], + running: false, + deliveryError: null, + } } From e1834d0ffd48b2136b60ea4fdd5ebdf36b49bed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 12:43:53 +0200 Subject: [PATCH 13/85] refactor(io): carry blocks and patches in the world snapshot The snapshot now carries the Portable Text blocks behind every textspec (each editor's screen and base, the server's value) and the patches of every batch and transaction (in flight, pending, rejected, echoed, held, sent, the server's log, save requests and feed items), so a viewer can open any of them without reaching into the model. `inspect()` on the editor exposes the pending changes' patches for the same reason. All additive, pinned by the full-value snapshot tests. --- packages/io/src/editor.test.ts | 15 +++- packages/io/src/editor.ts | 7 +- packages/io/src/scenario/world.test.ts | 107 +++++++++++++++++++++++-- packages/io/src/scenario/world.ts | 31 ++++++- 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/packages/io/src/editor.test.ts b/packages/io/src/editor.test.ts index 70968a6576..eb04d35692 100644 --- a/packages/io/src/editor.test.ts +++ b/packages/io/src/editor.test.ts @@ -728,6 +728,7 @@ describe(createIoEditor.name, () => { test('`inspect` reports the batch in flight, the rejected one, pending changes and held transactions', () => { const {editor, clock} = createLoadedEditor('B: foo|') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] editor.type('x') editor.type('y') @@ -743,7 +744,12 @@ describe(createIoEditor.name, () => { inFlight: {id: 'A-1', transactionId: 'A-1', patchCount: 1}, rejected: undefined, echoed: [], - pending: [{patchCount: 1}], + pending: [ + { + patchCount: 1, + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], held: [ { transactionId: 't2', @@ -764,7 +770,12 @@ describe(createIoEditor.name, () => { inFlight: undefined, rejected: {id: 'A-1', transactionId: 'A-1', patchCount: 1}, echoed: [], - pending: [{patchCount: 1}], + pending: [ + { + patchCount: 1, + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], held: [], outOfStep: true, readOnly: true, diff --git a/packages/io/src/editor.ts b/packages/io/src/editor.ts index 7febdb47bf..f7ed9c2947 100644 --- a/packages/io/src/editor.ts +++ b/packages/io/src/editor.ts @@ -61,7 +61,7 @@ export type IoEditorLedger = { inFlight: IoEditorSentBatch | undefined rejected: IoEditorSentBatch | undefined echoed: Array - pending: Array<{patchCount: number}> + pending: Array<{patchCount: number; patches: Array}> held: Array< Pick & { arrivedAt: number @@ -780,7 +780,10 @@ export function createIoEditor(options: { inFlight: inFlight ? describeSentBatch(inFlight) : undefined, rejected: rejected ? describeSentBatch(rejected) : undefined, echoed: echoedAwaitingBase.map(describeSentBatch), - pending: pending.map((patches) => ({patchCount: patches.length})), + pending: pending.map((patches) => ({ + patchCount: patches.length, + patches, + })), held: held.map(({transaction, arrivedAt}) => ({ transactionId: transaction.transactionId, previousRev: transaction.previousRev, diff --git a/packages/io/src/scenario/world.test.ts b/packages/io/src/scenario/world.test.ts index 51427938aa..a27c9853c6 100644 --- a/packages/io/src/scenario/world.test.ts +++ b/packages/io/src/scenario/world.test.ts @@ -1,3 +1,4 @@ +import {diffMatchPatch, set} from '@portabletext/patches' import {describe, expect, test} from 'vitest' import {createWorld} from './world' @@ -25,22 +26,64 @@ describe(createWorld.name, () => { world.deliverNamed('Editor B', 'the other field') world.type('Editor A', 'y') + const textPath = [{_key: 'd-k0'}, 'children', {_key: 'd-k1'}, 'text'] + const stylePath = [{_key: 'd-k0'}, 'style'] + expect(world.snapshot()).toEqual({ editors: { 'Editor A': { id: 'A', status: 'ready', screen: 'B: fooxy|', - base: {textspec: 'B: foo', rev: 'r1'}, - inFlight: {batchNumber: 1, transactionId: 'A-1', patchCount: 1}, + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'fooxy', marks: []}, + ], + style: 'normal', + }, + ], + base: { + textspec: 'B: foo', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + rev: 'r1', + }, + inFlight: { + batchNumber: 1, + transactionId: 'A-1', + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + }, rejected: null, echoed: [], - pending: [{patchCount: 1}], + pending: [ + { + patchCount: 1, + patches: [diffMatchPatch('foox', 'fooxy', textPath)], + }, + ], held: [], outOfStep: false, readOnly: false, sentBatches: [ - {number: 1, transactionId: 'A-1', patchCount: 1, final: false}, + { + number: 1, + transactionId: 'A-1', + patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], + final: false, + }, ], events: [ {type: 'change', origin: 'local', patchCount: 1}, @@ -51,8 +94,34 @@ describe(createWorld.name, () => { id: 'B', status: 'ready', screen: 'H1: foo|', - base: {textspec: 'B: foo', rev: 'r1'}, - inFlight: {batchNumber: 1, transactionId: 'B-1', patchCount: 1}, + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foo', marks: []}], + style: 'h1', + }, + ], + base: { + textspec: 'B: foo', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [ + {_key: 'd-k1', _type: 'span', text: 'foo', marks: []}, + ], + style: 'normal', + }, + ], + rev: 'r1', + }, + inFlight: { + batchNumber: 1, + transactionId: 'B-1', + patchCount: 1, + patches: [set('h1', stylePath)], + }, rejected: null, echoed: [], pending: [], @@ -61,18 +130,33 @@ describe(createWorld.name, () => { transactionId: 'other-field-1', previousRev: 'r2', resultRev: 'r3', + patches: [], }, ], outOfStep: false, readOnly: false, sentBatches: [ - {number: 1, transactionId: 'B-1', patchCount: 1, final: false}, + { + number: 1, + transactionId: 'B-1', + patchCount: 1, + patches: [set('h1', stylePath)], + final: false, + }, ], events: [{type: 'change', origin: 'local', patchCount: 1}], }, }, server: { value: 'B: foox', + blocks: [ + { + _type: 'block', + _key: 'd-k0', + children: [{_key: 'd-k1', _type: 'span', text: 'foox', marks: []}], + style: 'normal', + }, + ], rev: 'r3', transactions: [ { @@ -81,6 +165,7 @@ describe(createWorld.name, () => { resultRev: 'r2', batchIds: ['A-1'], patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], noop: false, source: { type: 'batches', @@ -93,6 +178,7 @@ describe(createWorld.name, () => { resultRev: 'r3', batchIds: [], patchCount: 0, + patches: [], noop: true, source: {type: 'named', name: 'the other field'}, }, @@ -106,6 +192,7 @@ describe(createWorld.name, () => { batchNumber: 1, final: false, patchCount: 1, + patches: [set('h1', stylePath)], }, ], replies: [ @@ -122,7 +209,9 @@ describe(createWorld.name, () => { transactionId: 'A-1', previousRev: 'r1', resultRev: 'r2', + batchIds: ['A-1'], patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], source: { type: 'batches', batches: [{name: 'Editor A', batchNumber: 1}], @@ -132,7 +221,9 @@ describe(createWorld.name, () => { transactionId: 'other-field-1', previousRev: 'r2', resultRev: 'r3', + batchIds: [], patchCount: 0, + patches: [], source: {type: 'named', name: 'the other field'}, }, ], @@ -141,7 +232,9 @@ describe(createWorld.name, () => { transactionId: 'A-1', previousRev: 'r1', resultRev: 'r2', + batchIds: ['A-1'], patchCount: 1, + patches: [diffMatchPatch('foo', 'foox', textPath)], source: { type: 'batches', batches: [{name: 'Editor A', batchNumber: 1}], diff --git a/packages/io/src/scenario/world.ts b/packages/io/src/scenario/world.ts index b731bcc737..70391a1725 100644 --- a/packages/io/src/scenario/world.ts +++ b/packages/io/src/scenario/world.ts @@ -1,3 +1,4 @@ +import type {Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {formatTextspec, parseTextspec} from '../document' @@ -55,6 +56,7 @@ export type BatchSnapshot = { batchNumber: number transactionId: string | null patchCount: number + patches: Array } export type EditorSnapshot = { @@ -62,16 +64,23 @@ export type EditorSnapshot = { status: IoEditorStatus /** What the editor shows, with the caret. */ screen: string - base: {textspec: string | null; rev: string | null} + /** What the editor shows, as blocks, the placeholder included. */ + blocks: Array + base: { + textspec: string | null + blocks: Array | null + rev: string | null + } inFlight: BatchSnapshot | null rejected: BatchSnapshot | null /** Batches that came back and wait behind a held transaction. */ echoed: Array - pending: Array<{patchCount: number}> + pending: Array<{patchCount: number; patches: Array}> held: Array<{ transactionId: string previousRev: string | null resultRev: string | null + patches: Array }> outOfStep: boolean readOnly: boolean @@ -79,6 +88,7 @@ export type EditorSnapshot = { number: number transactionId: string patchCount: number + patches: Array final: boolean }> events: Array @@ -87,6 +97,8 @@ export type EditorSnapshot = { export type ServerSnapshot = { /** The field as textspec, `null` when there is no field. */ value: string | null + /** The field as blocks, `null` when there is no field. */ + blocks: Array | null rev: string | null transactions: Array<{ id: string @@ -94,6 +106,7 @@ export type ServerSnapshot = { resultRev: string | null batchIds: Array patchCount: number + patches: Array noop: boolean source: TransactionSource }> @@ -106,6 +119,7 @@ export type NetworkSnapshot = { batchNumber: number final: boolean patchCount: number + patches: Array }> replies: Array<{ editor: EditorName @@ -119,7 +133,9 @@ export type NetworkSnapshot = { transactionId: string previousRev: string | null resultRev: string | null + batchIds: Array patchCount: number + patches: Array source: TransactionSource }> > @@ -319,20 +335,24 @@ export function createWorld() { function snapshotEditor(name: EditorName): EditorSnapshot { const {editor, host, heard} = getEditor(name) + const {server} = getSetup() const ledger = editor.inspect() const base = editor.getBase() const describeBatch = (batch: IoEditorSentBatch): BatchSnapshot => ({ batchNumber: locateBatch(batch.id).batchNumber, transactionId: batch.transactionId ?? null, patchCount: batch.patchCount, + patches: getBatch(name, locateBatch(batch.id).batchNumber).patches, }) return { id: name === 'Editor A' ? 'A' : 'B', status: editor.getStatus(), screen: editor.document.toTextspec(), + blocks: editor.document.getValue(), base: { textspec: base.value === undefined ? null : formatTextspec(base.value), + blocks: base.value ?? null, rev: base.rev ?? null, }, inFlight: ledger.inFlight ? describeBatch(ledger.inFlight) : null, @@ -343,6 +363,7 @@ export function createWorld() { transactionId: transaction.transactionId, previousRev: transaction.previousRev ?? null, resultRev: transaction.resultRev ?? null, + patches: server.getTransaction(transaction.transactionId).patches, })), outOfStep: ledger.outOfStep, readOnly: ledger.readOnly, @@ -350,6 +371,7 @@ export function createWorld() { number: index + 1, transactionId: host.getTransactionId(batch.id), patchCount: batch.patches.length, + patches: batch.patches, final: batch.final === true, })), events: [...heard.events], @@ -367,7 +389,9 @@ export function createWorld() { transactionId: transaction.transactionId, previousRev: transaction.previousRev ?? null, resultRev: transaction.resultRev ?? null, + batchIds: [...transaction.batchIds], patchCount: transaction.patches.length, + patches: transaction.patches, source: describeSource(transaction), }) @@ -378,6 +402,7 @@ export function createWorld() { }, server: { value: copy.value === undefined ? null : formatTextspec(copy.value), + blocks: copy.value ?? null, rev: copy.rev ?? null, transactions: server.getLog().map(({transaction, changesField}) => ({ id: transaction.transactionId, @@ -385,6 +410,7 @@ export function createWorld() { resultRev: transaction.resultRev ?? null, batchIds: [...transaction.batchIds], patchCount: transaction.patches.length, + patches: transaction.patches, noop: !changesField, source: describeSource(transaction), })), @@ -396,6 +422,7 @@ export function createWorld() { batchNumber: locateBatch(batch.id).batchNumber, final: batch.final === true, patchCount: batch.patches.length, + patches: batch.patches, })), replies: network.getReplies().map((reply) => ({ editor: toEditorName(reply.editorId), From 708fbb6b0d261dad5833c0a2ad6fcc16e1d3fdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 12:43:54 +0200 Subject: [PATCH 14/85] feat(io-playground): show the message path, open every value and patch, and narrate each step Five columns: Editor A, its link, the server, Editor B's link, Editor B. Each link has two lanes drawn as cards: save requests on their way to the server, and save replies and feed transactions on their way back, each with its own deliver button and the editor's own marked "yours". Every textspec toggles to its Portable Text blocks, and every batch and transaction opens a drawer with its patches in `@portabletext/patches` shape, the revisions and the batches it carries. Every label carries a one-sentence explanation behind an "i" mark, with a Concepts drawer listing all of them and the notation rules. After each step the app diffs the world before and after and narrates what happened ("Editor A kept the change (typed "y") as pending, because batch 1 is still in flight"), and the panels that changed flash. "Replies" became "save replies", revisions explain themselves on hover, and the base is labelled as the server's copy at its revision. --- apps/io-playground/README.md | 6 +- apps/io-playground/src/App.tsx | 163 ++++-- apps/io-playground/src/concepts.ts | 138 ++++++ apps/io-playground/src/drawers.tsx | 307 ++++++++++++ apps/io-playground/src/editor-panel.tsx | 235 ++++++--- apps/io-playground/src/free-play-tab.tsx | 35 +- apps/io-playground/src/link-panel.tsx | 368 ++++++++++++++ apps/io-playground/src/narration-log.tsx | 47 ++ apps/io-playground/src/narration.ts | 603 +++++++++++++++++++++++ apps/io-playground/src/network-panel.tsx | 273 ---------- apps/io-playground/src/scenarios-tab.tsx | 61 ++- apps/io-playground/src/server-panel.tsx | 110 +++-- apps/io-playground/src/ui.tsx | 208 +++++++- 13 files changed, 2132 insertions(+), 422 deletions(-) create mode 100644 apps/io-playground/src/concepts.ts create mode 100644 apps/io-playground/src/drawers.tsx create mode 100644 apps/io-playground/src/link-panel.tsx create mode 100644 apps/io-playground/src/narration-log.tsx create mode 100644 apps/io-playground/src/narration.ts delete mode 100644 apps/io-playground/src/network-panel.tsx diff --git a/apps/io-playground/README.md b/apps/io-playground/README.md index 1bfb2f7c80..7ef561c207 100644 --- a/apps/io-playground/README.md +++ b/apps/io-playground/README.md @@ -1,6 +1,10 @@ # I/O protocol playground -An interactive view of `@portabletext/io`: two editors, one server and the network between them. The Scenarios tab steps through the Gherkin scenarios one step at a time, and the Free play tab drives the same world by hand while it writes down the steps taken. +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, save replies and feed transactions sit as cards in the links until you deliver them, so a race is played by choosing the order. + +Every value opens to its Portable Text blocks, every batch and transaction 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. ```sh pnpm --filter io-playground dev diff --git a/apps/io-playground/src/App.tsx b/apps/io-playground/src/App.tsx index 6fd9329a4c..b7d7c9ff9e 100644 --- a/apps/io-playground/src/App.tsx +++ b/apps/io-playground/src/App.tsx @@ -1,14 +1,18 @@ -import {useState, type ReactNode} from 'react' +import {useState} from 'react' +import {DrawerView, OpenDetailsProvider, type Drawer} from './drawers' import {EditorPanel} from './editor-panel' import {FreePlayTab, useFreePlay} from './free-play-tab' -import {NetworkPanel} from './network-panel' +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 [drawer, setDrawer] = useState(null) + const [selectedBatchIds, setSelectedBatchIds] = useState>([]) const scenarioRunner = useScenarioRunner() const freePlay = useFreePlay() const world = tab === 'scenarios' ? scenarioRunner.world : freePlay.world @@ -23,68 +27,133 @@ export function App() { : scenarioRunner.finished && !scenarioRunner.running ? scenarioRunner.deliver : undefined + const saveRequests = snapshot.network?.saveRequests ?? [] + const selectedRequests = saveRequests.filter((request) => + selectedBatchIds.includes(request.batchId), + ) + + function toggleSelected(batchId: string) { + setSelectedBatchIds((current) => + current.includes(batchId) + ? current.filter((candidate) => candidate !== batchId) + : [ + ...current.filter((candidate) => + saveRequests.some((request) => request.batchId === candidate), + ), + batchId, + ], + ) + } + + function receiveSelected() { + const [first, second] = selectedRequests + + if (!first || !second || !onStep) { + return + } + + setSelectedBatchIds([]) + onStep( + `the server receives ${first.editor}'s batch ${first.batchNumber} and ${second.editor}'s batch ${second.batchNumber} as one transaction`, + ) + } return ( -
      -
      - + setDrawer({type: 'details', selection})} + > +
      +
      +

      I/O protocol playground

      +

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

      + +
      + +
      - - - -
      - + + -
      - - -
      +
      -
      - -
      - {tab === 'scenarios' ? ( - - ) : ( - - )} -
      -
      -
      - ) -} +
      + +
      + {tab === 'scenarios' ? ( + + ) : ( + + )} +
      +
      +
    -function Column({children}: {children: ReactNode}) { - return ( -
    - {children} -
    + {drawer ? ( + setDrawer(null)} + /> + ) : null} + ) } diff --git a/apps/io-playground/src/concepts.ts b/apps/io-playground/src/concepts.ts new file mode 100644 index 0000000000..81fe7643df --- /dev/null +++ b/apps/io-playground/src/concepts.ts @@ -0,0 +1,138 @@ +export const concepts = [ + { + name: 'change', + definition: 'Something a user does, like typing or setting a style.', + }, + { + name: 'batch', + definition: + 'A group of changes an editor sends off to be saved, numbered per editor.', + }, + { + name: 'save request', + definition: + "A batch on its way from an editor's host to the server, waiting for the server to receive it.", + }, + { + name: 'save reply', + definition: + "The server's answer to a save request, accepted or rejected, and only a rejection changes what the editor does.", + }, + { + name: 'transaction', + definition: + 'One entry on the feed: what the server saved in one go, carrying one or more batches 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 batch the editor has sent and waits to see come back on the feed.', + }, + { + name: 'pending', + definition: + 'Changes made while a batch is in flight, waiting to go out together as the next batch.', + }, + { + name: 'confirmed', + definition: + "The editor's batch came back on the feed, so the editor knows the batch 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 batch that came back inside a held transaction, confirmed once that transaction applies.", + }, + { + name: 'rejected', + definition: + 'The server refused the batch 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 stopped applying the feed until a resync.', + }, + { + 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.', + }, + { + 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 apply the feed, which puts it out of step.", + }, + { + name: 'warning', + definition: + "An event the editor emits when something looks wrong but it can carry on, like a batch that hasn't come back in time.", + }, +] as const + +export type ConceptName = (typeof concepts)[number]['name'] + +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.', + }, +] diff --git a/apps/io-playground/src/drawers.tsx b/apps/io-playground/src/drawers.tsx new file mode 100644 index 0000000000..c067011a84 --- /dev/null +++ b/apps/io-playground/src/drawers.tsx @@ -0,0 +1,307 @@ +import { + editorNames, + type EditorName, + type WorldSnapshot, +} from '@portabletext/io' +import {createContext, useContext, type ReactNode} from 'react' +import {concepts, 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: 'batch'; editor: EditorName; batchNumber: number} + | {type: 'pending'; editor: EditorName; index: number} + | {type: 'transaction'; transactionId: string} + +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}
    +
    {concept.definition}
    +
    + ))} +
    +
    +

    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 'batch': + return + case 'pending': + return + case 'transaction': + return + } +} + +function BatchDetails({ + selection, + snapshot, +}: { + selection: Extract + snapshot: WorldSnapshot +}) { + const editor = snapshot.editors?.[selection.editor] + const batch = editor?.sentBatches[selection.batchNumber - 1] + + if (!editor || !batch || !snapshot.network || !snapshot.server) { + return This batch isn't in the current world. + } + + const savedAs = snapshot.server.transactions.find( + (transaction) => + transaction.source.type === 'batches' && + transaction.source.batches.some( + (candidate) => + candidate.name === selection.editor && + candidate.batchNumber === selection.batchNumber, + ), + ) + const whereItIs = snapshot.network.saveRequests.some( + (request) => + request.editor === selection.editor && + request.batchNumber === selection.batchNumber, + ) + ? 'a save request, waiting for the server to receive it' + : editor.inFlight?.batchNumber === batch.number + ? 'in flight: saved, waiting to come back on the feed' + : editor.rejected?.batchNumber === batch.number + ? 'rejected' + : editor.echoed.some((echoed) => echoed.batchNumber === batch.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 batch {batch.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 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} +

    + , + ], + ['batchIds', JSON.stringify(transaction.batchIds)], + ['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 Fields({fields}: {fields: Array<[string, ReactNode]>}) { + 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 index e3802fb742..4128398baa 100644 --- a/apps/io-playground/src/editor-panel.tsx +++ b/apps/io-playground/src/editor-panel.tsx @@ -4,7 +4,23 @@ import type { EditorSnapshot, HeardEvent, } from '@portabletext/io' -import {Badge, Empty, ItemList, Notation, plural, Section} from './ui' +import type {ReactNode} from 'react' +import type {ConceptName} from './concepts' +import {useOpenDetails} from './drawers' +import {describePatches} from './narration' +import { + Badge, + DetailsLink, + Empty, + ItemList, + Label, + plural, + Revision, + RevisionStep, + Section, + TextspecValue, + useFlash, +} from './ui' export function EditorPanel({ name, @@ -16,8 +32,13 @@ export function EditorPanel({ /** Transactions waiting in this editor's feed. */ waitingCount: number }) { + const flash = useFlash(JSON.stringify(editor ?? null)) + return ( -
    +

    {name}

    {editor ? ( @@ -43,95 +64,176 @@ export function EditorPanel({
    {editor ? ( - + ) : ( No editors yet )} -
    + ) } -function EditorDetails({editor}: {editor: EditorSnapshot}) { - const pendingPatchCount = editor.pending.reduce( - (count, change) => count + change.patchCount, - 0, +function EditorDetails({ + name, + editor, +}: { + name: EditorName + editor: EditorSnapshot +}) { + const openDetails = useOpenDetails() + const batchLink = (batch: BatchSnapshot) => ( + + openDetails({ + type: 'batch', + editor: name, + batchNumber: batch.batchNumber, + }) + } + > + batch {batch.batchNumber} → {batch.transactionId ?? '?'} ·{' '} + {plural(batch.patchCount, 'patch')} + ) return ( <> -
    - {editor.screen} +
    +
    -
    -
    - {editor.base.textspec === null ? ( - no field +
    · the server's copy, no document yet ) : ( - {editor.base.textspec} - )} - - @ {editor.base.rev ?? 'no document'} - -
    + <> + · the server's copy at {' '} + + + ) + } + > + {editor.base.textspec === null ? ( + no field + ) : ( + + )}
    -
    +
    -
  • - in flight:{' '} - {editor.inFlight ? describeBatch(editor.inFlight) : 'nothing'} -
  • - {editor.echoed.map((batch) => ( -
  • - came back, waiting: {describeBatch(batch)} -
  • - ))} -
  • - pending:{' '} - {editor.pending.length === 0 - ? 'nothing' - : `${plural(editor.pending.length, 'change')} (${plural(pendingPatchCount, 'patch')})`} -
  • -
  • - rejected:{' '} - {editor.rejected ? describeBatch(editor.rejected) : 'nothing'} -
  • -
  • - held: {editor.held.length === 0 ? 'nothing' : null} - {editor.held.map((transaction) => ( -
    - {transaction.transactionId}: {transaction.previousRev ?? '∅'} →{' '} - {transaction.resultRev ?? '∅'} -
    - ))} -
  • + + {editor.inFlight ? batchLink(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((batch) => ( + {batchLink(batch)} + ))} + + + ) : null} + + {editor.rejected ? batchLink(editor.rejected) : 'nothing'} + + + {editor.outOfStep ? 'yes' : 'no'} + + + {editor.readOnly ? 'yes' : 'no'} +
    -
    +
    {editor.sentBatches.length === 0 ? ( none ) : ( {editor.sentBatches.map((batch) => (
  • - batch {batch.number} → {batch.transactionId} ·{' '} - {plural(batch.patchCount, 'patch')} - {batch.final ? ' · final' : null} + + openDetails({ + type: 'batch', + editor: name, + batchNumber: batch.number, + }) + } + > + batch {batch.number} → {batch.transactionId} ·{' '} + {plural(batch.patchCount, 'patch')} + {batch.final ? ' · final' : null} +
  • ))}
    )}
    -
    +
    {editor.events.length === 0 ? ( none ) : ( {editor.events.map((event, index) => (
  • + {' '} {describeEvent(event)}
  • ))} @@ -142,18 +244,33 @@ function EditorDetails({editor}: {editor: EditorSnapshot}) { ) } -function describeBatch(batch: BatchSnapshot): string { - return `batch ${batch.batchNumber} → ${batch.transactionId ?? '?'} (${plural(batch.patchCount, 'patch')})` +function LedgerRow({ + concept, + label, + children, +}: { + concept: ConceptName + label: string + children: ReactNode +}) { + return ( +
  • + + + + {children} +
  • + ) } function describeEvent(event: HeardEvent): string { switch (event.type) { case 'change': - return `change · ${event.origin} · ${plural(event.patchCount, 'patch')}` + return `· ${event.origin} · ${plural(event.patchCount, 'patch')}` case 'error': - return `error · ${event.reason}${event.transactionId === undefined ? '' : ` · ${event.transactionId}`}` + return `· ${event.reason}${event.transactionId === undefined ? '' : ` · ${event.transactionId}`}` case 'warning': - return `warning · ${event.message}` + return `· ${event.message}` } } diff --git a/apps/io-playground/src/free-play-tab.tsx b/apps/io-playground/src/free-play-tab.tsx index ab592dd72f..58e4841041 100644 --- a/apps/io-playground/src/free-play-tab.tsx +++ b/apps/io-playground/src/free-play-tab.tsx @@ -15,6 +15,8 @@ import { type LoggedStep, type StepKeyword, } from './gherkin' +import {narrateStep, type NarrationEntry} from './narration' +import {NarrationLog} from './narration-log' import {Button, Section, TextInput} from './ui' type Setup = { @@ -29,6 +31,7 @@ type Setup = { type FreePlay = { world: World log: Array + narration: Array error: string | null } @@ -56,17 +59,31 @@ export function useFreePlay() { 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(freePlay.world, {keyword, text}) + runStep(world, {keyword, text}) + const entries = narrateNow() setFreePlay((current) => ({ ...current, log: [...current.log, {keyword, text}], + narration: [...current.narration, ...entries], error: null, })) return true } catch (error) { + const entries = narrateNow() setFreePlay((current) => ({ ...current, + narration: [...current.narration, ...entries], error: `${keyword} ${text}: ${error instanceof Error ? error.message : String(error)}`, })) return false @@ -107,6 +124,7 @@ export function useFreePlay() { return { world: freePlay.world, log: freePlay.log, + narration: freePlay.narration, error: freePlay.error, setup, setSetup, @@ -216,6 +234,8 @@ export function FreePlayTab({ {formatSteps(freePlay.log).join('\n')}
    + + ) @@ -330,18 +350,29 @@ function EditorControls({ 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, error: null} + return {world, log, narration, error: null} } catch (error) { return { world, log, + narration, error: `Setup: ${error instanceof Error ? error.message : String(error)}`, } } diff --git a/apps/io-playground/src/link-panel.tsx b/apps/io-playground/src/link-panel.tsx new file mode 100644 index 0000000000..a33730989f --- /dev/null +++ b/apps/io-playground/src/link-panel.tsx @@ -0,0 +1,368 @@ +import type { + EditorName, + EditorSnapshot, + NetworkSnapshot, + TransactionSource, +} from '@portabletext/io' +import type {ReactNode} from 'react' +import {useOpenDetails} from './drawers' +import {describeSource, isOwnFeedItem} from './narration' +import { + Badge, + Button, + DetailsLink, + Empty, + Label, + plural, + RevisionStep, + useFlash, +} from './ui' + +type FeedItem = NetworkSnapshot['feeds'][EditorName][number] + +/** + * The link between one editor and the server: save requests travel up + * toward the server, save replies 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, + onStep, + onDeliver, + selectedBatchIds, + onToggleSelected, +}: { + name: EditorName + /** Where the editor sits relative to this link. */ + editorSide: 'left' | 'right' + editor: EditorSnapshot | undefined + network: NetworkSnapshot | null + /** Present in free play: runs a `When` step. */ + onStep: ((text: string) => void) | 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. */ + selectedBatchIds: Array + onToggleSelected: (batchId: string) => void +}) { + const requests = + network?.saveRequests.filter((request) => request.editor === name) ?? [] + const replies = + network?.replies.filter((reply) => reply.editor === name) ?? [] + const feed = network?.feeds[name] ?? [] + const flash = useFlash(JSON.stringify({requests, replies, feed})) + const openDetails = useOpenDetails() + 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 ? ( + <> + + {' '} + + + } + > + {requests.length === 0 ? ( + none waiting + ) : ( +
    + {requests.map((request) => ( + +
    + {onStep && network.saveRequests.length > 1 ? ( + onToggleSelected(request.batchId)} + className="mt-0.5" + /> + ) : null} + + openDetails({ + type: 'batch', + editor: name, + batchNumber: request.batchNumber, + }) + } + > + batch {request.batchNumber} →{' '} + {editor?.sentBatches[request.batchNumber - 1] + ?.transactionId ?? request.batchId} + +
    + + {plural(request.patchCount, 'patch')} + {request.final ? ' · final' : null} + + {onStep ? ( +
    + + +
    + ) : null} +
    + ))} +
    + )} +
    + + + {editorSide === 'left' ? ( + + ) : null} + and the{' '} + + {editorSide === 'right' ? ( + + ) : null} + + } + > + {replies.length === 0 ? null : ( +
    + {replies.map((reply) => ( + + + reply:{' '} + + {reply.outcome} + + + + batch {reply.batchNumber} + + {onDeliver ? ( +
    + +
    + ) : null} +
    + ))} +
    + )} + + {feed.length === 0 ? ( + no transactions waiting + ) : ( +
    + {feed.map((item, index) => { + const blockedBy = earlierNamedItem(feed, index) + const yours = isOwnFeedItem(item, name) + + return ( + + + + openDetails({ + type: 'transaction', + transactionId: item.transactionId, + }) + } + > + {item.transactionId} + + {yours ? yours : null} + + + + {describeSource(item.source)} + + {onDeliver ? ( +
    + +
    + ) : null} +
    + ) + })} +
    + )} + + {onDeliver ? ( +
    + +
    + ) : 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 batch = + source.batches.find((candidate) => candidate.name === receiver) ?? + source.batches[0] + + return batch.name === receiver + ? `${receiver}'s batch ${batch.batchNumber} comes back` + : `${receiver} receives ${batch.name}'s batch ${batch.batchNumber}` +} + +/** + * 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 +} diff --git a/apps/io-playground/src/narration-log.tsx b/apps/io-playground/src/narration-log.tsx new file mode 100644 index 0000000000..a4ac59b98a --- /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: 'nearest'}) + }, [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..0331c3a639 --- /dev/null +++ b/apps/io-playground/src/narration.ts @@ -0,0 +1,603 @@ +import { + editorNames, + type BatchSnapshot, + type EditorName, + type EditorSnapshot, + type NetworkSnapshot, + type ServerSnapshot, + type TransactionSource, + type WorldSnapshot, +} from '@portabletext/io' +import {plural} from './ui' + +type Patch = BatchSnapshot['patches'][number] + +type FeedItem = NetworkSnapshot['feeds'][EditorName][number] + +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, + }), + ) + } + + 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, +}: { + name: EditorName + before: EditorSnapshot + after: EditorSnapshot + beforeNetwork: NetworkSnapshot + afterNetwork: NetworkSnapshot +}): Array { + const sentences: Array = [] + const newBatches = after.sentBatches.slice(before.sentBatches.length) + const explainedBatchNumbers = new Set() + const newEvents = after.events.slice(before.events.length) + const newErrors = newEvents.flatMap((event) => + event.type === 'error' ? [event] : [], + ) + const deliveredItems = beforeNetwork.feeds[name].filter( + (item) => + !afterNetwork.feeds[name].some( + (candidate) => candidate.transactionId === item.transactionId, + ), + ) + 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( + before.base.rev === after.base.rev && + before.base.textspec === after.base.textspec + ? `${name} is ready without a load.` + : `${name} loaded the server's copy at ${describeRev(after.base.rev)} and is ready, showing \`${after.screen}\`.`, + ) + } else if (after.status === 'unmounted') { + sentences.push(`${name} closed and unmounted.`) + } else { + sentences.push(`${name} is now ${after.status}.`) + } + } + + for (const reply of beforeNetwork.replies) { + if ( + reply.editor !== name || + afterNetwork.replies.some( + (candidate) => candidate.batchId === reply.batchId, + ) + ) { + continue + } + + if (reply.outcome === 'accepted') { + sentences.push( + `${name} got the save reply for batch ${reply.batchNumber}: accepted, which on its own changes nothing.`, + ) + } else if ( + after.rejected?.batchNumber === reply.batchNumber && + before.rejected?.batchNumber !== reply.batchNumber + ) { + sentences.push( + `${name} got the save reply for batch ${reply.batchNumber}: rejected, so ${name} sends nothing more until a resync.`, + ) + } else { + sentences.push( + `${name} got the save reply for batch ${reply.batchNumber}: rejected.`, + ) + } + } + + for (const item of deliveredItems) { + screenExplained = true + const ownBatchNumbers = ownBatches(item.source, name) + const failed = newErrors.some( + (error) => error.transactionId === item.transactionId, + ) + + if (before.outOfStep) { + sentences.push( + ownBatchNumbers.length > 0 + ? `${item.transactionId} came back to ${name} while out of step: it notes that ${describeBatchNumbers(ownBatchNumbers)} 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)})${ + ownBatchNumbers.length > 0 + ? `, so ${describeBatchNumbers(ownBatchNumbers)} waits as a held echo` + : '' + }.`, + ) + continue + } + + if (failed) { + continue + } + + if (ownBatchNumbers.length > 0) { + const confirmed = ownBatchNumbers.filter( + (batchNumber) => + after.inFlight?.batchNumber !== batchNumber && + !after.echoed.some((batch) => batch.batchNumber === batchNumber), + ) + const followUp = newBatches.at(0) + const parts = [ + confirmed.length > 0 + ? `${describeBatchNumbers(confirmed)} confirmed` + : `${describeBatchNumbers(ownBatchNumbers)} not confirmed yet`, + ] + + if (followUp) { + explainedBatchNumbers.add(followUp.number) + parts.push( + `batch ${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( + `${item.transactionId} came back to ${name}: ${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, + ), + ) + const resynced = + deliveredItems.length === 0 && + before.status === 'ready' && + after.status === 'ready' && + (before.base.rev !== after.base.rev || + before.base.textspec !== after.base.textspec || + (before.outOfStep && !after.outOfStep) || + (before.rejected !== null && after.rejected === null) || + (released.length > 0 && !after.outOfStep)) + + if (deliveredItems.length > 0 && !after.outOfStep && released.length > 0) { + const confirmedEchoes = before.echoed + .filter( + (batch) => + !after.echoed.some( + (candidate) => candidate.batchNumber === batch.batchNumber, + ), + ) + .map((batch) => batch.batchNumber) + + sentences.push( + `${name} applied the held ${joinPhrases(released.map((held) => held.transactionId))} now that it connects${ + confirmedEchoes.length > 0 + ? `: ${describeBatchNumbers(confirmedEchoes)} confirmed` + : '' + }.`, + ) + } + + if (resynced) { + screenExplained = true + const followUp = newBatches.at(0) + const parts = [ + `${name} took a fresh copy at ${describeRev(after.base.rev)}`, + ] + + if (followUp) { + explainedBatchNumbers.add(followUp.number) + parts.push( + `re-applied ${plural(before.pending.length, 'unsent change')}, which went out as batch ${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 batch ${before.rejected.batchNumber}`) + } + + if (before.outOfStep && !after.outOfStep) { + 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 + ? `batch ${after.inFlight.batchNumber} is still in flight` + : after.rejected + ? `batch ${after.rejected.batchNumber} was rejected` + : `it isn't ready` + }.`, + ) + } + + for (const batch of newBatches) { + if (explainedBatchNumbers.has(batch.number)) { + continue + } + + sentences.push( + `${name} sent batch ${batch.number} as transaction ${batch.transactionId}${ + batch.final ? ', its final batch' : '' + }: ${describePatches(batch.patches)}.`, + ) + } + + if (!before.outOfStep && after.outOfStep) { + 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 === '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 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' : '' + }.`, + ) + } + + for (const request of beforeNetwork.saveRequests) { + const refused = afterNetwork.replies.some( + (reply) => + reply.batchId === request.batchId && + reply.outcome === 'rejected' && + !beforeNetwork.replies.some( + (candidate) => candidate.batchId === request.batchId, + ), + ) + + if (refused) { + sentences.push( + `The server refused ${request.editor}'s batch ${request.batchNumber}, and a rejected save 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 ownBatches( + source: TransactionSource, + name: EditorName, +): Array { + return source.type === 'batches' + ? source.batches + .filter((batch) => batch.name === name) + .map((batch) => batch.batchNumber) + : [] +} + +function describeBatchNumbers(batchNumbers: Array): string { + return batchNumbers.length === 1 + ? `batch ${batchNumbers[0]}` + : `batches ${joinPhrases(batchNumbers.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.batches + .map((batch) => `${batch.name}'s batch ${batch.batchNumber}`) + .join(' + ') +} + +export function isOwnFeedItem(item: FeedItem, name: EditorName): boolean { + return ownBatches(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}"` +} + +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/network-panel.tsx b/apps/io-playground/src/network-panel.tsx deleted file mode 100644 index 3ff4c70e72..0000000000 --- a/apps/io-playground/src/network-panel.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { - editorNames, - type EditorName, - type NetworkSnapshot, - type TransactionSource, -} from '@portabletext/io' -import {useState} from 'react' -import {describeSource} from './server-panel' -import {Badge, Button, Empty, ItemList, plural, Section} from './ui' - -type FeedItem = NetworkSnapshot['feeds'][EditorName][number] - -export function NetworkPanel({ - network, - onStep, - onDeliver, -}: { - network: NetworkSnapshot | null - /** Present in free play: runs a `When` step. */ - onStep: ((text: string) => void) | 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 -}) { - const [selectedBatchIds, setSelectedBatchIds] = useState>([]) - - if (!network) { - return ( -
    -

    Network

    - No network yet -
    - ) - } - - const waitingBatchIds = network.saveRequests.map((request) => request.batchId) - const selectedRequests = network.saveRequests.filter((request) => - selectedBatchIds.includes(request.batchId), - ) - - function toggleSelected(batchId: string) { - setSelectedBatchIds((current) => - current.includes(batchId) - ? current.filter((candidate) => candidate !== batchId) - : [ - ...current.filter((candidate) => - waitingBatchIds.includes(candidate), - ), - batchId, - ], - ) - } - - return ( -
    -
    -

    Network

    - - t = {network.now / 1000} s - - {onStep ? ( - - ) : null} -
    - -
    - {network.saveRequests.length === 0 ? ( - none waiting - ) : ( - - {network.saveRequests.map((request) => ( -
  • - {onStep ? ( - toggleSelected(request.batchId)} - /> - ) : null} - - {request.editor}'s batch {request.batchNumber} ·{' '} - {plural(request.patchCount, 'patch')} - {request.final ? ' · final' : null} - - {onStep ? ( - <> - - - - ) : null} -
  • - ))} -
    - )} - {onStep && network.saveRequests.length > 1 ? ( -
    - -
    - ) : null} -
    - -
    - {network.replies.length === 0 ? ( - none waiting - ) : ( - - {network.replies.map((reply) => ( -
  • - - {reply.editor}'s batch {reply.batchNumber}{' '} - - {reply.outcome} - - - {onStep ? ( - - ) : null} -
  • - ))} -
    - )} -
    - - {editorNames.map((receiver) => ( -
    - {onDeliver ? ( -
    - -
    - ) : null} - {network.feeds[receiver].length === 0 ? ( - none waiting - ) : ( - - {network.feeds[receiver].map((item, index, feed) => { - const blockedBy = earlierNamedItem(feed, index) - - return ( -
  • - - {item.transactionId}: {item.previousRev ?? '∅'} →{' '} - {item.resultRev ?? '∅'}{' '} - - · {describeSource(item.source)} - - - {onDeliver ? ( - - ) : null} -
  • - ) - })} -
    - )} -
    - ))} -
    - ) -} - -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 batch = - source.batches.find((candidate) => candidate.name === receiver) ?? - source.batches[0] - - return batch.name === receiver - ? `${receiver}'s batch ${batch.batchNumber} comes back` - : `${receiver} receives ${batch.name}'s batch ${batch.batchNumber}` -} - -/** - * 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 -} diff --git a/apps/io-playground/src/scenarios-tab.tsx b/apps/io-playground/src/scenarios-tab.tsx index e3ce589b47..6e2f5d027d 100644 --- a/apps/io-playground/src/scenarios-tab.tsx +++ b/apps/io-playground/src/scenarios-tab.tsx @@ -2,6 +2,8 @@ import {createWorld, type World} from '@portabletext/io' import {useState} 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} @@ -11,6 +13,7 @@ type ScenarioRun = { scenarioIndex: number world: World results: Array + narration: Array running: boolean deliveryError: string | null } @@ -32,22 +35,42 @@ export function useScenarioRunner() { 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], running} + ? { + ...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 } @@ -64,6 +87,7 @@ export function useScenarioRunner() { function deliver(text: string): boolean { const {world} = run + const before = world.snapshot() let deliveryError: string | null = null try { @@ -72,8 +96,21 @@ export function useScenarioRunner() { 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} : current, + current.world === world + ? { + ...current, + deliveryError, + narration: [...current.narration, ...entries], + } + : current, ) return deliveryError === null @@ -85,6 +122,7 @@ export function useScenarioRunner() { scenarioIndex: run.scenarioIndex, scenario, results: run.results, + narration: run.narration, running: run.running, finished, deliveryError: run.deliveryError, @@ -185,6 +223,8 @@ export function ScenariosTab({ ) })} + + ) } @@ -197,7 +237,24 @@ function freshRun(selection: { ...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 index a74f0c5a24..ab93c5ac74 100644 --- a/apps/io-playground/src/server-panel.tsx +++ b/apps/io-playground/src/server-panel.tsx @@ -1,62 +1,96 @@ -import type {ServerSnapshot, TransactionSource} from '@portabletext/io' +import type {NetworkSnapshot, ServerSnapshot} from '@portabletext/io' import {useState} from 'react' +import {useOpenDetails} from './drawers' import {quoted} from './gherkin' +import {describeSource} from './narration' import { Badge, Button, + DetailsLink, Empty, ItemList, - Notation, + Label, plural, + Revision, + RevisionStep, Section, TextInput, + TextspecValue, + useFlash, } from './ui' +type SaveRequest = NetworkSnapshot['saveRequests'][number] + export function ServerPanel({ server, + now, onStep, + selectedRequests, + onReceiveSelected, }: { server: ServerSnapshot | null + /** The virtual clock, in milliseconds. */ + now: number | undefined /** Present in free play: runs a `When` step. */ onStep: ((text: string) => void) | undefined + /** Save requests picked to be received as one transaction. */ + selectedRequests: Array + onReceiveSelected: () => void }) { const [recreatedAs, setRecreatedAs] = useState('B: bar') + const flash = useFlash(JSON.stringify(server)) + const openDetails = useOpenDetails() return ( -
    -
    +
    +

    Server

    {server ? ( - - @ {server.rev ?? 'no document'} + + {' '} + ) : null}
    {server ? ( <> -
    +
    {server.value === null ? ( {server.rev === null ? 'no document' : 'no field'} ) : ( - - {server.value === '' ? 'an empty list' : server.value} - + )}
    -
    +
    {server.transactions.length === 0 ? ( none ) : ( - {server.transactions.map((transaction, index) => ( -
  • - {transaction.id} - - {transaction.previousRev ?? '∅'} →{' '} - {transaction.resultRev ?? '∅'} - + {server.transactions.map((transaction) => ( +
  • + + openDetails({ + type: 'transaction', + transactionId: transaction.id, + }) + } + > + {transaction.id} + + · {describeSource(transaction.source)} ·{' '} {plural(transaction.patchCount, 'patch')} @@ -71,7 +105,7 @@ export function ServerPanel({
  • {onStep ? ( -
    +
    +
    + + + t = {(now ?? 0) / 1000} s + +
    +
    + +
    ) : null} ) : ( No server yet )} -
    +
    ) } - -export function describeSource(source: TransactionSource): string { - return source.type === 'named' - ? source.name - : source.batches - .map((batch) => `${batch.name}'s batch ${batch.batchNumber}`) - .join(' + ') -} diff --git a/apps/io-playground/src/ui.tsx b/apps/io-playground/src/ui.tsx index 6e263e6683..90dd9c71b8 100644 --- a/apps/io-playground/src/ui.tsx +++ b/apps/io-playground/src/ui.tsx @@ -1,4 +1,5 @@ -import type {ReactNode} from 'react' +import {useEffect, useRef, useState, type ReactNode} from 'react' +import {definitionOf, revisionTitle, type ConceptName} from './concepts' const badgeTones = { gray: 'bg-gray-200 text-gray-700', @@ -26,26 +27,151 @@ export function Badge({ export function Section({ title, + concept, + suffix, children, }: { title: string + concept?: ConceptName + /** Shown after the title and its info mark. */ + suffix?: ReactNode children: ReactNode }) { return ( -
    -

    +
    +

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

    {children}
    ) } -export function Notation({children}: {children: ReactNode}) { +/** + * A small "i" after a label: the definition as a native tooltip, and as a + * popover on click. + */ +export function InfoMark({concept}: {concept: ConceptName}) { + const [position, setPosition] = useState<{ + left: number + top: number + } | null>(null) + const definition = definitionOf(concept) + + return ( + <> + + {position ? ( + + {concept}: {definition} + + ) : null} + + ) +} + +export function Label({ + concept, + children, +}: { + concept: ConceptName + children: ReactNode +}) { return ( - + {children} - + + + ) +} + +export function Revision({rev}: {rev: string | null}) { + return ( + + {rev ?? '∅'} + + ) +} + +export function RevisionStep({ + from, + to, +}: { + from: string | null + to: string | null +}) { + return ( + + → + + ) +} + +/** + * A textspec value that toggles the Portable Text blocks behind it. + */ +export function TextspecValue({ + textspec, + blocks, +}: { + textspec: string + blocks: unknown +}) { + const [open, setOpen] = useState(false) + + return ( +
    + + {open ? : null} +
    + ) +} + +export function JsonView({value}: {value: unknown}) { + return ( +
    +      {JSON.stringify(value, null, 2)}
    +    
    ) } @@ -57,6 +183,30 @@ export function ItemList({children}: {children: ReactNode}) { return
      {children}
    } +/** A text button that opens the details drawer. */ +export function DetailsLink({ + label, + onClick, + children, +}: { + /** The accessible name, like "Details of transaction A-1". */ + label: string + onClick: () => void + children: ReactNode +}) { + return ( + + ) +} + export function Button({ onClick, disabled, @@ -102,6 +252,50 @@ export function TextInput({ ) } +/** + * 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 { - return `${count} ${noun}${count === 1 ? '' : 's'}` + 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 + ), + )} + + ) } From 9e9a03c89f458c98afbed35f9695353ced3098c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 12:47:51 +0200 Subject: [PATCH 15/85] fix(io-playground): keep the scenario controls in view while the steps scroll The select and the next step, run all and reset buttons stick to the top of the scrolling pane, and the current step scrolls into view below them as the scenario advances. --- apps/io-playground/src/scenarios-tab.tsx | 35 ++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/io-playground/src/scenarios-tab.tsx b/apps/io-playground/src/scenarios-tab.tsx index 6e2f5d027d..65d6fbbe33 100644 --- a/apps/io-playground/src/scenarios-tab.tsx +++ b/apps/io-playground/src/scenarios-tab.tsx @@ -1,5 +1,5 @@ import {createWorld, type World} from '@portabletext/io' -import {useState} from 'react' +import {useEffect, useRef, useState, type ReactNode} from 'react' import {features} from './features' import {runStep} from './gherkin' import {narrateStep, type NarrationEntry} from './narration' @@ -150,7 +150,7 @@ export function ScenariosTab({ return (
    -
    +
    {runner.deliveryError}

    ) : null} -
      - {scenario.steps.map((step, index) => { - const result = results[index] - const current = index === results.length && !finished +
      +
        + {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} -
        - ) - })} -
      + return ( + + {step.keyword}{' '} + {step.text} + {result?.status === 'failed' ? ( +
      + {result.message} +
      + ) : null} +
      + ) + })} +
    - +
    + +
    +
    ) } From 0f3696b99438e3ce8761d38325cee11ebb3b5bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 13:05:25 +0200 Subject: [PATCH 17/85] test(io): pin four concurrent-edit loss classes, two of them known red Two editors typing into the same block at once both keep their words, and deleting a repeated word while another editor types next to it converges on every screen with the deleted copy gone and the typed text kept. Both are green. Two are tagged `@skip` with a `# known red:` line and stay in the suite as a record. A script replacing the whole field while an editor has unsent typing fails only at `has been warned`: the protocol promises the warning after a resync, not when a received transaction takes the target away, which is a hole in the spec. The caret staying with its word while another editor types before it fails because the model keeps the caret at its offset instead of mapping it through the remote text change. New steps: a script setting the whole field (a transaction with no batch), deleting text before the caret (a backspace run), and a warning check. `compileScenarios` exposes `skipped` and the known-red sentence. --- .../io/gherkin-spec/concurrent-edits.feature | 78 +++++++++++++++++++ packages/io/src/document.test.ts | 44 +++++++++++ packages/io/src/document.ts | 36 +++++++++ packages/io/src/editor.ts | 2 + packages/io/src/fakes/server.test.ts | 28 +++++++ packages/io/src/fakes/server.ts | 19 +++++ packages/io/src/scenario/compile.test.ts | 60 ++++++++++++++ packages/io/src/scenario/compile.ts | 46 ++++++++++- packages/io/src/scenario/steps.ts | 28 +++++++ packages/io/src/scenario/world.ts | 26 ++++++- packages/io/src/test/scenarios.test.ts | 2 + 11 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 packages/io/gherkin-spec/concurrent-edits.feature diff --git a/packages/io/gherkin-spec/concurrent-edits.feature b/packages/io/gherkin-spec/concurrent-edits.feature new file mode 100644 index 0000000000..a99c90bb61 --- /dev/null +++ b/packages/io/gherkin-spec/concurrent-edits.feature @@ -0,0 +1,78 @@ +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 batch 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 batch 1 + When the server receives Editor B's batch 1 + And the server receives Editor A's batch 1 + Then the server has "B: fooy barx" + When Editor A receives Editor B's batch 1 + And Editor A's batch 1 comes back + Then Editor A shows "B: fooy barx" + When Editor B's batch 1 comes back + And Editor B receives Editor A's batch 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 batch 1 + When "copy" is deleted before the caret + Then Editor A shows "B: copy |" + And Editor A has sent batch 1 + When the server receives Editor A's batch 1 + And the server receives Editor B's batch 1 + Then the server has "B: copy hi" + When Editor A's batch 1 comes back + And Editor A receives Editor B's batch 1 + Then Editor A shows "B: copy hi" + When Editor B receives Editor A's batch 1 + And Editor B's batch 1 comes back + Then Editor B shows "B: copy hi" + + # known red: the model warns about unsent typing with no target only after a resync, not when a received transaction takes the target away + @skip + 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 batch 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 + When the server receives Editor A's batch 1 + Then the server has "B: bar" + When Editor A's batch 1 comes back + Then Editor A shows "B: bar" + And Editor A has sent batch 2 + When the server receives Editor A's batch 2 + Then the server has "B: bar" + When Editor A's batch 2 comes back + Then Editor A shows "B: bar" + And Editor A has sent nothing new + + # known red: the model doesn't map the caret through remote text changes + @skip + 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 batch 1 + When the server receives Editor B's batch 1 + And Editor A receives Editor B's batch 1 + Then Editor A shows "B: yfoo| bar" diff --git a/packages/io/src/document.test.ts b/packages/io/src/document.test.ts index 822fb1421d..86aa6df19b 100644 --- a/packages/io/src/document.test.ts +++ b/packages/io/src/document.test.ts @@ -150,6 +150,50 @@ describe(createDocument.name, () => { expect(applyAll(before, result.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 = createDocument( + {keyGenerator}, + parseTextspec({keyGenerator}, 'B: foo bar|;;B: baz'), + ) + const before = document.getValue() + + const result = document.deleteBeforeCaret(' bar') + + expect(result).toEqual({ + patches: [ + diffMatchPatch('foo bar', 'foo', [ + {_key: 'k0'}, + 'children', + {_key: 'k1'}, + 'text', + ]), + ], + undoStep: undefined, + }) + expect(document.toTextspec()).toEqual('B: foo|;;B: baz') + expect(applyAll(before, result.patches)).toEqual(document.getValue()) + }) + + test('deleting text that is not right before the caret throws and changes nothing', () => { + const keyGenerator = createTestKeyGenerator() + const document = createDocument( + {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 = createDocument( diff --git a/packages/io/src/document.ts b/packages/io/src/document.ts index 57136e8a26..af2f73a6d2 100644 --- a/packages/io/src/document.ts +++ b/packages/io/src/document.ts @@ -82,6 +82,12 @@ export type Document = { toTextspec: (options?: {keys?: boolean}) => string setStyle: (style: string) => ActionResult type: (text: string) => ActionResult + /** + * 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. Undo doesn't + * revert it. + */ + deleteBeforeCaret: (text: string) => ActionResult putCaretAfter: (text: string) => void insertBlock: (textspec: string) => ActionResult deleteBlock: (text: string) => ActionResult @@ -268,6 +274,35 @@ export function createDocument( } } + function deleteBeforeCaret(text: string): ActionResult { + 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 { + patches: [diffMatchPatch(span.text, nextText, path)], + undoStep: undefined, + } + } + function deleteText(typed: Extract): Array { const blockIndex = value.findIndex((block) => block._key === typed.blockKey) @@ -505,6 +540,7 @@ export function createDocument( }), setStyle, type, + deleteBeforeCaret, putCaretAfter, insertBlock, deleteBlock, diff --git a/packages/io/src/editor.ts b/packages/io/src/editor.ts index f7ed9c2947..fdffa04501 100644 --- a/packages/io/src/editor.ts +++ b/packages/io/src/editor.ts @@ -95,6 +95,7 @@ export type IoEditor = { setStyle: (style: string) => void type: (text: string) => void + deleteBeforeCaret: (text: string) => void putCaretAfter: (text: string) => void insertBlock: (textspec: string) => void deleteBlock: (text: string) => void @@ -812,6 +813,7 @@ export function createIoEditor(options: { }, setStyle: (style) => act(() => document.setStyle(style)), type: (text) => act(() => document.type(text)), + deleteBeforeCaret: (text) => act(() => document.deleteBeforeCaret(text)), putCaretAfter, insertBlock: (textspec) => act(() => document.insertBlock(textspec)), deleteBlock: (text) => act(() => document.deleteBlock(text)), diff --git a/packages/io/src/fakes/server.test.ts b/packages/io/src/fakes/server.test.ts index 0e4ee0264a..c5c366c2f9 100644 --- a/packages/io/src/fakes/server.test.ts +++ b/packages/io/src/fakes/server.test.ts @@ -252,6 +252,34 @@ describe(createServer.name, () => { 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 = createServer({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, [])], + batchIds: [], + }) + expect(server.copy()).toEqual({value: nextValue, rev: 'r2'}) + expect(server.getLog()).toEqual([{transaction, changesField: true}]) + }) + + test('setting the whole field of a missing document throws', () => { + const keyGenerator = createTestKeyGenerator() + const server = createServer({documentId: 'document', document: undefined}) + + expect(() => + server.setField(parseTextspec({keyGenerator}, 'B: bar').value, 't1'), + ).toThrow('The document does not exist') + }) + test('deleting and recreating the document', () => { const keyGenerator = createTestKeyGenerator() const {value} = parseTextspec({keyGenerator}, 'B: foo') diff --git a/packages/io/src/fakes/server.ts b/packages/io/src/fakes/server.ts index f663dc9666..1de8d96537 100644 --- a/packages/io/src/fakes/server.ts +++ b/packages/io/src/fakes/server.ts @@ -39,6 +39,11 @@ export type Server = { isRefused: (batchId: string) => boolean /** Records a transaction that changes only another field of the document. */ changeOtherField: (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, @@ -127,6 +132,20 @@ export function createServer(initial: { false, ) }, + 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, [])], batchIds: []}, + nextRevision(), + JSON.stringify(valueBefore) !== JSON.stringify(value), + ) + }, deleteDocument: (transactionId) => { if (rev === undefined) { throw new Error('The document does not exist') diff --git a/packages/io/src/scenario/compile.test.ts b/packages/io/src/scenario/compile.test.ts index f8d0c2fd38..f7f67ca8f0 100644 --- a/packages/io/src/scenario/compile.test.ts +++ b/packages/io/src/scenario/compile.test.ts @@ -1,4 +1,5 @@ import {describe, expect, test} from 'vitest' +import concurrentEditsFeature from '../../gherkin-spec/concurrent-edits.feature?raw' import listenersFeature from '../../gherkin-spec/listeners.feature?raw' import loadingAndEmptyFeature from '../../gherkin-spec/loading-and-empty.feature?raw' import {compileScenarios} from './compile' @@ -26,6 +27,65 @@ describe(compileScenarios.name, () => { }) }) + test('marks skipped scenarios and reads what a known red one lacks', () => { + const {scenarios} = compileScenarios(concurrentEditsFeature) + + expect( + scenarios.map(({name, skipped, knownRed}) => ({name, skipped, knownRed})), + ).toEqual([ + { + name: 'Two editors type into the same block at once, and both keep their words', + skipped: false, + knownRed: undefined, + }, + { + name: 'One editor deletes a repeated word while another types next to it, every screen converges, and the second copy is the one that goes', + skipped: false, + knownRed: undefined, + }, + { + name: 'A script replaces the whole field while Editor A has unsent typing', + skipped: true, + knownRed: + 'the model warns about unsent typing with no target only after a resync, not when a received transaction takes the target away', + }, + { + name: 'The caret stays with its word while Editor B types before it', + skipped: true, + knownRed: "the model doesn't map the caret through remote text changes", + }, + ]) + }) + + test('a known red comment counts only on the line right above the scenario', () => { + const {scenarios} = compileScenarios( + [ + 'Feature: Free play', + ' # known red: the first', + '', + ' Scenario: foo', + ' Given the document is "B: foo|"', + '', + ' # known red: the second', + ' Scenario Outline: bar ', + ' Given the document is "B: |"', + '', + ' Examples:', + ' | text |', + ' | bar |', + ' | baz |', + ].join('\n'), + ) + + expect( + scenarios.map(({name, skipped, knownRed}) => ({name, skipped, knownRed})), + ).toEqual([ + {name: 'foo', skipped: false, knownRed: undefined}, + {name: 'bar bar', skipped: false, knownRed: 'the second'}, + {name: 'bar baz', skipped: false, knownRed: 'the second'}, + ]) + }) + test('a scenario runs one step at a time to the end', async () => { const [scenario] = compileScenarios(listenersFeature).scenarios const world = createWorld() diff --git a/packages/io/src/scenario/compile.ts b/packages/io/src/scenario/compile.ts index 9ab328e238..2782f1b3d3 100644 --- a/packages/io/src/scenario/compile.ts +++ b/packages/io/src/scenario/compile.ts @@ -14,6 +14,13 @@ export type CompiledStep = { export type CompiledScenario = { name: string + /** Whether the scenario or its feature is tagged `@skip`. */ + skipped: boolean + /** + * What the model lacks, from a `# known red:` comment on the line right + * above the scenario and its tags. + */ + knownRed: string | undefined steps: Array } @@ -45,10 +52,13 @@ export function compileScenarios(featureText: string): CompiledScenarios { return { feature: compiled.name, scenarios: compiled.scenarios.map((scenario, scenarioIndex) => { - const parsedSteps = pickles.scenarios[scenarioIndex].steps + const parsedScenario = pickles.scenarios[scenarioIndex] + const parsedSteps = parsedScenario.steps return { name: scenario.name, + skipped: scenario.tag === 'skip', + knownRed: parsedScenario.knownRed, steps: scenario.steps.map((runStep, stepIndex) => ({ keyword: parsedSteps[stepIndex].keyword, text: parsedSteps[stepIndex].text, @@ -60,7 +70,10 @@ export function compileScenarios(featureText: string): CompiledScenarios { } function parsePickles(featureText: string): { - scenarios: Array<{steps: Array<{keyword: string; text: string}>}> + scenarios: Array<{ + knownRed: string | undefined + steps: Array<{keyword: string; text: string}> + }> } { const newId = Messages.IdGenerator.incrementing() const parser = new Gherkin.Parser( @@ -74,10 +87,30 @@ function parsePickles(featureText: string): { keywords.set(step.id, step.keyword.trim()) } + const knownRedSentences = new Map() + + for (const scenario of astScenarios( + gherkinDocument.feature?.children ?? [], + )) { + const firstLine = Math.min( + scenario.location.line, + ...scenario.tags.map((tag) => tag.location.line), + ) + const comment = gherkinDocument.comments.find( + (candidate) => candidate.location.line === firstLine - 1, + ) + const knownRed = comment?.text.match(/^\s*#\s*known red:\s*(.+?)\s*$/) + + if (knownRed) { + knownRedSentences.set(scenario.id, knownRed[1]) + } + } + const pickles = Gherkin.compile(gherkinDocument, '', newId) return { scenarios: pickles.map((pickle) => ({ + knownRed: knownRedSentences.get(pickle.astNodeIds[0] ?? ''), steps: pickle.steps.map((step) => ({ keyword: keywords.get(step.astNodeIds[0] ?? '') ?? '', text: step.text, @@ -86,6 +119,15 @@ function parsePickles(featureText: string): { } } +function astScenarios( + children: ReadonlyArray, +): Array { + return children.flatMap((child) => [ + ...(child.scenario ? [child.scenario] : []), + ...('rule' in child && child.rule ? astScenarios(child.rule.children) : []), + ]) +} + function astSteps( children: ReadonlyArray, ): Array { diff --git a/packages/io/src/scenario/steps.ts b/packages/io/src/scenario/steps.ts index f89b964a5a..6f0daf00d8 100644 --- a/packages/io/src/scenario/steps.ts +++ b/packages/io/src/scenario/steps.ts @@ -93,6 +93,18 @@ export const stepDefinitions = [ context.world.deliverNamed(name, 'the other field') }, ), + When( + 'a script sets the field to {textspec}', + (context: Context, textspec: string) => { + context.world.setFieldByScript(textspec) + }, + ), + When( + "{editor} receives the script's change", + (context: Context, name: EditorName) => { + context.world.deliverNamed(name, "the script's change") + }, + ), When( "{editor}'s batch {int} comes back", (context: Context, name: EditorName, batchNumber: number) => { @@ -293,6 +305,17 @@ export const stepDefinitions = [ worldEditor.heard.errors.slice(worldEditor.checkedErrorCount), ) }), + Then('{editor} has been warned', (context: Context, name: EditorName) => { + const worldEditor = context.world.getEditor(name) + const warningCount = worldEditor.heard.warnings.length + + checkGreaterThan( + `The warnings ${name} has given`, + warningCount, + worldEditor.checkedWarningCount, + ) + worldEditor.checkedWarningCount = warningCount + }), Then('the resync is refused', (context: Context) => { const resync = context.world.getLastResync() const {editor, heard} = context.world.getEditor(resync.editorName) @@ -352,6 +375,11 @@ function userSteps() { run: (context: Context, name: EditorName, text: string) => context.world.type(name, text), }, + { + text: '{string} is deleted before the caret', + run: (context: Context, name: EditorName, text: string) => + context.world.deleteBeforeCaret(name, text), + }, { text: 'the caret is put after {string}', run: (context: Context, name: EditorName, text: string) => diff --git a/packages/io/src/scenario/world.ts b/packages/io/src/scenario/world.ts index 70391a1725..28ee118c0f 100644 --- a/packages/io/src/scenario/world.ts +++ b/packages/io/src/scenario/world.ts @@ -41,6 +41,7 @@ export type HeardEvent = export type NamedTransaction = | 'the other field' + | "the script's change" | 'the deletion' | 'the recreation' @@ -159,6 +160,8 @@ export type WorldEditor = { checkedBatchCount: number /** The error count at the previous out-of-step or in-step check. */ checkedErrorCount: number + /** The warning count at the previous `has been warned` check. */ + checkedWarningCount: number } type Setup = { @@ -555,6 +558,16 @@ export function createWorld() { server.changeOtherField(nameTransaction('the other field')), ) }, + setFieldByScript: (textspec: string) => { + const {server, network} = getSetup() + const {value} = parseTextspec( + {keyGenerator: documentKeyGenerator}, + textspec, + ) + network.publish( + server.setField(value, nameTransaction("the script's change")), + ) + }, deleteDocument: () => { const {server, network} = getSetup() network.publish(server.deleteDocument(nameTransaction('the deletion'))) @@ -622,6 +635,9 @@ export function createWorld() { type: (name: EditorName, text: string) => { getEditor(name).editor.type(text) }, + deleteBeforeCaret: (name: EditorName, text: string) => { + getEditor(name).editor.deleteBeforeCaret(text) + }, putCaretAfter: (name: EditorName, text: string) => { getEditor(name).editor.putCaretAfter(text) }, @@ -689,7 +705,14 @@ function createWorldEditor({ }) editor.mount() - return {editor, host, heard, checkedBatchCount: 0, checkedErrorCount: 0} + return { + editor, + host, + heard, + checkedBatchCount: 0, + checkedErrorCount: 0, + checkedWarningCount: 0, + } } export function listenTo(editor: IoEditor): Heard { @@ -744,6 +767,7 @@ export function listenTo(editor: IoEditor): Heard { const namedTransactionPrefixes: Record = { 'the other field': 'other-field', + "the script's change": 'script', 'the deletion': 'deletion', 'the recreation': 'recreation', } diff --git a/packages/io/src/test/scenarios.test.ts b/packages/io/src/test/scenarios.test.ts index 70627a6fe0..4a9c486ae0 100644 --- a/packages/io/src/test/scenarios.test.ts +++ b/packages/io/src/test/scenarios.test.ts @@ -1,5 +1,6 @@ import {Before} from 'racejar' import {Feature} from 'racejar/vitest' +import concurrentEditsFeature from '../../gherkin-spec/concurrent-edits.feature?raw' import keysFeature from '../../gherkin-spec/keys.feature?raw' import lifecycleFeature from '../../gherkin-spec/lifecycle.feature?raw' import listenersFeature from '../../gherkin-spec/listeners.feature?raw' @@ -12,6 +13,7 @@ import {stepDefinitions, type Context} from '../scenario/steps' import {createWorld} from '../scenario/world' const features = [ + concurrentEditsFeature, keysFeature, lifecycleFeature, listenersFeature, From 98ed60e98e41d126d90317666b2b5a003c0ddf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 13:05:26 +0200 Subject: [PATCH 18/85] feat(io-playground): list known-red scenarios and say what they expect to fail on --- apps/io-playground/src/features.ts | 2 ++ apps/io-playground/src/narration.ts | 5 ++++ apps/io-playground/src/scenarios-tab.tsx | 36 +++++++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/io-playground/src/features.ts b/apps/io-playground/src/features.ts index a101464a08..de31f852cf 100644 --- a/apps/io-playground/src/features.ts +++ b/apps/io-playground/src/features.ts @@ -1,4 +1,5 @@ import {compileScenarios} from '@portabletext/io' +import concurrentEditsFeature from '@portabletext/io/gherkin-spec/concurrent-edits.feature?raw' import keysFeature from '@portabletext/io/gherkin-spec/keys.feature?raw' import lifecycleFeature from '@portabletext/io/gherkin-spec/lifecycle.feature?raw' import listenersFeature from '@portabletext/io/gherkin-spec/listeners.feature?raw' @@ -10,6 +11,7 @@ import sendingAndConfirmingFeature from '@portabletext/io/gherkin-spec/sending-a export const features = [ sendingAndConfirmingFeature, otherEditorsFeature, + concurrentEditsFeature, outOfStepAndResyncFeature, keysFeature, loadingAndEmptyFeature, diff --git a/apps/io-playground/src/narration.ts b/apps/io-playground/src/narration.ts index 0331c3a639..5fdb5ebfed 100644 --- a/apps/io-playground/src/narration.ts +++ b/apps/io-playground/src/narration.ts @@ -393,6 +393,11 @@ function narrateServer({ `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 deletion': sentences.push( `The document was deleted on the server as ${transaction.id}: ${revisions}.`, diff --git a/apps/io-playground/src/scenarios-tab.tsx b/apps/io-playground/src/scenarios-tab.tsx index b668935e4d..10271ff88d 100644 --- a/apps/io-playground/src/scenarios-tab.tsx +++ b/apps/io-playground/src/scenarios-tab.tsx @@ -164,16 +164,30 @@ export function ScenariosTab({ > {features.map((feature, featureIndex) => ( - {feature.scenarios.map((candidate, scenarioIndex) => ( + {feature.scenarios.map((candidate, scenarioIndex) => + candidate.knownRed === undefined ? ( + + ) : null, + )} + + ))} + {knownRedScenarios.length > 0 ? ( + + {knownRedScenarios.map(({featureIndex, scenarioIndex, name}) => ( ))} - ))} + ) : null}
    + {scenario.knownRed ? ( +

    + Expected to fail: {scenario.knownRed} +

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

    {runner.deliveryError}

    ) : null} @@ -237,6 +257,14 @@ export function ScenariosTab({ ) } +const knownRedScenarios = features.flatMap((feature, featureIndex) => + feature.scenarios.flatMap((candidate, scenarioIndex) => + candidate.knownRed === undefined + ? [] + : [{featureIndex, scenarioIndex, name: candidate.name}], + ), +) + function StepItem({ current, className, From b46c885b1199688db38b9d99efd6d1d0bdbc1c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Tue, 29 Sep 2026 13:47:43 +0200 Subject: [PATCH 19/85] refactor(io): drop `mutation accepted`, the host reports sent and rejected only A save reply confirms nothing: the batch is confirmed when its own transaction comes back on the feed. A message the editor ignores is noise for a host to implement, so it is gone from the types, the editor, the host and the steps. The network's reply queue carries rejections only, produced by a refusal. The playground's reply lane is labelled "rejections" and shows nothing for a save that went well. --- apps/io-playground/README.md | 2 +- apps/io-playground/src/concepts.ts | 4 +- apps/io-playground/src/link-panel.tsx | 15 ++--- apps/io-playground/src/narration.ts | 13 ++--- .../io/gherkin-spec/other-editors.feature | 2 - .../out-of-step-and-resync.feature | 3 +- .../sending-and-confirming.feature | 8 +-- packages/io/src/editor.ts | 9 --- packages/io/src/fakes/network.test.ts | 12 ++-- packages/io/src/fakes/network.ts | 5 +- packages/io/src/host.ts | 4 -- packages/io/src/index.ts | 1 - packages/io/src/scenario/steps.ts | 6 -- packages/io/src/scenario/world.test.ts | 9 +-- packages/io/src/scenario/world.ts | 57 +++++-------------- packages/io/src/types.ts | 2 - 16 files changed, 39 insertions(+), 113 deletions(-) diff --git a/apps/io-playground/README.md b/apps/io-playground/README.md index 7ef561c207..11826b306f 100644 --- a/apps/io-playground/README.md +++ b/apps/io-playground/README.md @@ -1,6 +1,6 @@ # 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, save replies and feed transactions sit as cards in the links until you deliver them, so a race is played by choosing the order. +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. Every value opens to its Portable Text blocks, every batch and transaction 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. diff --git a/apps/io-playground/src/concepts.ts b/apps/io-playground/src/concepts.ts index 81fe7643df..8efe01418d 100644 --- a/apps/io-playground/src/concepts.ts +++ b/apps/io-playground/src/concepts.ts @@ -14,9 +14,9 @@ export const concepts = [ "A batch on its way from an editor's host to the server, waiting for the server to receive it.", }, { - name: 'save reply', + name: 'rejection', definition: - "The server's answer to a save request, accepted or rejected, and only a rejection changes what the editor does.", + 'The server refused the batch and the host says so. A save that went well has no reply the editor needs: its transaction coming back is the confirmation.', }, { name: 'transaction', diff --git a/apps/io-playground/src/link-panel.tsx b/apps/io-playground/src/link-panel.tsx index a33730989f..5850a51bb5 100644 --- a/apps/io-playground/src/link-panel.tsx +++ b/apps/io-playground/src/link-panel.tsx @@ -22,7 +22,7 @@ type FeedItem = NetworkSnapshot['feeds'][EditorName][number] /** * The link between one editor and the server: save requests travel up - * toward the server, save replies and the feed travel down toward the editor. + * 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({ @@ -163,7 +163,7 @@ export function LinkPanel({ {towardEditor} {name} ) : null} - and the{' '} + and the{' '} {editorSide === 'right' ? (

    ) @@ -462,15 +595,84 @@ function startFreePlay(setup: Setup): FreePlay { ) } - return {world, log, narration, error: null} + 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.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.outOfStep + ? {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.sentBatches.length > times.length + ? [...times, ...editor.sentBatches.slice(times.length).map(() => now)] + : times } function setupSteps(setup: Setup): Array { diff --git a/apps/io-playground/src/link-panel.tsx b/apps/io-playground/src/link-panel.tsx index 8b89610b1d..68730fdafe 100644 --- a/apps/io-playground/src/link-panel.tsx +++ b/apps/io-playground/src/link-panel.tsx @@ -37,6 +37,7 @@ export function LinkPanel({ onDeliver, selectedBatchIds, onToggleSelected, + deadFeed, }: { name: EditorName /** Where the editor sits relative to this link. */ @@ -54,6 +55,8 @@ export function LinkPanel({ /** Save requests picked to be received as one transaction. */ selectedBatchIds: Array onToggleSelected: (batchId: string) => void + /** Whether the network has stopped delivering to the editor's listener. */ + deadFeed: boolean }) { const requests = network?.saveRequests.filter((request) => request.editor === name) ?? [] @@ -216,6 +219,7 @@ export function LinkPanel({ and the ) : null} + {deadFeed ? feed dead : null} {editorSide === 'right' ? (
    +
    +

    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

    diff --git a/apps/io-playground/src/narration.ts b/apps/io-playground/src/narration.ts index aa12fd1a5a..c0d55c40bf 100644 --- a/apps/io-playground/src/narration.ts +++ b/apps/io-playground/src/narration.ts @@ -578,6 +578,15 @@ function narrateServer({ ) } + 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) => diff --git a/apps/io-playground/src/server-panel.tsx b/apps/io-playground/src/server-panel.tsx index fbe4f5f54d..68af74deb4 100644 --- a/apps/io-playground/src/server-panel.tsx +++ b/apps/io-playground/src/server-panel.tsx @@ -1,10 +1,16 @@ -import type {NetworkSnapshot, ServerSnapshot} from '@portabletext/io/testing' +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, @@ -29,18 +35,38 @@ export function ServerPanel({ onStep, selectedRequests, onReceiveSelected, + editorA, + editorADeadFeed, }: { server: ServerSnapshot | null /** The virtual clock, in milliseconds. */ now: number | undefined advanceClock: Applicability - /** Present in free play: runs a `When` step. */ - onStep: ((text: string) => void) | undefined + /** 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 }) { 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() @@ -155,6 +181,16 @@ export function ServerPanel({ t = {(now ?? 0) / 1000} s +

    + + +
    ) : null} + + {onStep ? ( +
    · corrupt the stored copy, then pick a door} + > +
    + block + + +
    +
    + { + if ( + corruptedKey !== undefined && + onStep( + `the server's copy changes without a transaction so its block ${quoted(corruptedKey)} ${corruption}`, + ) + ) { + onStep( + editorA?.inFlight + ? `Editor A is resynced with the outcome of batch ${editorA.inFlight.batchNumber}` + : '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 @@ -173,3 +280,59 @@ export function ServerPanel({
    ) } + +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.outOfStep) { + 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 batch", + }, + 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', + }, + } +} From 7f805e742a5b9d99f4a8bbedb2a2565f2a315330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Thu, 1 Oct 2026 22:37:45 +0200 Subject: [PATCH 63/85] feat(io-playground): start free play in the first commit, and show what `load` may do and when Free play started with `the document is`, which loads both editors and ends their first commit in one step, so the lifecycle the protocol specifies was never on screen. Free play now starts with the editors in their first commit, and each editor panel marks the first commit while it lasts. `load` stays available until the first commit ends, a second `load` before ready replaces the first and the narration says so, and once the editor is ready `load` is disabled with the reason written next to it: after ready a load throws, and `resync` takes over. The concepts drawer describes the first commit. --- apps/io-playground/src/applicable.test.ts | 8 ++++---- apps/io-playground/src/applicable.ts | 2 +- apps/io-playground/src/concepts.ts | 5 +++++ apps/io-playground/src/editor-panel.tsx | 7 +++++++ apps/io-playground/src/free-play-tab.tsx | 9 +++++++-- apps/io-playground/src/narration.ts | 11 ++++++++--- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/io-playground/src/applicable.test.ts b/apps/io-playground/src/applicable.test.ts index d8a334fcb2..842294f183 100644 --- a/apps/io-playground/src/applicable.test.ts +++ b/apps/io-playground/src/applicable.test.ts @@ -31,7 +31,7 @@ describe(applicableActions.name, () => { 'resync discarding': {enabled: false, why: 'nothing unsent to discard'}, 'load': { enabled: false, - why: 'load is only accepted in the first commit: after ready it throws', + why: 'after ready a load throws: resync takes over', }, 'end first commit': {enabled: false, why: 'the first commit has ended'}, }) @@ -78,7 +78,7 @@ describe(applicableActions.name, () => { }, 'load': { enabled: false, - why: 'load is only accepted in the first commit: after ready it throws', + why: 'after ready a load throws: resync takes over', }, 'end first commit': {enabled: false, why: 'the first commit has ended'}, }) @@ -118,7 +118,7 @@ describe(applicableActions.name, () => { }, 'load': { enabled: false, - why: 'load is only accepted in the first commit: after ready it throws', + why: 'after ready a load throws: resync takes over', }, 'end first commit': {enabled: false, why: 'the first commit has ended'}, }) @@ -195,7 +195,7 @@ describe(applicableActions.name, () => { 'resync discarding': {enabled: false, why: 'nothing unsent to discard'}, 'load': { enabled: false, - why: 'load is only accepted in the first commit: after ready it throws', + why: 'after ready a load throws: resync takes over', }, 'end first commit': {enabled: false, why: 'the first commit has ended'}, }) diff --git a/apps/io-playground/src/applicable.ts b/apps/io-playground/src/applicable.ts index 6d9bdf152a..a06e6345b9 100644 --- a/apps/io-playground/src/applicable.ts +++ b/apps/io-playground/src/applicable.ts @@ -126,7 +126,7 @@ export function applicableActions( 'load': firstCommitApplicability( editorName, editor, - 'load is only accepted in the first commit: after ready it throws', + 'after ready a load throws: resync takes over', ), 'end first commit': firstCommitApplicability( editorName, diff --git a/apps/io-playground/src/concepts.ts b/apps/io-playground/src/concepts.ts index f91dafafc9..5c5c8392c9 100644 --- a/apps/io-playground/src/concepts.ts +++ b/apps/io-playground/src/concepts.ts @@ -114,6 +114,11 @@ export const concepts = [ 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 a resync dropped the rejected batch.", }, + { + 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: diff --git a/apps/io-playground/src/editor-panel.tsx b/apps/io-playground/src/editor-panel.tsx index 38ffcb00d2..b47255cc77 100644 --- a/apps/io-playground/src/editor-panel.tsx +++ b/apps/io-playground/src/editor-panel.tsx @@ -68,6 +68,13 @@ export function EditorPanel({ > {editor.status} + {editor.status === 'loading' ? ( + + ) : null}