From f6c4e6f3033b76a4e7b7eca8dbd735942c20afd0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 5 May 2026 09:55:16 -0700 Subject: [PATCH] Suggestions: Bump payload schema to v2 v1 readers silently dropped operation types they didn't know about, which would cause a v2 payload with structural ops (block-insert-after, block-remove, block-move under #77434) to apply only its attribute-set operations. Refusing the payload outright instead surfaces an explicit "newer editor" notice and offers only Reject. The shape is unchanged: v1 payloads only ever carried attribute-set ops and remain valid as v2 payloads. The migration step in parseSuggestionPayload stamps the version field forward; no rewriting needed. Pre-versioned payloads are treated as v1. Refs #77434. --- docs/explanations/architecture/suggestions.md | 10 ++- .../components/suggestion-mode/provider.js | 87 +++++++++++++++---- .../suggestion-mode/test/provider.js | 58 ++++++++++++- 3 files changed, 133 insertions(+), 22 deletions(-) diff --git a/docs/explanations/architecture/suggestions.md b/docs/explanations/architecture/suggestions.md index 16ae83737c0b08..a78a7500d22d75 100644 --- a/docs/explanations/architecture/suggestions.md +++ b/docs/explanations/architecture/suggestions.md @@ -108,13 +108,13 @@ REST/PHP surface lives in `lib/compat/wordpress-6.9/`: | `block-comments.php` | Registers the `_wp_note_status`, `_wp_suggestion`, and `_wp_suggestion_status` comment meta and adds `editor.notes` post-type support. | | `class-gutenberg-rest-comment-controller-6-9.php` | REST controller subclass remapping permissions for `note`-type comments (post editors get `edit_post`-based access; updates are gated by an allowlist of suggestion-lifecycle fields). | -## Suggestion Payload (v1) +## Suggestion Payload (v2) Stored as a JSON string in the `_wp_suggestion` comment meta on a `note` comment: ```json { - "schemaVersion": 1, + "schemaVersion": 2, "blockName": "core/paragraph", "baseRevision": "2026-04-15T12:34:56", "operations": [ @@ -133,10 +133,14 @@ Stored as a JSON string in the `_wp_suggestion` comment meta on a `note` comment | `schemaVersion` | Allows future schema evolution without breaking old payloads. | | `blockName` | Safety check — apply is refused if the block type has changed. | | `baseRevision` | `post_modified_gmt` at capture time. A mismatch at apply time triggers a staleness warning. | -| `operations` | Declarative transforms on the block tree. Currently only `attribute-set`; designed to extend to `block-insert-after`, `block-remove` in the future. | +| `operations` | Declarative transforms on the block tree. v1 emitted `attribute-set` only; v2 reserves the structural variants (`block-insert-after`, `block-remove`, `block-move`) tracked in [#77434](https://github.com/WordPress/gutenberg/issues/77434). | Operations are **declarative transforms**, not HTML diffs. This makes them compatible with Yjs attribution semantics and resilient to concurrent edits on unrelated attributes. +### v1 → v2 compatibility + +The shape of a v1 payload is a strict subset of v2 (only `attribute-set` operations). v1 payloads are migrated forward in `parseSuggestionPayload` by stamping `schemaVersion: 2` — no rewriting needed. The bump matters because a v1 reader that encountered a v2 payload with structural ops would silently drop them at apply time; refusing the payload outright surfaces an explicit "newer editor" notice and offers only Reject. + ### Schema versioning `schemaVersion` is incremented whenever the payload shape changes. Consumers apply the following rule: diff --git a/packages/editor/src/components/suggestion-mode/provider.js b/packages/editor/src/components/suggestion-mode/provider.js index 6be6f6a8946cf0..6a945c857c0d9d 100644 --- a/packages/editor/src/components/suggestion-mode/provider.js +++ b/packages/editor/src/components/suggestion-mode/provider.js @@ -20,11 +20,13 @@ import { /** * @typedef {Object} SuggestionOperation - * @property {'attribute-set'} type Operation type. Only `attribute-set` - * is implemented in Phase 2. - * @property {string} attribute The attribute being changed. - * @property {*} before The baseline value. - * @property {*} after The proposed value. + * @property {'attribute-set'|'block-insert-after'|'block-remove'|'block-move'} type + * Operation type. `attribute-set` ships in Phase 2; the structural + * variants ship in Phase 6 (issue #77434). + * @property {string} [attribute] The attribute being changed + * (`attribute-set` only). + * @property {*} [before] The baseline value (`attribute-set`). + * @property {*} [after] The proposed value (`attribute-set`). */ /** @@ -37,7 +39,20 @@ import { * @property {SuggestionOperation[]} operations Ordered operations. */ -const SCHEMA_VERSION = 1; +/** + * Suggestion payload schema version. v1 emitted only `attribute-set` + * operations; v2 reserves the structural op types (`block-insert-after`, + * `block-remove`, `block-move`) tracked in issue #77434. + * + * Reader rule: + * parsed < SCHEMA_VERSION → migrate forward, then apply. + * parsed === SCHEMA_VERSION → apply as-is. + * parsed > SCHEMA_VERSION → refuse (newer-editor notice; offer Reject only). + * + * Bumping this constant requires a corresponding migration step in + * `parseSuggestionPayload`. + */ +const SCHEMA_VERSION = 2; /** * Maximum byte length of a serialized suggestion payload. Mirrors @@ -216,28 +231,66 @@ export function hasAttributeConflict( currentAttributes, operations ) { } /** - * Parse a `_wp_suggestion` meta value into a typed payload. + * Migrate a payload emitted by an older `SCHEMA_VERSION` up to the current + * shape. v1 → v2 is a pure additive change (structural op types reserved but + * v1 payloads never used them), so the migration just stamps the version + * field forward — no shape rewriting is needed. + * + * Add a new `case` per future bump; never remove old cases, since the + * comment-meta store may contain payloads written by every prior version. + * + * @param {Object} parsed Parsed JSON payload of a known older version. + * @return {Object} Payload upgraded to the current schema. + */ +function migrateSuggestionPayload( parsed ) { + let next = parsed; + if ( next.schemaVersion === 1 ) { + next = { ...next, schemaVersion: 2 }; + } + return next; +} + +/** + * Parse a `_wp_suggestion` meta value into a typed payload. Refuses payloads + * written by a newer editor (`schemaVersion > SCHEMA_VERSION`) so a partial + * apply can't drop op types this consumer doesn't understand. Migrates + * older payloads forward to the current shape. * * @param {string|undefined} raw The raw JSON string from comment meta. - * @return {SuggestionPayload|null} Parsed payload, or null if invalid. + * @return {SuggestionPayload|null} Parsed payload, or null when the input is + * malformed or the payload was written by a newer editor. */ export function parseSuggestionPayload( raw ) { if ( ! raw ) { return null; } + let parsed; try { - const parsed = JSON.parse( raw ); - if ( - typeof parsed === 'object' && - parsed !== null && - Array.isArray( parsed.operations ) - ) { - return parsed; - } - return null; + parsed = JSON.parse( raw ); } catch { return null; } + if ( + typeof parsed !== 'object' || + parsed === null || + ! Array.isArray( parsed.operations ) + ) { + return null; + } + // Pre-versioned payloads (schemaVersion missing) are treated as v1 — the + // only writer that emitted them was the v1 implementation. + const version = + typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : 1; + if ( version > SCHEMA_VERSION ) { + return null; + } + if ( version < SCHEMA_VERSION ) { + return migrateSuggestionPayload( { + ...parsed, + schemaVersion: version, + } ); + } + return parsed; } /** diff --git a/packages/editor/src/components/suggestion-mode/test/provider.js b/packages/editor/src/components/suggestion-mode/test/provider.js index 5c73c4aef56a39..4a938a0adbf8b2 100644 --- a/packages/editor/src/components/suggestion-mode/test/provider.js +++ b/packages/editor/src/components/suggestion-mode/test/provider.js @@ -249,9 +249,9 @@ describe( 'hasAttributeConflict', () => { } ); describe( 'parseSuggestionPayload', () => { - it( 'parses a valid JSON payload', () => { + it( 'parses a valid current-version JSON payload', () => { const raw = JSON.stringify( { - schemaVersion: 1, + schemaVersion: 2, blockName: 'core/paragraph', baseRevision: '2026-04-15T00:00:00', operations: [ @@ -265,10 +265,64 @@ describe( 'parseSuggestionPayload', () => { } ); const result = parseSuggestionPayload( raw ); expect( result ).not.toBeNull(); + expect( result.schemaVersion ).toBe( 2 ); expect( result.operations ).toHaveLength( 1 ); expect( result.blockName ).toBe( 'core/paragraph' ); } ); + it( 'migrates a v1 payload forward to the current version', () => { + const raw = JSON.stringify( { + schemaVersion: 1, + blockName: 'core/paragraph', + baseRevision: null, + operations: [ + { + type: 'attribute-set', + attribute: 'content', + before: 'a', + after: 'b', + }, + ], + } ); + const result = parseSuggestionPayload( raw ); + expect( result ).not.toBeNull(); + expect( result.schemaVersion ).toBe( 2 ); + expect( result.operations ).toHaveLength( 1 ); + } ); + + it( 'treats a missing schemaVersion as v1 and migrates it', () => { + const raw = JSON.stringify( { + blockName: 'core/paragraph', + baseRevision: null, + operations: [ + { + type: 'attribute-set', + attribute: 'content', + before: 'a', + after: 'b', + }, + ], + } ); + const result = parseSuggestionPayload( raw ); + expect( result ).not.toBeNull(); + expect( result.schemaVersion ).toBe( 2 ); + } ); + + it( 'refuses a payload from a newer editor', () => { + const raw = JSON.stringify( { + schemaVersion: 99, + blockName: 'core/paragraph', + baseRevision: null, + operations: [ + { + type: 'block-rotate', + clientId: 'x', + }, + ], + } ); + expect( parseSuggestionPayload( raw ) ).toBeNull(); + } ); + it( 'returns null for missing, empty, or invalid input', () => { expect( parseSuggestionPayload( undefined ) ).toBeNull(); expect( parseSuggestionPayload( '' ) ).toBeNull();