From 0d5ab882d8bad26a680e8083b2eb9354e0b9f514 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 27 Feb 2026 15:44:07 -0800 Subject: [PATCH 1/2] feat(document-api): history name space --- apps/cli/scripts/export-sdk-contract.ts | 3 + .../src/__tests__/conformance/scenarios.ts | 20 +++ apps/cli/src/cli/types.ts | 1 + .../document-api/available-operations.mdx | 4 + .../reference/_generated-manifest.json | 13 +- .../reference/capabilities/get.mdx | 134 +++++++++++++++++- .../document-api/reference/history/get.mdx | 117 +++++++++++++++ .../document-api/reference/history/index.mdx | 20 +++ .../document-api/reference/history/redo.mdx | 108 ++++++++++++++ .../document-api/reference/history/undo.mdx | 108 ++++++++++++++ apps/docs/document-api/reference/index.mdx | 9 ++ apps/docs/document-engine/sdks.mdx | 9 ++ apps/docs/scripts/generate-sdk-overview.ts | 2 + .../src/capabilities/capabilities.ts | 1 + .../src/contract/contract.test.ts | 23 +++ .../src/contract/metadata-types.ts | 2 + .../src/contract/operation-definitions.ts | 72 +++++++++- .../src/contract/operation-registry.ts | 6 + .../src/contract/reference-doc-map.ts | 5 + packages/document-api/src/contract/schemas.ts | 47 ++++++ .../document-api/src/history/history.test.ts | 116 +++++++++++++++ packages/document-api/src/history/history.ts | 49 +++++++ .../document-api/src/history/history.types.ts | 34 +++++ packages/document-api/src/index.test.ts | 69 +++++++++ packages/document-api/src/index.ts | 22 +++ packages/document-api/src/invoke/invoke.ts | 5 + .../assemble-adapters.ts | 2 + .../capabilities-adapter.test.ts | 22 +++ .../capabilities-adapter.ts | 11 ++ .../history-adapter.test.ts | 133 +++++++++++++++++ .../document-api-adapters/history-adapter.ts | 88 ++++++++++++ .../out-of-band-mutation.test.ts | 66 +++++++++ .../out-of-band-mutation.ts | 21 +++ .../plan-engine/executor.ts | 20 +++ 34 files changed, 1359 insertions(+), 3 deletions(-) create mode 100644 apps/docs/document-api/reference/history/get.mdx create mode 100644 apps/docs/document-api/reference/history/index.mdx create mode 100644 apps/docs/document-api/reference/history/redo.mdx create mode 100644 apps/docs/document-api/reference/history/undo.mdx create mode 100644 packages/document-api/src/history/history.test.ts create mode 100644 packages/document-api/src/history/history.ts create mode 100644 packages/document-api/src/history/history.types.ts create mode 100644 packages/super-editor/src/document-api-adapters/history-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/history-adapter.ts diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index e8c15a0430..8c33b7e47b 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -145,6 +145,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 c6993055b7..c0e85fbf53 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -1311,6 +1311,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/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 8cf00b3f6b..b91a4dadbd 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 | 4 | 0 | 4 | [Reference](/document-api/reference/create/index) | | Format | 5 | 4 | 9 | [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) | | Query | 1 | 0 | 1 | [Reference](/document-api/reference/query/index) | @@ -58,6 +59,9 @@ Use the tables below to see what operations are available and where each one is | editor.doc.format.italic(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.underline(...) | [`format.apply`](/document-api/reference/format/apply) | | editor.doc.format.strikethrough(...) | [`format.apply`](/document-api/reference/format/apply) | +| 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 c7f7325269..240bdd8e38 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -28,6 +28,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", @@ -272,8 +276,15 @@ ], "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" } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "8b0c27a3eff9d8548a4de7b931f4a577774027635048dc42492f0500d00963b1" + "sourceHash": "6dba7cccec85e639574779afbf60c7b93f359a02e990b38ebd8872fc39cf770a" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index f705fe7e39..38730b2491 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -258,6 +258,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, @@ -1850,6 +1874,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": { @@ -4572,7 +4701,10 @@ _No fields._ "tables.clearCellSpacing", "tables.get", "tables.getCells", - "tables.getProperties" + "tables.getProperties", + "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..975b063772 --- /dev/null +++ b/apps/docs/document-api/reference/history/redo.mdx @@ -0,0 +1,108 @@ +--- +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" +} +``` + 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..c57b7d2057 --- /dev/null +++ b/apps/docs/document-api/reference/history/undo.mdx @@ -0,0 +1,108 @@ +--- +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" +} +``` + diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index ae1ef0a061..2aca27bca3 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -33,6 +33,7 @@ Document API is currently alpha and subject to breaking changes. | Query | 1 | 0 | 1 | [Open](/document-api/reference/query/index) | | Mutations | 2 | 0 | 2 | [Open](/document-api/reference/mutations/index) | | Tables | 39 | 0 | 39 | [Open](/document-api/reference/tables/index) | +| History | 3 | 0 | 3 | [Open](/document-api/reference/history/index) | ## Available operations @@ -202,3 +203,11 @@ The tables below are grouped by namespace. | tables.get | editor.doc.tables.get(...) | Retrieve table structure and dimensions by locator. | | 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. | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 2cbacb9d64..29d15a88c3 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -221,6 +221,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | --- | --- | --- | | `doc.create.paragraph` | `create paragraph` | Create a new paragraph at the target position. | | `doc.create.heading` | `create heading` | Create a new heading at the target position. | +| `doc.create.sectionBreak` | `create section-break` | Create a section break at the target location with optional initial section properties. | | `doc.create.table` | `create table` | Create a new table at the target position. | #### Blocks @@ -260,6 +261,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 21ecac5f98..42c830bc3c 100644 --- a/packages/document-api/src/capabilities/capabilities.ts +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -72,6 +72,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 0eb2811ebe..b19f73dd93 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -130,6 +130,7 @@ describe('document-api contract catalog', () => { 'query', 'mutations', 'tables', + 'history', ]; for (const id of OPERATION_IDS) { expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); @@ -174,4 +175,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 47c8e35c16..e4bc8bb55b 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -44,7 +44,8 @@ export type ReferenceGroupKey = | 'trackChanges' | 'query' | 'mutations' - | 'tables'; + | 'tables' + | 'history'; // --------------------------------------------------------------------------- // Entry shape @@ -98,6 +99,7 @@ function mutationOperation(options: { throws: readonly PreApplyThrowCode[]; deterministicTargetResolution?: boolean; remediationHints?: readonly string[]; + historyUnsafe?: boolean; }): CommandStaticMetadata { return { mutates: true, @@ -111,6 +113,7 @@ function mutationOperation(options: { }, deterministicTargetResolution: options.deterministicTargetResolution ?? true, remediationHints: options.remediationHints, + historyUnsafe: options.historyUnsafe, }; } @@ -390,6 +393,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', @@ -478,6 +482,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', @@ -494,6 +499,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', @@ -510,6 +516,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', @@ -526,6 +533,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', @@ -541,6 +549,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', @@ -556,6 +565,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', @@ -571,6 +581,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', @@ -586,6 +597,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', @@ -601,6 +613,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', @@ -616,6 +629,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', @@ -631,6 +645,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', @@ -647,6 +662,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', @@ -663,6 +679,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', @@ -679,6 +696,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', @@ -695,6 +713,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', @@ -711,6 +730,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', @@ -1643,6 +1663,56 @@ export const OPERATION_DEFINITIONS = { referenceDocPath: 'tables/get-properties.mdx', referenceGroup: 'tables', }, + // ------------------------------------------------------------------------- + // 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 5a84c2f174..4d1c34affd 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -45,6 +45,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, @@ -280,6 +281,11 @@ export interface OperationRegistry { // --- 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 283e8f1836..a5fbcc58a0 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -86,6 +86,11 @@ const GROUP_METADATA: Record = { ['nodeId'], ), }, + + // --- 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: objectSchema( + { + noop: { type: 'boolean' }, + revision: objectSchema( + { + before: { type: 'string' }, + after: { type: 'string' }, + }, + ['before', 'after'], + ), + }, + ['noop', 'revision'], + ), + }, + 'history.redo': { + input: strictEmptyObjectSchema, + output: objectSchema( + { + noop: { type: 'boolean' }, + revision: objectSchema( + { + before: { type: 'string' }, + after: { type: 'string' }, + }, + ['before', 'after'], + ), + }, + ['noop', 'revision'], + ), + }, }; /** 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 a32199e800..ab64204cb2 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 { @@ -265,6 +266,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: { @@ -272,6 +287,7 @@ function makeCapabilitiesAdapter(overrides?: Partial): comments: { enabled: false }, lists: { enabled: false }, dryRun: { enabled: false }, + history: { enabled: false }, }, format: { properties: {} }, operations: {} as DocumentApiCapabilities['operations'], @@ -804,6 +820,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 d44c57bcb5..cff207be51 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, @@ -176,6 +178,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 { SectionsAdapter, SectionsApi } from './sections/sections.js'; import type { @@ -534,6 +539,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. * @@ -573,6 +583,7 @@ export interface DocumentApiAdapters { tables: TablesAdapter; query: QueryAdapter; mutations: MutationsAdapter; + history: HistoryAdapter; } /** @@ -1077,6 +1088,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 1e4997305a..b2a7a6610a 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -114,6 +114,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/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 1b7249b16c..3fac7e7ef1 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -100,6 +100,7 @@ import { tablesClearCellSpacingWrapper, } from './plan-engine/tables-wrappers.js'; import { tablesGetAdapter, tablesGetCellsAdapter, tablesGetPropertiesAdapter } from './tables-adapter.js'; +import { createHistoryAdapter } from './history-adapter.js'; /** * Assembles all document-api adapters for the given editor instance. @@ -239,5 +240,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 10cfab9c36..e63f1fb846 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 a279933b26..f884626abc 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -51,6 +51,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') && @@ -393,6 +399,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 { @@ -413,6 +420,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 63676da945..eca581ba46 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 @@ -37,6 +37,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'; @@ -964,6 +966,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( From 4cff79f88ae29a3014bf7148493acc2bdbc30b96 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 27 Feb 2026 16:38:39 -0800 Subject: [PATCH 2/2] chore: fix sdk validation --- .../contract-response-conformance.test.ts | 8 +++ apps/cli/src/cli/operation-hints.ts | 12 ++++ .../reference/_generated-manifest.json | 2 +- .../reference/capabilities/get.mdx | 36 +++++++++- .../document-api/reference/history/redo.mdx | 71 +++++++++++++++++++ .../document-api/reference/history/undo.mdx | 71 +++++++++++++++++++ .../src/contract/contract.test.ts | 15 ++++ packages/document-api/src/contract/schemas.ts | 64 ++++++++++------- packages/sdk/scripts/sdk-validate.mjs | 70 +++++++++--------- 9 files changed, 288 insertions(+), 61 deletions(-) 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 b0d46b6f0e..8e1089b190 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -125,6 +125,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', }; // --------------------------------------------------------------------------- @@ -238,6 +241,9 @@ export const OUTPUT_FORMAT: Record = { 'tables.get': 'tableInfo', 'tables.getCells': 'tableCellList', 'tables.getProperties': 'tablePropertiesInfo', + 'history.get': 'plain', + 'history.undo': 'plain', + 'history.redo': 'plain', }; // --------------------------------------------------------------------------- @@ -335,6 +341,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', }; // --------------------------------------------------------------------------- @@ -461,4 +470,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/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index f3242ed250..8729da6851 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -390,5 +390,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "4b8ed87306be0b1f95fd6cf2786a3ea47f733613eb04413a5877c1d4ff6aa06e" + "sourceHash": "815d2bb72038b6148fe8c105662a1eee83dbc5573197c47ea626c7b3db5be154" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 23222d8e7e..65f3d8dd3a 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -316,6 +316,12 @@ _No fields._ "COMMAND_UNAVAILABLE" ] }, + "history": { + "enabled": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ] + }, "lists": { "enabled": true, "reasons": [ @@ -3027,6 +3033,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": { @@ -3086,7 +3119,8 @@ _No fields._ "trackChanges", "comments", "lists", - "dryRun" + "dryRun", + "history" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/history/redo.mdx b/apps/docs/document-api/reference/history/redo.mdx index 975b063772..2ec2f328a5 100644 --- a/apps/docs/document-api/reference/history/redo.mdx +++ b/apps/docs/document-api/reference/history/redo.mdx @@ -106,3 +106,74 @@ _No fields._ } ``` + + +```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 index c57b7d2057..d39549d220 100644 --- a/apps/docs/document-api/reference/history/undo.mdx +++ b/apps/docs/document-api/reference/history/undo.mdx @@ -106,3 +106,74 @@ _No fields._ } ``` + + +```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/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index cc8fc66cf6..451f578296 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(); diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 7b7c838350..af2e1ec510 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1153,8 +1153,9 @@ const capabilitiesOutputSchema = objectSchema( comments: capabilityFlagSchema, lists: capabilityFlagSchema, dryRun: capabilityFlagSchema, + history: capabilityFlagSchema, }, - ['trackChanges', 'comments', 'lists', 'dryRun'], + ['trackChanges', 'comments', 'lists', 'dryRun', 'history'], ), format: formatCapabilitiesSchema, operations: operationCapabilitiesSchema, @@ -1292,6 +1293,35 @@ const createTableResultSchema: JsonSchema = { oneOf: [createTableSuccessSchema, tableMutationFailureSchema], }; +const historyActionSuccessSchema: JsonSchema = objectSchema( + { + noop: { type: 'boolean' }, + revision: objectSchema( + { + before: { type: 'string' }, + after: { type: 'string' }, + }, + ['before', 'after'], + ), + }, + ['noop', 'revision'], +); + +const historyActionFailureSchema: JsonSchema = objectSchema( + { + success: { const: false }, + failure: objectSchema( + { + code: { enum: ['CAPABILITY_UNAVAILABLE'] }, + message: { type: 'string' }, + details: {}, + }, + ['code', 'message'], + ), + }, + ['success', 'failure'], +); + type FormatInlineAliasOperationId = `format.${(typeof INLINE_PROPERTY_REGISTRY)[number]['key']}`; function supportsImplicitTrueValue(operationId: FormatInlineAliasOperationId): boolean { @@ -2890,35 +2920,15 @@ const operationSchemas: Record = { }, 'history.undo': { input: strictEmptyObjectSchema, - output: objectSchema( - { - noop: { type: 'boolean' }, - revision: objectSchema( - { - before: { type: 'string' }, - after: { type: 'string' }, - }, - ['before', 'after'], - ), - }, - ['noop', 'revision'], - ), + output: historyActionSuccessSchema, + success: historyActionSuccessSchema, + failure: historyActionFailureSchema, }, 'history.redo': { input: strictEmptyObjectSchema, - output: objectSchema( - { - noop: { type: 'boolean' }, - revision: objectSchema( - { - before: { type: 'string' }, - after: { type: 'string' }, - }, - ['before', 'after'], - ), - }, - ['noop', 'revision'], - ), + output: historyActionSuccessSchema, + success: historyActionSuccessSchema, + failure: historyActionFailureSchema, }, // ------------------------------------------------------------------------- // TOC schemas 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 ?? {};