diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 4cf4debe13..664c3122bc 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -172,6 +172,9 @@ const INTENT_NAMES = { 'doc.tables.get': 'get_table', 'doc.tables.getCells': 'get_table_cells', 'doc.tables.getProperties': 'get_table_properties', + 'doc.history.get': 'get_history', + 'doc.history.undo': 'undo', + 'doc.history.redo': 'redo', } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 6661371d00..f3a2291e79 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -1553,6 +1553,26 @@ export const SUCCESS_SCENARIOS = { 'doc.tables.get': tableReadScenario('tables.get'), 'doc.tables.getCells': tableReadScenario('tables.getCells'), 'doc.tables.getProperties': tableReadScenario('tables.getProperties'), + + // --------------------------------------------------------------------------- + // History operations + // --------------------------------------------------------------------------- + + 'doc.history.get': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-history-get-success'); + await harness.openSessionFixture(stateDir, 'doc-history-get', 'history-get-session'); + return { stateDir, args: ['history', 'get', '--session', 'history-get-session'] }; + }, + 'doc.history.undo': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-history-undo-success'); + await harness.openSessionFixture(stateDir, 'doc-history-undo', 'history-undo-session'); + return { stateDir, args: ['history', 'undo', '--session', 'history-undo-session'] }; + }, + 'doc.history.redo': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-history-redo-success'); + await harness.openSessionFixture(stateDir, 'doc-history-redo', 'history-redo-session'); + return { stateDir, args: ['history', 'redo', '--session', 'history-redo-session'] }; + }, } as const satisfies Record Promise>; export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => { diff --git a/apps/cli/src/__tests__/contract-response-conformance.test.ts b/apps/cli/src/__tests__/contract-response-conformance.test.ts index 32d6dfadc6..cea74f90d3 100644 --- a/apps/cli/src/__tests__/contract-response-conformance.test.ts +++ b/apps/cli/src/__tests__/contract-response-conformance.test.ts @@ -35,6 +35,14 @@ describe('contract response conformance', () => { const success = envelope as SuccessEnvelope; validateOperationResponseData(scenario.operationId, success.data, commandKey); + + // Regression guard: history operations must serialize payload under `result`, + // never under an "undefined" key from missing envelope metadata. + if (scenario.operationId.startsWith('doc.history.')) { + const data = success.data as Record; + expect(Object.prototype.hasOwnProperty.call(data, 'result')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(data, 'undefined')).toBe(false); + } }); test(`failure envelope conforms for ${scenario.operationId}`, async () => { diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 08450053b6..78e69d8925 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -154,6 +154,9 @@ export const SUCCESS_VERB: Record = { 'tables.get': 'resolved table', 'tables.getCells': 'listed cells', 'tables.getProperties': 'resolved table properties', + 'history.get': 'retrieved history state', + 'history.undo': 'undid last change', + 'history.redo': 'redid last change', }; // --------------------------------------------------------------------------- @@ -267,6 +270,9 @@ export const OUTPUT_FORMAT: Record = { 'tables.get': 'tableInfo', 'tables.getCells': 'tableCellList', 'tables.getProperties': 'tablePropertiesInfo', + 'history.get': 'plain', + 'history.undo': 'plain', + 'history.redo': 'plain', }; // --------------------------------------------------------------------------- @@ -364,6 +370,9 @@ export const RESPONSE_ENVELOPE_KEY: Record 'tables.get': 'result', 'tables.getCells': 'result', 'tables.getProperties': 'result', + 'history.get': 'result', + 'history.undo': 'result', + 'history.redo': 'result', }; // --------------------------------------------------------------------------- @@ -489,4 +498,7 @@ export const OPERATION_FAMILY: Record = 'tables.get': 'tables', 'tables.getCells': 'tables', 'tables.getProperties': 'tables', + 'history.get': 'query', + 'history.undo': 'general', + 'history.redo': 'general', }; diff --git a/apps/cli/src/cli/types.ts b/apps/cli/src/cli/types.ts index fcd50e8f0a..eae4ba8a8f 100644 --- a/apps/cli/src/cli/types.ts +++ b/apps/cli/src/cli/types.ts @@ -124,6 +124,7 @@ export type CliCategory = | 'comments' | 'trackChanges' | 'capabilities' + | 'history' | 'lifecycle' | 'session' | 'introspection'; diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 24491cd292..116dda41f1 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -20,6 +20,7 @@ Use the tables below to see what operations are available and where each one is | Core | 8 | 0 | 8 | [Reference](/document-api/reference/core/index) | | Create | 5 | 0 | 5 | [Reference](/document-api/reference/create/index) | | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | +| History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | | Lists | 8 | 0 | 8 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Paragraph Formatting | 17 | 0 | 17 | [Reference](/document-api/reference/format/paragraph/index) | @@ -98,6 +99,9 @@ Use the tables below to see what operations are available and where each one is | editor.doc.format.stylisticSets(...) | [`format.stylisticSets`](/document-api/reference/format/stylistic-sets) | | editor.doc.format.contextualAlternates(...) | [`format.contextualAlternates`](/document-api/reference/format/contextual-alternates) | | editor.doc.format.strikethrough(...) | [`format.strike`](/document-api/reference/format/strike) | +| editor.doc.history.get(...) | [`history.get`](/document-api/reference/history/get) | +| editor.doc.history.undo(...) | [`history.undo`](/document-api/reference/history/undo) | +| editor.doc.history.redo(...) | [`history.redo`](/document-api/reference/history/redo) | | editor.doc.lists.list(...) | [`lists.list`](/document-api/reference/lists/list) | | editor.doc.lists.get(...) | [`lists.get`](/document-api/reference/lists/get) | | editor.doc.lists.insert(...) | [`lists.insert`](/document-api/reference/lists/insert) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index bd76195562..5ecfc79e01 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -86,6 +86,10 @@ "apps/docs/document-api/reference/get-node-by-id.mdx", "apps/docs/document-api/reference/get-node.mdx", "apps/docs/document-api/reference/get-text.mdx", + "apps/docs/document-api/reference/history/get.mdx", + "apps/docs/document-api/reference/history/index.mdx", + "apps/docs/document-api/reference/history/redo.mdx", + "apps/docs/document-api/reference/history/undo.mdx", "apps/docs/document-api/reference/index.mdx", "apps/docs/document-api/reference/info.mdx", "apps/docs/document-api/reference/insert.mdx", @@ -423,6 +427,13 @@ "pagePath": "apps/docs/document-api/reference/tables/index.mdx", "title": "Tables" }, + { + "aliasMemberPaths": [], + "key": "history", + "operationIds": ["history.get", "history.undo", "history.redo"], + "pagePath": "apps/docs/document-api/reference/history/index.mdx", + "title": "History" + }, { "aliasMemberPaths": [], "key": "toc", @@ -432,5 +443,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "98ac639d0837d66b0d968f4a0811a0bac22f407a989caeda784326e40f337e68" + "sourceHash": "c5cf08d833b08c281a2bb18ec03caa6d95d20f69defe7897b30a9b903774705c" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 23cad987a5..f69798d889 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -322,6 +322,12 @@ _No fields._ "COMMAND_UNAVAILABLE" ] }, + "history": { + "enabled": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ] + }, "lists": { "enabled": true, "reasons": [ @@ -960,6 +966,30 @@ _No fields._ ], "tracked": true }, + "history.get": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "history.redo": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, + "history.undo": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "info": { "available": true, "dryRun": true, @@ -3195,6 +3225,33 @@ _No fields._ ], "type": "object" }, + "history": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "lists": { "additionalProperties": false, "properties": { @@ -3254,7 +3311,8 @@ _No fields._ "trackChanges", "comments", "lists", - "dryRun" + "dryRun", + "history" ], "type": "object" }, @@ -5991,6 +6049,111 @@ _No fields._ ], "type": "object" }, + "history.get": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "history.redo": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "history.undo": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "info": { "additionalProperties": false, "properties": { @@ -9022,7 +9185,10 @@ _No fields._ "toc.get", "toc.configure", "toc.update", - "toc.remove" + "toc.remove", + "history.get", + "history.undo", + "history.redo" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/history/get.mdx b/apps/docs/document-api/reference/history/get.mdx new file mode 100644 index 0000000000..2f22eaa7f7 --- /dev/null +++ b/apps/docs/document-api/reference/history/get.mdx @@ -0,0 +1,117 @@ +--- +title: history.get +sidebarTitle: history.get +description: Query the current undo/redo history state of the active editor. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Query the current undo/redo history state of the active editor. + +- Operation ID: `history.get` +- API member path: `editor.doc.history.get(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HistoryState object with undoDepth, redoDepth, canUndo, canRedo, and a list of history-unsafe operations. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `canRedo` | boolean | yes | | +| `canUndo` | boolean | yes | | +| `historyUnsafeOperations` | string[] | yes | | +| `redoDepth` | integer | yes | | +| `undoDepth` | integer | yes | | + +### Example response + +```json +{ + "canRedo": true, + "canUndo": true, + "historyUnsafeOperations": [ + "example" + ], + "redoDepth": 1, + "undoDepth": 1 +} +``` + +## Pre-apply throws + +- None + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "canRedo": { + "type": "boolean" + }, + "canUndo": { + "type": "boolean" + }, + "historyUnsafeOperations": { + "items": { + "type": "string" + }, + "type": "array" + }, + "redoDepth": { + "minimum": 0, + "type": "integer" + }, + "undoDepth": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "undoDepth", + "redoDepth", + "canUndo", + "canRedo", + "historyUnsafeOperations" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/history/index.mdx b/apps/docs/document-api/reference/history/index.mdx new file mode 100644 index 0000000000..f885a4ed01 --- /dev/null +++ b/apps/docs/document-api/reference/history/index.mdx @@ -0,0 +1,20 @@ +--- +title: History operations +sidebarTitle: History +description: History operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Undo/redo history state and navigation. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| history.get | `history.get` | No | `idempotent` | No | No | +| history.undo | `history.undo` | Yes | `non-idempotent` | No | No | +| history.redo | `history.redo` | Yes | `non-idempotent` | No | No | + diff --git a/apps/docs/document-api/reference/history/redo.mdx b/apps/docs/document-api/reference/history/redo.mdx new file mode 100644 index 0000000000..2ec2f328a5 --- /dev/null +++ b/apps/docs/document-api/reference/history/redo.mdx @@ -0,0 +1,179 @@ +--- +title: history.redo +sidebarTitle: history.redo +description: Redo the most recently undone action in the active editor. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Redo the most recently undone action in the active editor. + +- Operation ID: `history.redo` +- API member path: `editor.doc.history.redo(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HistoryActionResult with noop flag and revision before/after; noop is true when the redo stack is empty. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `noop` | boolean | yes | | +| `revision` | object | yes | | + +### Example response + +```json +{ + "noop": true, + "revision": { + "after": "example", + "before": "example" + } +} +``` + +## Pre-apply throws + +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "noop": { + "type": "boolean" + }, + "revision": { + "additionalProperties": false, + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + } + }, + "required": [ + "before", + "after" + ], + "type": "object" + } + }, + "required": [ + "noop", + "revision" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "noop": { + "type": "boolean" + }, + "revision": { + "additionalProperties": false, + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + } + }, + "required": [ + "before", + "after" + ], + "type": "object" + } + }, + "required": [ + "noop", + "revision" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/history/undo.mdx b/apps/docs/document-api/reference/history/undo.mdx new file mode 100644 index 0000000000..d39549d220 --- /dev/null +++ b/apps/docs/document-api/reference/history/undo.mdx @@ -0,0 +1,179 @@ +--- +title: history.undo +sidebarTitle: history.undo +description: Undo the most recent history-safe mutation in the active editor. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Undo the most recent history-safe mutation in the active editor. + +- Operation ID: `history.undo` +- API member path: `editor.doc.history.undo(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a HistoryActionResult with noop flag and revision before/after; noop is true when the undo stack is empty. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `noop` | boolean | yes | | +| `revision` | object | yes | | + +### Example response + +```json +{ + "noop": true, + "revision": { + "after": "example", + "before": "example" + } +} +``` + +## Pre-apply throws + +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "noop": { + "type": "boolean" + }, + "revision": { + "additionalProperties": false, + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + } + }, + "required": [ + "before", + "after" + ], + "type": "object" + } + }, + "required": [ + "noop", + "revision" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "noop": { + "type": "boolean" + }, + "revision": { + "additionalProperties": false, + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + } + }, + "required": [ + "before", + "after" + ], + "type": "object" + } + }, + "required": [ + "noop", + "revision" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "CAPABILITY_UNAVAILABLE" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 73e31ad2c6..0f9362f289 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -35,6 +35,7 @@ Document API is currently alpha and subject to breaking changes. | Paragraph Formatting | 17 | 0 | 17 | [Open](/document-api/reference/format/paragraph/index) | | Paragraph Styles | 2 | 0 | 2 | [Open](/document-api/reference/styles/paragraph/index) | | Tables | 39 | 0 | 39 | [Open](/document-api/reference/tables/index) | +| History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | | Table of Contents | 5 | 0 | 5 | [Open](/document-api/reference/toc/index) | ## Available operations @@ -272,6 +273,14 @@ The tables below are grouped by namespace. | tables.getCells | editor.doc.tables.getCells(...) | Retrieve cell information for a table, optionally filtered by row or column. | | tables.getProperties | editor.doc.tables.getProperties(...) | Retrieve layout and style properties of a table. | +#### History + +| Operation | API member path | Description | +| --- | --- | --- | +| history.get | editor.doc.history.get(...) | Query the current undo/redo history state of the active editor. | +| history.undo | editor.doc.history.undo(...) | Undo the most recent history-safe mutation in the active editor. | +| history.redo | editor.doc.history.redo(...) | Redo the most recently undone action in the active editor. | + #### Table of Contents | Operation | API member path | Description | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index c290694e19..455d98abfe 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -301,6 +301,14 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.trackChanges.get` | `track-changes get` | Retrieve a single tracked change by ID. | | `doc.trackChanges.decide` | `track-changes decide` | Accept or reject a tracked change (by ID or scope: all). | +#### History + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.history.get` | `history get` | Query the current undo/redo history state of the active editor. | +| `doc.history.undo` | `history undo` | Undo the most recent history-safe mutation in the active editor. | +| `doc.history.redo` | `history redo` | Redo the most recently undone action in the active editor. | + #### Session | Operation | CLI command | Description | diff --git a/apps/docs/scripts/generate-sdk-overview.ts b/apps/docs/scripts/generate-sdk-overview.ts index 872b701e07..3dd8468251 100644 --- a/apps/docs/scripts/generate-sdk-overview.ts +++ b/apps/docs/scripts/generate-sdk-overview.ts @@ -72,6 +72,7 @@ const CATEGORY_ORDER = [ 'lists', 'comments', 'trackChanges', + 'history', 'session', 'introspection', ] as const; @@ -86,6 +87,7 @@ const CATEGORY_LABELS: Record = { lists: 'Lists', comments: 'Comments', trackChanges: 'Track changes', + history: 'History', session: 'Session', introspection: 'Introspection', }; diff --git a/packages/document-api/src/capabilities/capabilities.ts b/packages/document-api/src/capabilities/capabilities.ts index 50b8be5196..ad0fae9533 100644 --- a/packages/document-api/src/capabilities/capabilities.ts +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -79,6 +79,7 @@ export interface DocumentApiCapabilities { comments: CapabilityFlag; lists: CapabilityFlag; dryRun: CapabilityFlag; + history: CapabilityFlag; }; /** Format capability discovery for `format.apply`. */ format: FormatCapabilities; diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 7f303a97ed..cde528e653 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -109,6 +109,21 @@ describe('document-api contract catalog', () => { expect(insertFailureSchema.properties?.failure?.properties?.code?.enum).toContain('UNSUPPORTED_ENVIRONMENT'); }); + it('includes global.history in capabilities.get output schema', () => { + const schemas = buildInternalContractSchemas(); + const capabilitiesOutput = schemas.operations['capabilities.get'].output as { + properties?: { + global?: { + properties?: Record; + required?: string[]; + }; + }; + }; + + expect(capabilitiesOutput.properties?.global?.properties).toHaveProperty('history'); + expect(capabilitiesOutput.properties?.global?.required).toContain('history'); + }); + it('derives OPERATION_IDS from OPERATION_DEFINITIONS keys', () => { const definitionKeys = Object.keys(OPERATION_DEFINITIONS).sort(); const operationIds = [...OPERATION_IDS].sort(); @@ -132,6 +147,7 @@ describe('document-api contract catalog', () => { 'query', 'mutations', 'tables', + 'history', 'toc', ]; for (const id of OPERATION_IDS) { @@ -177,4 +193,26 @@ describe('document-api contract catalog', () => { expect(expectedResult.length, `${id} expectedResult is too short`).toBeGreaterThan(10); } }); + + it('marks exactly the out-of-band mutation operations as historyUnsafe', () => { + const historyUnsafeOps = OPERATION_IDS.filter((id) => COMMAND_CATALOG[id].historyUnsafe === true).sort(); + + // styles.apply + all sections.set* / sections.clear* mutations + expect(historyUnsafeOps).toContain('styles.apply'); + for (const id of historyUnsafeOps) { + expect(id.startsWith('sections.') || id === 'styles.apply', `unexpected historyUnsafe: ${id}`).toBe(true); + } + + // All section mutations (set*/clear*) should be marked + const sectionMutations = OPERATION_IDS.filter((id) => id.startsWith('sections.') && COMMAND_CATALOG[id].mutates); + for (const id of sectionMutations) { + expect(COMMAND_CATALOG[id].historyUnsafe, `${id} should be historyUnsafe`).toBe(true); + } + + // Non-mutating and non-out-of-band operations should NOT be historyUnsafe + for (const id of OPERATION_IDS) { + if (!COMMAND_CATALOG[id].mutates || historyUnsafeOps.includes(id)) continue; + expect(COMMAND_CATALOG[id].historyUnsafe, `${id} should not be historyUnsafe`).toBeFalsy(); + } + }); }); diff --git a/packages/document-api/src/contract/metadata-types.ts b/packages/document-api/src/contract/metadata-types.ts index ecc7a9069c..ed2f69b454 100644 --- a/packages/document-api/src/contract/metadata-types.ts +++ b/packages/document-api/src/contract/metadata-types.ts @@ -48,4 +48,6 @@ export interface CommandStaticMetadata { throws: CommandThrowPolicy; deterministicTargetResolution: boolean; remediationHints?: readonly string[]; + /** When true, this operation bypasses PM transaction history (out-of-band XML mutation). */ + historyUnsafe?: boolean; } diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 9857a446e0..558d5ba547 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -48,6 +48,7 @@ export type ReferenceGroupKey = | 'query' | 'mutations' | 'tables' + | 'history' | 'toc'; // --------------------------------------------------------------------------- @@ -102,6 +103,7 @@ function mutationOperation(options: { throws: readonly PreApplyThrowCode[]; deterministicTargetResolution?: boolean; remediationHints?: readonly string[]; + historyUnsafe?: boolean; }): CommandStaticMetadata { return { mutates: true, @@ -115,6 +117,7 @@ function mutationOperation(options: { }, deterministicTargetResolution: options.deterministicTargetResolution ?? true, remediationHints: options.remediationHints, + historyUnsafe: options.historyUnsafe, }; } @@ -365,6 +368,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: NONE_FAILURES, throws: ['INVALID_TARGET', 'INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'], + historyUnsafe: true, }), referenceDocPath: 'styles/apply.mdx', referenceGroup: 'styles', @@ -453,6 +457,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-break-type.mdx', referenceGroup: 'sections', @@ -469,6 +474,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-page-margins.mdx', referenceGroup: 'sections', @@ -485,6 +491,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-header-footer-margins.mdx', referenceGroup: 'sections', @@ -501,6 +508,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-page-setup.mdx', referenceGroup: 'sections', @@ -516,6 +524,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-columns.mdx', referenceGroup: 'sections', @@ -531,6 +540,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-line-numbering.mdx', referenceGroup: 'sections', @@ -546,6 +556,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-page-numbering.mdx', referenceGroup: 'sections', @@ -561,6 +572,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-title-page.mdx', referenceGroup: 'sections', @@ -576,6 +588,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_SETTINGS_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-odd-even-headers-footers.mdx', referenceGroup: 'sections', @@ -591,6 +604,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-vertical-align.mdx', referenceGroup: 'sections', @@ -606,6 +620,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-section-direction.mdx', referenceGroup: 'sections', @@ -622,6 +637,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-header-footer-ref.mdx', referenceGroup: 'sections', @@ -638,6 +654,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/clear-header-footer-ref.mdx', referenceGroup: 'sections', @@ -654,6 +671,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-link-to-previous.mdx', referenceGroup: 'sections', @@ -670,6 +688,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/set-page-borders.mdx', referenceGroup: 'sections', @@ -686,6 +705,7 @@ export const OPERATION_DEFINITIONS = { supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'], throws: T_SECTION_MUTATION, + historyUnsafe: true, }), referenceDocPath: 'sections/clear-page-borders.mdx', referenceGroup: 'sections', @@ -1910,7 +1930,6 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'tables/get-properties.mdx', referenceGroup: 'tables', }, - // ------------------------------------------------------------------------- // Create: table of contents // ------------------------------------------------------------------------- @@ -2004,6 +2023,57 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'toc/remove.mdx', referenceGroup: 'toc', }, + + // ------------------------------------------------------------------------- + // History + // ------------------------------------------------------------------------- + + 'history.get': { + memberPath: 'history.get', + description: 'Query the current undo/redo history state of the active editor.', + expectedResult: + 'Returns a HistoryState object with undoDepth, redoDepth, canUndo, canRedo, and a list of history-unsafe operations.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + }), + referenceDocPath: 'history/get.mdx', + referenceGroup: 'history', + }, + + 'history.undo': { + memberPath: 'history.undo', + description: 'Undo the most recent history-safe mutation in the active editor.', + expectedResult: + 'Returns a HistoryActionResult with noop flag and revision before/after; noop is true when the undo stack is empty.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: NONE_FAILURES, + throws: ['CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'history/undo.mdx', + referenceGroup: 'history', + }, + + 'history.redo': { + memberPath: 'history.redo', + description: 'Redo the most recently undone action in the active editor.', + expectedResult: + 'Returns a HistoryActionResult with noop flag and revision before/after; noop is true when the redo stack is empty.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: NONE_FAILURES, + throws: ['CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'history/redo.mdx', + referenceGroup: 'history', + }, } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 10c40429ef..9f4bad8462 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -40,6 +40,7 @@ import type { CommentInfo, CommentsListQuery, CommentsListResult } from '../comm import type { TrackChangesListInput, TrackChangesGetInput, ReviewDecideInput } from '../track-changes/track-changes.js'; import type { TrackChangeInfo, TrackChangesListResult } from '../types/track-changes.types.js'; import type { DocumentApiCapabilities } from '../capabilities/capabilities.js'; +import type { HistoryState, HistoryActionResult } from '../history/history.types.js'; import type { ListsListQuery, ListsListResult, @@ -411,6 +412,11 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { // --- capabilities --- 'capabilities.get': { input: undefined; options: never; output: DocumentApiCapabilities }; + // --- history.* --- + 'history.get': { input: undefined; options: never; output: HistoryState }; + 'history.undo': { input: undefined; options: never; output: HistoryActionResult }; + 'history.redo': { input: undefined; options: never; output: HistoryActionResult }; + // --- create.table --- 'create.table': { input: CreateTableInput; options: MutationOptions; output: CreateTableResult }; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index 21d3bc1a94..0c0286e80b 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -96,6 +96,11 @@ const GROUP_METADATA: Record = { ), }, + // --- history.* --- + 'history.get': { + input: strictEmptyObjectSchema, + output: objectSchema( + { + undoDepth: { type: 'integer', minimum: 0 }, + redoDepth: { type: 'integer', minimum: 0 }, + canUndo: { type: 'boolean' }, + canRedo: { type: 'boolean' }, + historyUnsafeOperations: { type: 'array', items: { type: 'string' } }, + }, + ['undoDepth', 'redoDepth', 'canUndo', 'canRedo', 'historyUnsafeOperations'], + ), + }, + 'history.undo': { + input: strictEmptyObjectSchema, + output: historyActionSuccessSchema, + success: historyActionSuccessSchema, + failure: historyActionFailureSchema, + }, + 'history.redo': { + input: strictEmptyObjectSchema, + output: historyActionSuccessSchema, + success: historyActionSuccessSchema, + failure: historyActionFailureSchema, + }, // ------------------------------------------------------------------------- // TOC schemas // ------------------------------------------------------------------------- diff --git a/packages/document-api/src/history/history.test.ts b/packages/document-api/src/history/history.test.ts new file mode 100644 index 0000000000..eb796437f6 --- /dev/null +++ b/packages/document-api/src/history/history.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { HistoryAdapter } from './history.js'; +import { executeHistoryGet, executeHistoryUndo, executeHistoryRedo } from './history.js'; +import type { HistoryState, HistoryActionResult } from './history.types.js'; + +function makeHistoryAdapter( + overrides?: Partial<{ + state: HistoryState; + undoResult: HistoryActionResult; + redoResult: HistoryActionResult; + }>, +): HistoryAdapter { + const state: HistoryState = overrides?.state ?? { + undoDepth: 3, + redoDepth: 1, + canUndo: true, + canRedo: true, + historyUnsafeOperations: ['styles.apply'], + }; + const undoResult: HistoryActionResult = overrides?.undoResult ?? { + noop: false, + revision: { before: '5', after: '6' }, + }; + const redoResult: HistoryActionResult = overrides?.redoResult ?? { + noop: false, + revision: { before: '6', after: '7' }, + }; + + return { + get: vi.fn(() => state), + undo: vi.fn(() => undoResult), + redo: vi.fn(() => redoResult), + }; +} + +describe('history execute functions', () => { + describe('executeHistoryGet', () => { + it('delegates to the adapter and returns HistoryState', () => { + const adapter = makeHistoryAdapter(); + const result = executeHistoryGet(adapter); + + expect(adapter.get).toHaveBeenCalledOnce(); + expect(result).toEqual({ + undoDepth: 3, + redoDepth: 1, + canUndo: true, + canRedo: true, + historyUnsafeOperations: ['styles.apply'], + }); + }); + + it('returns empty state when adapter reports no history', () => { + const adapter = makeHistoryAdapter({ + state: { + undoDepth: 0, + redoDepth: 0, + canUndo: false, + canRedo: false, + historyUnsafeOperations: [], + }, + }); + const result = executeHistoryGet(adapter); + + expect(result.canUndo).toBe(false); + expect(result.canRedo).toBe(false); + expect(result.undoDepth).toBe(0); + expect(result.redoDepth).toBe(0); + }); + }); + + describe('executeHistoryUndo', () => { + it('delegates to the adapter and returns HistoryActionResult', () => { + const adapter = makeHistoryAdapter(); + const result = executeHistoryUndo(adapter); + + expect(adapter.undo).toHaveBeenCalledOnce(); + expect(result).toEqual({ + noop: false, + revision: { before: '5', after: '6' }, + }); + }); + + it('returns noop when undo stack is empty', () => { + const adapter = makeHistoryAdapter({ + undoResult: { noop: true, revision: { before: '3', after: '3' } }, + }); + const result = executeHistoryUndo(adapter); + + expect(result.noop).toBe(true); + expect(result.revision.before).toBe(result.revision.after); + }); + }); + + describe('executeHistoryRedo', () => { + it('delegates to the adapter and returns HistoryActionResult', () => { + const adapter = makeHistoryAdapter(); + const result = executeHistoryRedo(adapter); + + expect(adapter.redo).toHaveBeenCalledOnce(); + expect(result).toEqual({ + noop: false, + revision: { before: '6', after: '7' }, + }); + }); + + it('returns noop when redo stack is empty', () => { + const adapter = makeHistoryAdapter({ + redoResult: { noop: true, revision: { before: '3', after: '3' } }, + }); + const result = executeHistoryRedo(adapter); + + expect(result.noop).toBe(true); + expect(result.revision.before).toBe(result.revision.after); + }); + }); +}); diff --git a/packages/document-api/src/history/history.ts b/packages/document-api/src/history/history.ts new file mode 100644 index 0000000000..64c3a48d6a --- /dev/null +++ b/packages/document-api/src/history/history.ts @@ -0,0 +1,49 @@ +import type { HistoryState, HistoryActionResult } from './history.types.js'; + +/** + * Engine-specific adapter for history operations. + */ +export interface HistoryAdapter { + get(): HistoryState; + undo(): HistoryActionResult; + redo(): HistoryActionResult; +} + +/** + * Public API shape for the history namespace on DocumentApi. + */ +export interface HistoryApi { + get(): HistoryState; + undo(): HistoryActionResult; + redo(): HistoryActionResult; +} + +/** + * Reads undo/redo history state from the underlying adapter. + * + * @param adapter - Engine-specific history adapter. + * @returns Current history state snapshot. + */ +export function executeHistoryGet(adapter: HistoryAdapter): HistoryState { + return adapter.get(); +} + +/** + * Executes an undo action via the underlying adapter. + * + * @param adapter - Engine-specific history adapter. + * @returns Undo action result with noop + revision metadata. + */ +export function executeHistoryUndo(adapter: HistoryAdapter): HistoryActionResult { + return adapter.undo(); +} + +/** + * Executes a redo action via the underlying adapter. + * + * @param adapter - Engine-specific history adapter. + * @returns Redo action result with noop + revision metadata. + */ +export function executeHistoryRedo(adapter: HistoryAdapter): HistoryActionResult { + return adapter.redo(); +} diff --git a/packages/document-api/src/history/history.types.ts b/packages/document-api/src/history/history.types.ts new file mode 100644 index 0000000000..6320242cdb --- /dev/null +++ b/packages/document-api/src/history/history.types.ts @@ -0,0 +1,34 @@ +import type { OperationId } from '../contract/types.js'; + +/** + * Snapshot of the editor's undo/redo history state. + */ +export interface HistoryState { + /** Number of undo steps available. */ + undoDepth: number; + /** Number of redo steps available. */ + redoDepth: number; + /** Whether undo is possible (shorthand for undoDepth > 0). */ + canUndo: boolean; + /** Whether redo is possible (shorthand for redoDepth > 0). */ + canRedo: boolean; + /** + * Operation IDs that bypass PM history (out-of-band mutations). + * Their effects cannot be undone via history.undo. + */ + historyUnsafeOperations: readonly OperationId[]; +} + +/** + * Result of a history.undo or history.redo action. + * Mirrors PlanReceipt's revision shape for consistency. + */ +export interface HistoryActionResult { + /** True if the action had no effect (empty stack). */ + noop: boolean; + /** Revision bookends matching PlanReceipt.revision shape. */ + revision: { + before: string; + after: string; + }; +} diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index f092b204d2..238c1f4cbb 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -16,6 +16,7 @@ import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comme import type { CreateAdapter } from './create/create.js'; import type { ListsAdapter } from './lists/lists.js'; import type { CapabilitiesAdapter, DocumentApiCapabilities } from './capabilities/capabilities.js'; +import type { HistoryAdapter } from './history/history.js'; import type { TablesAdapter } from './index.js'; function makeFindAdapter(result: FindOutput): FindAdapter { @@ -261,6 +262,20 @@ function makeTablesAdapter(): TablesAdapter { }; } +function makeHistoryAdapter(): HistoryAdapter { + return { + get: vi.fn(() => ({ + undoDepth: 0, + redoDepth: 0, + canUndo: false, + canRedo: false, + historyUnsafeOperations: [], + })), + undo: vi.fn(() => ({ noop: true, revision: { before: '0', after: '0' } })), + redo: vi.fn(() => ({ noop: true, revision: { before: '0', after: '0' } })), + }; +} + function makeCapabilitiesAdapter(overrides?: Partial): CapabilitiesAdapter { const defaultCapabilities: DocumentApiCapabilities = { global: { @@ -268,6 +283,7 @@ function makeCapabilitiesAdapter(overrides?: Partial): comments: { enabled: false }, lists: { enabled: false }, dryRun: { enabled: false }, + history: { enabled: false }, }, format: { supportedInlineProperties: {} as DocumentApiCapabilities['format']['supportedInlineProperties'] }, operations: {} as DocumentApiCapabilities['operations'], @@ -731,6 +747,59 @@ describe('createDocumentApi', () => { expect(trackAdpt.rejectAll).toHaveBeenCalledWith({}, undefined); }); + it('delegates history.get to the history adapter', () => { + const historyAdpt = makeHistoryAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + history: historyAdpt, + } as any); + + const result = api.history.get(); + + expect(historyAdpt.get).toHaveBeenCalledOnce(); + expect(result).toEqual({ + undoDepth: 0, + redoDepth: 0, + canUndo: false, + canRedo: false, + historyUnsafeOperations: [], + }); + }); + + it('delegates history.undo and history.redo to the history adapter', () => { + const historyAdpt = makeHistoryAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + history: historyAdpt, + } as any); + + const undoResult = api.history.undo(); + const redoResult = api.history.redo(); + + expect(historyAdpt.undo).toHaveBeenCalledOnce(); + expect(historyAdpt.redo).toHaveBeenCalledOnce(); + expect(undoResult).toEqual({ noop: true, revision: { before: '0', after: '0' } }); + expect(redoResult).toEqual({ noop: true, revision: { before: '0', after: '0' } }); + }); + describe('trackChanges.decide input validation', () => { function makeApi() { return createDocumentApi({ diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index cbc54b69de..9b4bbbd619 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -6,6 +6,8 @@ export * from './types/index.js'; export * from './contract/index.js'; export * from './capabilities/capabilities.js'; export * from './inline-semantics/index.js'; +export type { HistoryAdapter, HistoryApi } from './history/history.js'; +export type { HistoryState, HistoryActionResult } from './history/history.types.js'; import type { CreateParagraphInput, @@ -173,6 +175,9 @@ import { import type { OperationId } from './contract/types.js'; import type { DynamicInvokeRequest, InvokeRequest, InvokeResult } from './contract/operation-registry.js'; import { buildDispatchTable } from './invoke/invoke.js'; +import type { HistoryAdapter, HistoryApi } from './history/history.js'; +import type { HistoryState, HistoryActionResult } from './history/history.types.js'; +import { executeHistoryGet, executeHistoryUndo, executeHistoryRedo } from './history/history.js'; import { executeTableOperation } from './tables/tables.js'; import type { ParagraphsAdapter, @@ -683,6 +688,11 @@ export interface DocumentApi { * Mutation plan engine — preview and apply atomic mutation plans. */ mutations: MutationsApi; + /** + * History operations (undo/redo) scoped to the active editor instance. + * Session-scoped — reflects the runtime undo/redo stack, not persistent state. + */ + history: HistoryApi; /** * Runtime capability introspection. * @@ -724,6 +734,7 @@ export interface DocumentApiAdapters { toc: TocAdapter; query: QueryAdapter; mutations: MutationsAdapter; + history: HistoryAdapter; } /** @@ -1304,6 +1315,17 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return adapters.mutations.apply(input); }, }, + history: { + get(): HistoryState { + return executeHistoryGet(adapters.history); + }, + undo(): HistoryActionResult { + return executeHistoryUndo(adapters.history); + }, + redo(): HistoryActionResult { + return executeHistoryRedo(adapters.history); + }, + }, invoke(request: DynamicInvokeRequest): unknown { if (!Object.prototype.hasOwnProperty.call(dispatch, request.operationId)) { throw new Error(`Unknown operationId: "${request.operationId}"`); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 6b7c771922..8c183030eb 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -160,6 +160,11 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { // --- capabilities --- 'capabilities.get': () => api.capabilities(), + // --- history.* --- + 'history.get': () => api.history.get(), + 'history.undo': () => api.history.undo(), + 'history.redo': () => api.history.redo(), + // --- create.table --- 'create.table': (input, options) => api.create.table(input, options), diff --git a/packages/sdk/scripts/sdk-validate.mjs b/packages/sdk/scripts/sdk-validate.mjs index 8b89c23fe3..a1a99b013d 100644 --- a/packages/sdk/scripts/sdk-validate.mjs +++ b/packages/sdk/scripts/sdk-validate.mjs @@ -5,22 +5,23 @@ * * Checks: * 1. CLI export contract is current (--check) - * 2. Contract JSON loads and has required structure - * 3. All operations have outputSchema - * 4. Node SDK typechecks (tsc --noEmit) - * 5. Python SDK imports successfully - * 6. Tool catalog operation count matches contract - * 7. Tool name map covers all operations - * 8. Provider bundles are consistent - * 9. Node/Python parity — both generated clients expose same operations - * 10. Catalog input schemas present and required params match contract - * 11. Skill files only reference existing operations (fails on unknown refs) - * 12. Provider tool name extraction smoke test - * 13. Node npm pack includes required tools/*.json, skills/*.md, and CJS artifacts - * 14. SDK release scripts test suite passes - * 15. SDK test suite passes (contract-integrity + cross-lang parity) - * 16. Node SDK platform package manifests exist and are well-formed - * 17. Node SDK optionalDependencies reference all expected platform packages + * 2. SDK/codegen artifacts are regenerated from current contract + * 3. Contract JSON loads and has required structure + * 4. All operations have outputSchema + * 5. Node SDK typechecks (tsc --noEmit) + * 6. Python SDK imports successfully + * 7. Tool catalog operation count matches contract + * 8. Tool name map covers all operations + * 9. Provider bundles are consistent + * 10. Node/Python parity — both generated clients expose same operations + * 11. Catalog input schemas present and required params match contract + * 12. Skill files only reference existing operations (fails on unknown refs) + * 13. Provider tool name extraction smoke test + * 14. Node npm pack includes required tools/*.json, skills/*.md, and CJS artifacts + * 15. SDK release scripts test suite passes + * 16. SDK test suite passes (contract-integrity + cross-lang parity) + * 17. Node SDK platform package manifests exist and are well-formed + * 18. Node SDK optionalDependencies reference all expected platform packages */ import { execFile } from 'node:child_process'; @@ -70,7 +71,12 @@ async function main() { ]); }); - // 2. Load contract and verify structure + // 2. Regenerate SDK artifacts from current contract + await check('SDK/codegen artifacts are current', async () => { + await run('node', [path.join(REPO_ROOT, 'packages/sdk/codegen/src/generate-all.mjs')]); + }); + + // 3. Load contract and verify structure const contractPath = path.join(REPO_ROOT, 'apps/cli/generated/sdk-contract.json'); let contract; await check('Contract JSON loads and has operations', async () => { @@ -82,21 +88,21 @@ async function main() { if (!contract.protocol) throw new Error('Missing protocol metadata'); }); - // 3. All operations have outputSchema + // 4. All operations have outputSchema await check('All operations have outputSchema', async () => { for (const [id, op] of Object.entries(contract.operations)) { if (!op.outputSchema) throw new Error(`${id} missing outputSchema`); } }); - // 4. Node SDK typecheck + // 5. Node SDK typecheck await check('Node SDK typechecks (tsc --noEmit)', async () => { await run('npx', ['tsc', '--noEmit'], { cwd: path.join(REPO_ROOT, 'packages/sdk/langs/node'), }); }); - // 5. Python SDK imports + // 6. Python SDK imports await check('Python SDK imports successfully', async () => { await run('python3', [ '-c', @@ -106,7 +112,7 @@ async function main() { }); }); - // 6. Tool catalog integrity + // 7. Tool catalog integrity await check('Tool catalog operation count matches contract', async () => { const catalog = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/catalog.json')); const contractOpCount = Object.keys(contract.operations).length; @@ -121,7 +127,7 @@ async function main() { } }); - // 7. Tool name map covers all operations + // 8. Tool name map covers all operations await check('Tool name map covers all operations', async () => { const nameMap = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/tool-name-map.json')); const contractOps = new Set(Object.keys(contract.operations)); @@ -134,7 +140,7 @@ async function main() { } }); - // 8. Provider bundles exist and have correct profile counts + // 9. Provider bundles exist and have correct profile counts await check('Provider bundles are consistent', async () => { const providers = ['openai', 'anthropic', 'vercel', 'generic']; const contractOpCount = Object.keys(contract.operations).length; @@ -153,7 +159,7 @@ async function main() { } }); - // 9. Node/Python parity — generated clients expose same operations + // 10. Node/Python parity — generated clients expose same operations await check('Node/Python generated clients have matching operation counts', async () => { const nodeContract = await readFile( path.join(REPO_ROOT, 'packages/sdk/langs/node/src/generated/contract.ts'), @@ -177,7 +183,7 @@ async function main() { } }); - // 10. All catalog tools have input schemas and required params match contract + // 11. All catalog tools have input schemas and required params match contract await check('Catalog input schemas present and required params match contract', async () => { const catalog = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/catalog.json')); @@ -210,7 +216,7 @@ async function main() { } }); - // 11. Skill files only reference existing operations + // 12. Skill files only reference existing operations await check('Skill files reference valid operations', async () => { const skillDirs = [ path.join(REPO_ROOT, 'packages/sdk/langs/node/skills'), @@ -248,7 +254,7 @@ async function main() { } }); - // 12. Provider tool name extraction smoke test + // 13. Provider tool name extraction smoke test await check('OpenAI/Vercel tools have extractable names', async () => { const openaiBundle = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/tools.openai.json')); const nameMap = await readJson(path.join(REPO_ROOT, 'packages/sdk/tools/tool-name-map.json')); @@ -264,7 +270,7 @@ async function main() { } }); - // 13. Node package tarball includes required tools/*.json, skills/*.md, and CJS artifacts + // 14. Node package tarball includes required tools/*.json, skills/*.md, and CJS artifacts await check('Node npm pack includes tools/*.json, skills/*.md, and CJS artifacts', async () => { const npmCacheDir = path.join(REPO_ROOT, '.cache', 'npm'); const { stdout } = await execFileAsync('npm', ['pack', '--dry-run', '--json'], { @@ -301,17 +307,17 @@ async function main() { } }); - // 14. Run SDK release script tests + // 15. Run SDK release script tests await check('SDK release scripts tests pass', async () => { await run('pnpm', ['--prefix', path.join(REPO_ROOT, 'packages/sdk'), 'run', 'test:scripts']); }); - // 15. Run SDK codegen test suite (contract-integrity + cross-lang parity) + // 16. Run SDK codegen test suite (contract-integrity + cross-lang parity) await check('SDK test suite passes (bun test)', async () => { await run('bun', ['test', path.join(REPO_ROOT, 'packages/sdk/codegen/src/__tests__/')]); }); - // 16. Node SDK platform package manifests exist and are well-formed + // 17. Node SDK platform package manifests exist and are well-formed const EXPECTED_NODE_PLATFORMS = [ { name: '@superdoc-dev/sdk-darwin-arm64', dir: 'sdk-darwin-arm64', os: 'darwin', cpu: 'arm64' }, { name: '@superdoc-dev/sdk-darwin-x64', dir: 'sdk-darwin-x64', os: 'darwin', cpu: 'x64' }, @@ -339,7 +345,7 @@ async function main() { } }); - // 17. Node SDK optionalDependencies reference all expected platform packages + // 18. Node SDK optionalDependencies reference all expected platform packages await check('Node SDK optionalDependencies reference all platform packages', async () => { const nodePkg = await readJson(path.join(REPO_ROOT, 'packages/sdk/langs/node/package.json')); const optDeps = nodePkg.optionalDependencies ?? {}; diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 30f68ccbd6..5b05e5a55e 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -1189,6 +1189,7 @@ const STUB_TABLE_OPS: ReadonlySet = new Set([] as OperationId[]); * pattern. mutations.apply returns PlanReceipt (always success: true) or throws. */ const PLAN_ENGINE_META_OPS: ReadonlySet = new Set(['mutations.apply'] as OperationId[]); +const NON_RECEIPT_MUTATION_OPS: ReadonlySet = new Set(['history.undo', 'history.redo'] as OperationId[]); const HAS_STRUCTURED_FAILURE_RESULT = (operationId: OperationId): boolean => COMMAND_CATALOG[operationId].possibleFailureCodes.length > 0; @@ -4351,9 +4352,11 @@ describe('document-api adapter conformance', () => { if (!COMMAND_CATALOG[operationId].mutates) continue; expect(COMMAND_CATALOG[operationId].throws.postApplyForbidden).toBe(true); - expect(schema.success).toBeDefined(); + if (!NON_RECEIPT_MUTATION_OPS.has(operationId)) { + expect(schema.success).toBeDefined(); + } // Plan-engine meta-ops (mutations.apply) return PlanReceipt (always success) or throw — no failure schema. - if (!PLAN_ENGINE_META_OPS.has(operationId)) { + if (!PLAN_ENGINE_META_OPS.has(operationId) && !NON_RECEIPT_MUTATION_OPS.has(operationId)) { expect(schema.failure).toBeDefined(); } } @@ -4362,7 +4365,7 @@ describe('document-api adapter conformance', () => { it('covers every implemented mutating operation with throw/failure/apply vectors', () => { const vectorKeys = Object.keys(mutationVectors).sort(); const expectedKeys = [...MUTATING_OPERATION_IDS] - .filter((id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id)) + .filter((id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id) && !NON_RECEIPT_MUTATION_OPS.has(id)) .sort(); expect(vectorKeys).toEqual(expectedKeys); @@ -4396,7 +4399,7 @@ describe('document-api adapter conformance', () => { it('enforces pre-apply throw behavior for every mutating operation', () => { const implementedMutatingOps = MUTATING_OPERATION_IDS.filter( - (id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id), + (id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id) && !NON_RECEIPT_MUTATION_OPS.has(id), ); for (const operationId of implementedMutatingOps) { const vector = mutationVectors[operationId]; @@ -4407,7 +4410,11 @@ describe('document-api adapter conformance', () => { it('enforces structured non-applied outcomes for every mutating operation', () => { const implementedMutatingOps = MUTATING_OPERATION_IDS.filter( - (id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id) && HAS_STRUCTURED_FAILURE_RESULT(id), + (id) => + !STUB_TABLE_OPS.has(id) && + !PLAN_ENGINE_META_OPS.has(id) && + !NON_RECEIPT_MUTATION_OPS.has(id) && + HAS_STRUCTURED_FAILURE_RESULT(id), ); for (const operationId of implementedMutatingOps) { const vector = mutationVectors[operationId]; @@ -4423,7 +4430,7 @@ describe('document-api adapter conformance', () => { it('enforces no post-apply throws across every mutating operation', () => { const implementedMutatingOps = MUTATING_OPERATION_IDS.filter( - (id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id), + (id) => !STUB_TABLE_OPS.has(id) && !PLAN_ENGINE_META_OPS.has(id) && !NON_RECEIPT_MUTATION_OPS.has(id), ); for (const operationId of implementedMutatingOps) { const vector = mutationVectors[operationId]!; diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index d29e18ff6f..6b3a769835 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -115,6 +115,7 @@ import { tablesClearCellSpacingWrapper, } from './plan-engine/tables-wrappers.js'; import { tablesGetAdapter, tablesGetCellsAdapter, tablesGetPropertiesAdapter } from './tables-adapter.js'; +import { createHistoryAdapter } from './history-adapter.js'; import { tocListWrapper, tocGetWrapper, @@ -287,5 +288,6 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters preview: (input) => previewPlan(editor, input), apply: (input) => executePlan(editor, input), }, + history: createHistoryAdapter(editor), }; } diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index 4086065e68..0636875586 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -102,12 +102,34 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.global.comments.enabled).toBe(false); expect(capabilities.global.lists.enabled).toBe(false); expect(capabilities.global.trackChanges.enabled).toBe(false); + expect(capabilities.global.history.enabled).toBe(false); expect(capabilities.operations['comments.create'].available).toBe(false); expect(capabilities.operations['lists.setType'].available).toBe(false); expect(capabilities.operations.insert.tracked).toBe(false); expect(capabilities.operations['format.apply'].available).toBe(false); }); + it('reports history namespace enabled only when undo/redo commands are both present', () => { + const fullCapabilities = getDocumentApiCapabilities( + makeEditor({ + commands: { + undo: vi.fn(() => true), + redo: vi.fn(() => true), + } as unknown as Editor['commands'], + }), + ); + expect(fullCapabilities.global.history.enabled).toBe(true); + + const missingRedoCapabilities = getDocumentApiCapabilities( + makeEditor({ + commands: { + redo: undefined, + } as unknown as Editor['commands'], + }), + ); + expect(missingRedoCapabilities.global.history.enabled).toBe(false); + }); + it('exposes tracked + dryRun flags in line with command catalog capabilities', () => { const capabilities = getDocumentApiCapabilities(makeEditor()); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 12f5ad1ebd..367f29c61a 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -48,6 +48,8 @@ const REQUIRED_COMMANDS: Partial hasAllCommands(editor, id)); } +function isHistoryNamespaceEnabled(editor: Editor): boolean { + return hasCommand(editor, 'undo') && hasCommand(editor, 'redo'); +} + function isTrackChangesEnabled(editor: Editor): boolean { return ( hasCommand(editor, 'insertTrackedChange') && @@ -423,6 +429,7 @@ export function getDocumentApiCapabilities(editor: Editor): DocumentApiCapabilit const commentsEnabled = isCommentsNamespaceEnabled(editor); const listsEnabled = isListsNamespaceEnabled(editor); const trackChangesEnabled = isTrackChangesEnabled(editor); + const historyEnabled = isHistoryNamespaceEnabled(editor); const dryRunEnabled = OPERATION_IDS.some((operationId) => operations[operationId].dryRun); return { @@ -443,6 +450,10 @@ export function getDocumentApiCapabilities(editor: Editor): DocumentApiCapabilit enabled: dryRunEnabled, reasons: dryRunEnabled ? undefined : ['DRY_RUN_UNAVAILABLE'], }, + history: { + enabled: historyEnabled, + reasons: getNamespaceReason(historyEnabled), + }, }, format: buildFormatCapabilities(editor), operations, diff --git a/packages/super-editor/src/document-api-adapters/history-adapter.test.ts b/packages/super-editor/src/document-api-adapters/history-adapter.test.ts new file mode 100644 index 0000000000..3ff73d3df5 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/history-adapter.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Editor } from '../core/Editor.js'; +import { createHistoryAdapter } from './history-adapter.js'; + +const { undoDepthMock, redoDepthMock, yGetStateMock } = vi.hoisted(() => ({ + undoDepthMock: vi.fn(() => 0), + redoDepthMock: vi.fn(() => 0), + yGetStateMock: vi.fn(() => undefined), +})); + +vi.mock('prosemirror-history', () => ({ + undoDepth: undoDepthMock, + redoDepth: redoDepthMock, +})); + +vi.mock('y-prosemirror', () => ({ + yUndoPluginKey: { + getState: yGetStateMock, + }, +})); + +function makeEditor(overrides: Partial = {}): Editor { + return { + options: {}, + state: { tr: {} } as Editor['state'], + commands: { + undo: vi.fn(() => true), + redo: vi.fn(() => true), + } as unknown as Editor['commands'], + ...overrides, + } as unknown as Editor; +} + +describe('createHistoryAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + undoDepthMock.mockReturnValue(0); + redoDepthMock.mockReturnValue(0); + yGetStateMock.mockReturnValue(undefined); + }); + + it('reads undo/redo depth from PM history in non-collab mode', () => { + undoDepthMock.mockReturnValue(2); + redoDepthMock.mockReturnValue(1); + + const adapter = createHistoryAdapter(makeEditor()); + const result = adapter.get(); + + expect(undoDepthMock).toHaveBeenCalledOnce(); + expect(redoDepthMock).toHaveBeenCalledOnce(); + expect(result.undoDepth).toBe(2); + expect(result.redoDepth).toBe(1); + expect(result.canUndo).toBe(true); + expect(result.canRedo).toBe(true); + expect(result.historyUnsafeOperations).toContain('styles.apply'); + }); + + it('reads undo/redo depth from yUndoPlugin in collab mode', () => { + yGetStateMock.mockReturnValue({ + undoManager: { + undoStack: [1, 2, 3], + redoStack: [1], + }, + }); + + const adapter = createHistoryAdapter( + makeEditor({ + options: { + collaborationProvider: {}, + ydoc: {}, + } as Editor['options'], + }), + ); + + const result = adapter.get(); + + expect(yGetStateMock).toHaveBeenCalledTimes(2); + expect(result.undoDepth).toBe(3); + expect(result.redoDepth).toBe(1); + expect(result.canUndo).toBe(true); + expect(result.canRedo).toBe(true); + }); + + it('throws CAPABILITY_UNAVAILABLE when undo command is missing', () => { + const adapter = createHistoryAdapter( + makeEditor({ + commands: { + undo: undefined, + redo: vi.fn(() => true), + } as unknown as Editor['commands'], + }), + ); + + try { + adapter.undo(); + expect.fail('Expected undo to throw'); + } catch (error: unknown) { + expect(error).toMatchObject({ name: 'DocumentApiAdapterError', code: 'CAPABILITY_UNAVAILABLE' }); + } + }); + + it('throws CAPABILITY_UNAVAILABLE when redo command is missing', () => { + const adapter = createHistoryAdapter( + makeEditor({ + commands: { + undo: vi.fn(() => true), + redo: undefined, + } as unknown as Editor['commands'], + }), + ); + + try { + adapter.redo(); + expect.fail('Expected redo to throw'); + } catch (error: unknown) { + expect(error).toMatchObject({ name: 'DocumentApiAdapterError', code: 'CAPABILITY_UNAVAILABLE' }); + } + }); + + it('returns noop=false when undo/redo commands succeed', () => { + const adapter = createHistoryAdapter(makeEditor()); + + const undoResult = adapter.undo(); + const redoResult = adapter.redo(); + + expect(undoResult.noop).toBe(false); + expect(redoResult.noop).toBe(false); + expect(undoResult.revision.before).toBeDefined(); + expect(undoResult.revision.after).toBeDefined(); + expect(redoResult.revision.before).toBeDefined(); + expect(redoResult.revision.after).toBeDefined(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/history-adapter.ts b/packages/super-editor/src/document-api-adapters/history-adapter.ts new file mode 100644 index 0000000000..58d7c2e19f --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/history-adapter.ts @@ -0,0 +1,88 @@ +import { undoDepth, redoDepth } from 'prosemirror-history'; +import { yUndoPluginKey } from 'y-prosemirror'; +import type { HistoryAdapter, HistoryState, HistoryActionResult, OperationId } from '@superdoc/document-api'; +import { OPERATION_IDS, COMMAND_CATALOG } from '@superdoc/document-api'; +import type { Editor } from '../core/Editor.js'; +import { getRevision } from './plan-engine/revision-tracker.js'; +import { DocumentApiAdapterError } from './errors.js'; + +function isCollabHistory(editor: Editor): boolean { + return Boolean(editor.options.collaborationProvider && editor.options.ydoc); +} + +function getUndoDepth(editor: Editor): number { + if (!editor.state) return 0; + try { + if (isCollabHistory(editor)) { + const undoManager = yUndoPluginKey.getState(editor.state)?.undoManager; + return undoManager?.undoStack?.length ?? 0; + } + return undoDepth(editor.state); + } catch { + return 0; + } +} + +function getRedoDepth(editor: Editor): number { + if (!editor.state) return 0; + try { + if (isCollabHistory(editor)) { + const undoManager = yUndoPluginKey.getState(editor.state)?.undoManager; + return undoManager?.redoStack?.length ?? 0; + } + return redoDepth(editor.state); + } catch { + return 0; + } +} + +/** Cached list of history-unsafe operation IDs, computed once from the catalog. */ +const HISTORY_UNSAFE_OPS: readonly OperationId[] = OPERATION_IDS.filter( + (id) => COMMAND_CATALOG[id].historyUnsafe === true, +); + +export function createHistoryAdapter(editor: Editor): HistoryAdapter { + return { + get(): HistoryState { + const ud = getUndoDepth(editor); + const rd = getRedoDepth(editor); + return { + undoDepth: ud, + redoDepth: rd, + canUndo: ud > 0, + canRedo: rd > 0, + historyUnsafeOperations: HISTORY_UNSAFE_OPS, + }; + }, + + undo(): HistoryActionResult { + if (typeof editor.commands?.undo !== 'function') { + throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', 'history.undo command is not available.', { + reason: 'missing_command', + }); + } + const revBefore = getRevision(editor); + const success = Boolean(editor.commands.undo()); + const revAfter = getRevision(editor); + return { + noop: !success, + revision: { before: revBefore, after: revAfter }, + }; + }, + + redo(): HistoryActionResult { + if (typeof editor.commands?.redo !== 'function') { + throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', 'history.redo command is not available.', { + reason: 'missing_command', + }); + } + const revBefore = getRevision(editor); + const success = Boolean(editor.commands.redo()); + const revAfter = getRevision(editor); + return { + noop: !success, + revision: { before: revBefore, after: revAfter }, + }; + }, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts index 1727306412..7a51afa333 100644 --- a/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts +++ b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts @@ -1,6 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { executeOutOfBandMutation, type OutOfBandMutationResult } from './out-of-band-mutation.js'; +const { closeHistoryMock, stopCapturingMock, yGetStateMock } = vi.hoisted(() => ({ + closeHistoryMock: vi.fn((tr) => tr), + stopCapturingMock: vi.fn(), + yGetStateMock: vi.fn(() => undefined), +})); + +vi.mock('prosemirror-history', () => ({ + closeHistory: closeHistoryMock, +})); + +vi.mock('y-prosemirror', () => ({ + yUndoPluginKey: { + getState: yGetStateMock, + }, +})); + // --------------------------------------------------------------------------- // Mock editor with revision tracking support // --------------------------------------------------------------------------- @@ -16,6 +32,8 @@ function createMockEditor(opts: { initialRevision?: number; guid?: string | null const editor = { converter, options: {}, + state: { tr: { id: 'tx' } }, + view: { dispatch: vi.fn() }, on: vi.fn(), _revision: opts.initialRevision ?? 0, }; @@ -47,6 +65,7 @@ vi.mock('./plan-engine/revision-tracker.js', () => { describe('executeOutOfBandMutation', () => { beforeEach(() => { vi.clearAllMocks(); + yGetStateMock.mockReturnValue(undefined); }); it('runs revision guard before mutateFn', () => { @@ -132,4 +151,51 @@ describe('executeOutOfBandMutation', () => { expect(result).toEqual({ receipt: 'data' }); }); + + it('closes PM history group before non-collab out-of-band mutation', () => { + const editor = createMockEditor(); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: false, payload: 'ok' })); + + executeOutOfBandMutation(editor as never, mutateFn, { + dryRun: false, + expectedRevision: undefined, + }); + + expect(closeHistoryMock).toHaveBeenCalledWith(editor.state.tr); + expect(editor.view.dispatch).toHaveBeenCalled(); + expect(stopCapturingMock).not.toHaveBeenCalled(); + }); + + it('stops yjs capture before collab out-of-band mutation', () => { + const editor = createMockEditor(); + editor.options = { collaborationProvider: {}, ydoc: {} }; + yGetStateMock.mockReturnValue({ + undoManager: { + stopCapturing: stopCapturingMock, + }, + }); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: false, payload: 'ok' })); + + executeOutOfBandMutation(editor as never, mutateFn, { + dryRun: false, + expectedRevision: undefined, + }); + + expect(stopCapturingMock).toHaveBeenCalledOnce(); + expect(closeHistoryMock).not.toHaveBeenCalled(); + }); + + it('does not touch history grouping during dry-run', () => { + const editor = createMockEditor(); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: false, payload: 'ok' })); + + executeOutOfBandMutation(editor as never, mutateFn, { + dryRun: true, + expectedRevision: undefined, + }); + + expect(closeHistoryMock).not.toHaveBeenCalled(); + expect(stopCapturingMock).not.toHaveBeenCalled(); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts index dbf27ff00d..94992be4df 100644 --- a/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts +++ b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts @@ -13,6 +13,8 @@ * primitive without reimplementing lifecycle steps. */ +import { closeHistory } from 'prosemirror-history'; +import { yUndoPluginKey } from 'y-prosemirror'; import type { Editor } from '../core/Editor.js'; import { checkRevision, incrementRevision } from './plan-engine/revision-tracker.js'; @@ -51,6 +53,25 @@ export function executeOutOfBandMutation( mutateFn: (dryRun: boolean) => OutOfBandMutationResult, options: OutOfBandMutationOptions, ): T { + // Step 0: Close the current undo group to prevent bleeding into adjacent undo entries. + // The collab-history path requires both collaborationProvider AND ydoc (matching + // the History extension guard at history.js:34); ydoc-without-provider uses PM history. + if (!options.dryRun) { + if (editor.options?.collaborationProvider && editor.options?.ydoc) { + try { + yUndoPluginKey.getState(editor.state)?.undoManager?.stopCapturing(); + } catch { + // yUndoPlugin may not be loaded — safe to ignore. + } + } else { + try { + editor.view?.dispatch?.(closeHistory(editor.state.tr)); + } catch { + // History plugin may not be loaded — safe to ignore. + } + } + } + // Step 1: Revision guard (throws REVISION_MISMATCH if stale) checkRevision(editor, options.expectedRevision); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts index 3d2f1e4795..5848d9882c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -39,6 +39,8 @@ import type { } from './executor-registry.types.js'; import { getStepExecutor } from './executor-registry.js'; import { planError } from './errors.js'; +import { closeHistory } from 'prosemirror-history'; +import { yUndoPluginKey } from 'y-prosemirror'; import { checkRevision, getRevision } from './revision-tracker.js'; import { compilePlan } from './compiler.js'; import { getBlockIndex } from '../helpers/index-cache.js'; @@ -1368,6 +1370,24 @@ export function executeCompiledPlan( checkRevision(editor, options.expectedRevision); + // Close the current undo group so this API mutation becomes its own undo step, + // preventing PM's newGroupDelay from merging sequential API calls. + // The collab-history path requires both collaborationProvider AND ydoc (matching + // the History extension guard at history.js:34); ydoc-without-provider uses PM history. + if (editor.options?.collaborationProvider && editor.options?.ydoc) { + try { + yUndoPluginKey.getState(editor.state)?.undoManager?.stopCapturing(); + } catch { + // yUndoPlugin may not be loaded — safe to ignore. + } + } else { + try { + editor.view?.dispatch?.(closeHistory(editor.state.tr)); + } catch { + // History plugin may not be loaded — safe to ignore. + } + } + // D3: Detect revision drift between compile and execute if (compiled.compiledRevision !== revisionBefore) { throw planError(