From b6e5fa6f30d836e28fbae11fd60b21fbdd882fb0 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 20 Feb 2026 14:08:51 -0800 Subject: [PATCH] fix(document-api): add nodeId shorthand resolution across all operations --- apps/cli/src/lib/error-mapping.ts | 6 +- .../reference/_generated-manifest.json | 2 +- .../document-api/reference/comments/add.mdx | 95 +- .../document-api/reference/comments/move.mdx | 97 +- .../document-api/reference/create/heading.mdx | 40 +- .../reference/create/paragraph.mdx | 40 +- apps/docs/document-api/reference/delete.mdx | 97 +- .../document-api/reference/format/bold.mdx | 97 +- .../document-api/reference/format/italic.mdx | 97 +- .../reference/format/strikethrough.mdx | 97 +- .../reference/format/underline.mdx | 97 +- .../document-api/reference/lists/exit.mdx | 20 +- .../document-api/reference/lists/indent.mdx | 20 +- .../document-api/reference/lists/insert.mdx | 18 +- .../document-api/reference/lists/outdent.mdx | 20 +- .../document-api/reference/lists/restart.mdx | 20 +- .../document-api/reference/lists/set-type.mdx | 18 +- apps/docs/document-api/reference/replace.mdx | 95 +- .../generated/agent/compatibility-hints.json | 2 +- .../generated/agent/remediation-map.json | 30 +- .../generated/agent/workflow-playbooks.json | 2 +- .../manifests/document-api-tools.json | 709 ++++++++++- .../schemas/document-api-contract.json | 709 ++++++++++- .../document-api/src/comments/comments.ts | 218 +++- .../src/contract/metadata-types.ts | 1 + .../src/contract/operation-definitions.ts | 32 +- packages/document-api/src/contract/schemas.ts | 307 +++-- packages/document-api/src/create/create.ts | 44 +- packages/document-api/src/delete/delete.ts | 129 +- packages/document-api/src/format/format.ts | 139 +- packages/document-api/src/index.test.ts | 1128 +++++++++++++++++ packages/document-api/src/index.ts | 2 + packages/document-api/src/insert/insert.ts | 47 +- packages/document-api/src/lists/lists.ts | 38 + .../document-api/src/lists/lists.types.ts | 8 +- packages/document-api/src/replace/replace.ts | 136 +- .../document-api/src/types/create.types.ts | 4 +- .../src/validation-primitives.test.ts | 159 +++ .../document-api/src/validation-primitives.ts | 63 + packages/document-api/src/write/locator.ts | 19 +- packages/document-api/src/write/write.ts | 10 +- .../create-adapter.test.ts | 122 ++ .../document-api-adapters/create-adapter.ts | 16 +- .../src/document-api-adapters/errors.test.ts | 2 +- .../src/document-api-adapters/errors.ts | 6 +- .../document-api-adapters/format-adapter.ts | 54 +- .../helpers/node-address-resolver.ts | 29 + .../lists-adapter.test.ts | 91 ++ .../document-api-adapters/lists-adapter.ts | 27 +- .../document-api-adapters/write-adapter.ts | 113 +- 50 files changed, 5013 insertions(+), 359 deletions(-) create mode 100644 packages/document-api/src/validation-primitives.test.ts create mode 100644 packages/document-api/src/validation-primitives.ts diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index d5d2aef30..4dfa41142 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -129,7 +129,11 @@ function mapCreateError(operationId: CliExposedOperationId, error: unknown, code return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); } - if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') { + if (code === 'AMBIGUOUS_TARGET' || code === 'INVALID_TARGET') { + return new CliError('INVALID_ARGUMENT', message, { operationId, details }); + } + + if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE' || code === 'CAPABILITY_UNAVAILABLE') { return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); } diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 559723654..7427bf40b 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -124,5 +124,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa" + "sourceHash": "dd10c0218ba8a9148e8a3412053672bbbe88976f7a779650602aa98dec6efeee" } diff --git a/apps/docs/document-api/reference/comments/add.mdx b/apps/docs/document-api/reference/comments/add.mdx index b1a51c07c..db85cfdee 100644 --- a/apps/docs/document-api/reference/comments/add.mdx +++ b/apps/docs/document-api/reference/comments/add.mdx @@ -23,6 +23,7 @@ description: Generated reference for comments.add - `TARGET_NOT_FOUND` - `COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,100 @@ description: Generated reference for comments.add ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -73,7 +167,6 @@ description: Generated reference for comments.add } }, "required": [ - "target", "text" ], "type": "object" diff --git a/apps/docs/document-api/reference/comments/move.mdx b/apps/docs/document-api/reference/comments/move.mdx index 69980badf..207cbe308 100644 --- a/apps/docs/document-api/reference/comments/move.mdx +++ b/apps/docs/document-api/reference/comments/move.mdx @@ -23,6 +23,7 @@ description: Generated reference for comments.move - `TARGET_NOT_FOUND` - `COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,10 +35,103 @@ description: Generated reference for comments.move ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, "commentId": { "type": "string" }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -73,8 +167,7 @@ description: Generated reference for comments.move } }, "required": [ - "commentId", - "target" + "commentId" ], "type": "object" } diff --git a/apps/docs/document-api/reference/create/heading.mdx b/apps/docs/document-api/reference/create/heading.mdx index 44c315bac..360c5a6b1 100644 --- a/apps/docs/document-api/reference/create/heading.mdx +++ b/apps/docs/document-api/reference/create/heading.mdx @@ -24,6 +24,8 @@ description: Generated reference for create.heading - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `AMBIGUOUS_TARGET` ## Non-applied failure codes @@ -63,10 +65,26 @@ description: Generated reference for create.heading }, { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { "kind": { "const": "before" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -98,17 +116,32 @@ description: Generated reference for create.heading } }, "required": [ - "kind", - "target" + "kind" ], "type": "object" }, { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { "kind": { "const": "after" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -140,8 +173,7 @@ description: Generated reference for create.heading } }, "required": [ - "kind", - "target" + "kind" ], "type": "object" } diff --git a/apps/docs/document-api/reference/create/paragraph.mdx b/apps/docs/document-api/reference/create/paragraph.mdx index edbc13208..35fb943d9 100644 --- a/apps/docs/document-api/reference/create/paragraph.mdx +++ b/apps/docs/document-api/reference/create/paragraph.mdx @@ -24,6 +24,8 @@ description: Generated reference for create.paragraph - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `AMBIGUOUS_TARGET` ## Non-applied failure codes @@ -63,10 +65,26 @@ description: Generated reference for create.paragraph }, { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { "kind": { "const": "before" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -98,17 +116,32 @@ description: Generated reference for create.paragraph } }, "required": [ - "kind", - "target" + "kind" ], "type": "object" }, { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { "kind": { "const": "after" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -140,8 +173,7 @@ description: Generated reference for create.paragraph } }, "required": [ - "kind", - "target" + "kind" ], "type": "object" } diff --git a/apps/docs/document-api/reference/delete.mdx b/apps/docs/document-api/reference/delete.mdx index 3904bca91..562d4cf91 100644 --- a/apps/docs/document-api/reference/delete.mdx +++ b/apps/docs/document-api/reference/delete.mdx @@ -23,6 +23,7 @@ description: Generated reference for delete - `TARGET_NOT_FOUND` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -33,7 +34,100 @@ description: Generated reference for delete ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -68,9 +162,6 @@ description: Generated reference for delete "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/format/bold.mdx b/apps/docs/document-api/reference/format/bold.mdx index cd42e6214..c73515458 100644 --- a/apps/docs/document-api/reference/format/bold.mdx +++ b/apps/docs/document-api/reference/format/bold.mdx @@ -24,6 +24,7 @@ description: Generated reference for format.bold - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,100 @@ description: Generated reference for format.bold ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -69,9 +163,6 @@ description: Generated reference for format.bold "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/format/italic.mdx b/apps/docs/document-api/reference/format/italic.mdx index 6f2083529..47cdfd322 100644 --- a/apps/docs/document-api/reference/format/italic.mdx +++ b/apps/docs/document-api/reference/format/italic.mdx @@ -24,6 +24,7 @@ description: Generated reference for format.italic - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,100 @@ description: Generated reference for format.italic ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -69,9 +163,6 @@ description: Generated reference for format.italic "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/format/strikethrough.mdx b/apps/docs/document-api/reference/format/strikethrough.mdx index 2059db366..927d74db2 100644 --- a/apps/docs/document-api/reference/format/strikethrough.mdx +++ b/apps/docs/document-api/reference/format/strikethrough.mdx @@ -24,6 +24,7 @@ description: Generated reference for format.strikethrough - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,100 @@ description: Generated reference for format.strikethrough ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -69,9 +163,6 @@ description: Generated reference for format.strikethrough "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/format/underline.mdx b/apps/docs/document-api/reference/format/underline.mdx index 50ff5b6db..a51033c82 100644 --- a/apps/docs/document-api/reference/format/underline.mdx +++ b/apps/docs/document-api/reference/format/underline.mdx @@ -24,6 +24,7 @@ description: Generated reference for format.underline - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,100 @@ description: Generated reference for format.underline ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -69,9 +163,6 @@ description: Generated reference for format.underline "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/lists/exit.mdx b/apps/docs/document-api/reference/lists/exit.mdx index e01820da0..afd80c75e 100644 --- a/apps/docs/document-api/reference/lists/exit.mdx +++ b/apps/docs/document-api/reference/lists/exit.mdx @@ -24,6 +24,7 @@ description: Generated reference for lists.exit - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,23 @@ description: Generated reference for lists.exit ```json { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -56,9 +73,6 @@ description: Generated reference for lists.exit "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/lists/indent.mdx b/apps/docs/document-api/reference/lists/indent.mdx index 5bc2f3463..d4e22a7bb 100644 --- a/apps/docs/document-api/reference/lists/indent.mdx +++ b/apps/docs/document-api/reference/lists/indent.mdx @@ -24,6 +24,7 @@ description: Generated reference for lists.indent - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -35,7 +36,23 @@ description: Generated reference for lists.indent ```json { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -57,9 +74,6 @@ description: Generated reference for lists.indent "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/lists/insert.mdx b/apps/docs/document-api/reference/lists/insert.mdx index c7e2d462b..0a7f278ca 100644 --- a/apps/docs/document-api/reference/lists/insert.mdx +++ b/apps/docs/document-api/reference/lists/insert.mdx @@ -24,6 +24,7 @@ description: Generated reference for lists.insert - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,23 @@ description: Generated reference for lists.insert ```json { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "position": { "enum": [ "before", @@ -66,7 +83,6 @@ description: Generated reference for lists.insert } }, "required": [ - "target", "position" ], "type": "object" diff --git a/apps/docs/document-api/reference/lists/outdent.mdx b/apps/docs/document-api/reference/lists/outdent.mdx index aebde40d1..a2ffc2782 100644 --- a/apps/docs/document-api/reference/lists/outdent.mdx +++ b/apps/docs/document-api/reference/lists/outdent.mdx @@ -24,6 +24,7 @@ description: Generated reference for lists.outdent - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -35,7 +36,23 @@ description: Generated reference for lists.outdent ```json { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -57,9 +74,6 @@ description: Generated reference for lists.outdent "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/lists/restart.mdx b/apps/docs/document-api/reference/lists/restart.mdx index 3cc6d5e30..802d4c9fd 100644 --- a/apps/docs/document-api/reference/lists/restart.mdx +++ b/apps/docs/document-api/reference/lists/restart.mdx @@ -24,6 +24,7 @@ description: Generated reference for lists.restart - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -35,7 +36,23 @@ description: Generated reference for lists.restart ```json { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -57,9 +74,6 @@ description: Generated reference for lists.restart "type": "object" } }, - "required": [ - "target" - ], "type": "object" } ``` diff --git a/apps/docs/document-api/reference/lists/set-type.mdx b/apps/docs/document-api/reference/lists/set-type.mdx index 29bc7054a..4c47f9811 100644 --- a/apps/docs/document-api/reference/lists/set-type.mdx +++ b/apps/docs/document-api/reference/lists/set-type.mdx @@ -24,6 +24,7 @@ description: Generated reference for lists.setType - `COMMAND_UNAVAILABLE` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -35,6 +36,18 @@ description: Generated reference for lists.setType ```json { "additionalProperties": false, + "oneOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "nodeId" + ] + } + ], "properties": { "kind": { "enum": [ @@ -42,6 +55,10 @@ description: Generated reference for lists.setType "bullet" ] }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -64,7 +81,6 @@ description: Generated reference for lists.setType } }, "required": [ - "target", "kind" ], "type": "object" diff --git a/apps/docs/document-api/reference/replace.mdx b/apps/docs/document-api/reference/replace.mdx index 6393a5a5e..277634992 100644 --- a/apps/docs/document-api/reference/replace.mdx +++ b/apps/docs/document-api/reference/replace.mdx @@ -23,6 +23,7 @@ description: Generated reference for replace - `TARGET_NOT_FOUND` - `TRACK_CHANGE_COMMAND_UNAVAILABLE` - `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` ## Non-applied failure codes @@ -34,7 +35,100 @@ description: Generated reference for replace ```json { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": [ + "target", + "blockId" + ] + } + }, + { + "not": { + "required": [ + "target", + "start" + ] + } + }, + { + "not": { + "required": [ + "target", + "end" + ] + } + }, + { + "if": { + "required": [ + "start" + ] + }, + "then": { + "required": [ + "blockId", + "end" + ] + } + }, + { + "if": { + "required": [ + "end" + ] + }, + "then": { + "required": [ + "blockId", + "start" + ] + } + }, + { + "if": { + "required": [ + "blockId" + ] + }, + "then": { + "required": [ + "start", + "end" + ] + } + } + ], + "anyOf": [ + { + "required": [ + "target" + ] + }, + { + "required": [ + "blockId", + "start", + "end" + ] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -73,7 +167,6 @@ description: Generated reference for replace } }, "required": [ - "target", "text" ], "type": "object" diff --git a/packages/document-api/generated/agent/compatibility-hints.json b/packages/document-api/generated/agent/compatibility-hints.json index a0db2e2a7..245352384 100644 --- a/packages/document-api/generated/agent/compatibility-hints.json +++ b/packages/document-api/generated/agent/compatibility-hints.json @@ -362,5 +362,5 @@ "supportsTrackedMode": false } }, - "sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa" + "sourceHash": "dd10c0218ba8a9148e8a3412053672bbbe88976f7a779650602aa98dec6efeee" } diff --git a/packages/document-api/generated/agent/remediation-map.json b/packages/document-api/generated/agent/remediation-map.json index 0f3323d94..b92701725 100644 --- a/packages/document-api/generated/agent/remediation-map.json +++ b/packages/document-api/generated/agent/remediation-map.json @@ -1,6 +1,13 @@ { "contractVersion": "0.1.0", "entries": [ + { + "code": "AMBIGUOUS_TARGET", + "message": "Inspect structured error details and operation capabilities.", + "nonAppliedOperations": [], + "operations": ["create.heading", "create.paragraph"], + "preApplyOperations": ["create.heading", "create.paragraph"] + }, { "code": "CAPABILITY_UNAVAILABLE", "message": "Check runtime capabilities and switch to supported mode or operation.", @@ -157,6 +164,7 @@ "comments.setInternal", "create.heading", "create.paragraph", + "delete", "format.bold", "format.italic", "format.strikethrough", @@ -170,7 +178,25 @@ "lists.setType", "replace" ], - "preApplyOperations": ["insert"] + "preApplyOperations": [ + "comments.add", + "comments.move", + "create.heading", + "create.paragraph", + "delete", + "format.bold", + "format.italic", + "format.strikethrough", + "format.underline", + "insert", + "lists.exit", + "lists.indent", + "lists.insert", + "lists.outdent", + "lists.restart", + "lists.setType", + "replace" + ] }, { "code": "NO_OP", @@ -328,5 +354,5 @@ ] } ], - "sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa" + "sourceHash": "dd10c0218ba8a9148e8a3412053672bbbe88976f7a779650602aa98dec6efeee" } diff --git a/packages/document-api/generated/agent/workflow-playbooks.json b/packages/document-api/generated/agent/workflow-playbooks.json index f8916c956..c45bb1a51 100644 --- a/packages/document-api/generated/agent/workflow-playbooks.json +++ b/packages/document-api/generated/agent/workflow-playbooks.json @@ -1,6 +1,6 @@ { "contractVersion": "0.1.0", - "sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa", + "sourceHash": "dd10c0218ba8a9148e8a3412053672bbbe88976f7a779650602aa98dec6efeee", "workflows": [ { "id": "find-mutate", diff --git a/packages/document-api/generated/manifests/document-api-tools.json b/packages/document-api/generated/manifests/document-api-tools.json index ba68db104..d7c8f0ce6 100644 --- a/packages/document-api/generated/manifests/document-api-tools.json +++ b/packages/document-api/generated/manifests/document-api-tools.json @@ -2,7 +2,7 @@ "contractVersion": "0.1.0", "generatedAt": null, "sourceCommit": null, - "sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa", + "sourceHash": "dd10c0218ba8a9148e8a3412053672bbbe88976f7a779650602aa98dec6efeee", "tools": [ { "description": "Read Document API data via `find`.", @@ -1702,7 +1702,70 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -1733,7 +1796,7 @@ "type": "string" } }, - "required": ["target", "text"], + "required": ["text"], "type": "object" }, "memberPath": "replace", @@ -2045,7 +2108,12 @@ ] }, "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], - "preApplyThrows": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" + ], "remediationHints": [], "successSchema": { "additionalProperties": false, @@ -2357,7 +2425,70 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -2385,7 +2516,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "delete", @@ -2697,7 +2827,12 @@ ] }, "possibleFailureCodes": ["NO_OP"], - "preApplyThrows": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "preApplyThrows": [ + "TARGET_NOT_FOUND", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" + ], "remediationHints": [], "successSchema": { "additionalProperties": false, @@ -3009,7 +3144,70 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -3037,7 +3235,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.bold", @@ -3353,7 +3550,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -3666,7 +3864,70 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -3694,7 +3955,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.italic", @@ -4010,7 +4270,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -4323,7 +4584,70 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -4351,7 +4675,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.underline", @@ -4667,7 +4990,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -4980,7 +5304,70 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -5008,7 +5395,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.strikethrough", @@ -5324,7 +5710,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -5587,10 +5974,22 @@ }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "before" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -5608,15 +6007,27 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "after" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -5634,7 +6045,7 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" } ] @@ -5752,7 +6163,9 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET", + "AMBIGUOUS_TARGET" ], "remediationHints": [], "successSchema": { @@ -5884,10 +6297,22 @@ }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "before" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -5905,15 +6330,27 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "after" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -5931,7 +6368,7 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" } ] @@ -6055,7 +6492,9 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET", + "AMBIGUOUS_TARGET" ], "remediationHints": [], "successSchema": { @@ -6368,7 +6807,19 @@ "idempotency": "non-idempotent", "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "position": { "enum": ["before", "after"] }, @@ -6392,7 +6843,7 @@ "type": "string" } }, - "required": ["target", "position"], + "required": ["position"], "type": "object" }, "memberPath": "lists.insert", @@ -6502,7 +6953,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -6609,10 +7061,22 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "enum": ["ordered", "bullet"] }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -6630,7 +7094,7 @@ "type": "object" } }, - "required": ["target", "kind"], + "required": ["kind"], "type": "object" }, "memberPath": "lists.setType", @@ -6695,7 +7159,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -6757,7 +7222,19 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -6775,7 +7252,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.indent", @@ -6840,7 +7316,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -6902,7 +7379,19 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -6920,7 +7409,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.outdent", @@ -6985,7 +7473,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -7047,7 +7536,19 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -7065,7 +7566,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.restart", @@ -7130,7 +7630,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -7192,7 +7693,19 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -7210,7 +7723,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.exit", @@ -7275,7 +7787,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ], "remediationHints": [], "successSchema": { @@ -7337,7 +7850,70 @@ "idempotency": "non-idempotent", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -7368,7 +7944,7 @@ "type": "string" } }, - "required": ["target", "text"], + "required": ["text"], "type": "object" }, "memberPath": "comments.add", @@ -7530,7 +8106,7 @@ ] }, "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], - "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE", "INVALID_TARGET"], "remediationHints": [], "successSchema": { "additionalProperties": false, @@ -8356,10 +8932,73 @@ "idempotency": "conditional", "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, "commentId": { "type": "string" }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -8387,7 +9026,7 @@ "type": "object" } }, - "required": ["commentId", "target"], + "required": ["commentId"], "type": "object" }, "memberPath": "comments.move", @@ -8549,7 +9188,7 @@ ] }, "possibleFailureCodes": ["INVALID_TARGET", "NO_OP"], - "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"], + "preApplyThrows": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE", "INVALID_TARGET"], "remediationHints": [], "successSchema": { "additionalProperties": false, diff --git a/packages/document-api/generated/schemas/document-api-contract.json b/packages/document-api/generated/schemas/document-api-contract.json index 2b61cb063..ab6abc159 100644 --- a/packages/document-api/generated/schemas/document-api-contract.json +++ b/packages/document-api/generated/schemas/document-api-contract.json @@ -1320,7 +1320,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -1351,7 +1414,7 @@ "type": "string" } }, - "required": ["target", "text"], + "required": ["text"], "type": "object" }, "memberPath": "comments.add", @@ -1364,7 +1427,7 @@ "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, - "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE", "INVALID_TARGET"] } }, "outputSchema": { @@ -2377,10 +2440,73 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, "commentId": { "type": "string" }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -2408,7 +2534,7 @@ "type": "object" } }, - "required": ["commentId", "target"], + "required": ["commentId"], "type": "object" }, "memberPath": "comments.move", @@ -2421,7 +2547,7 @@ "supportsTrackedMode": false, "throws": { "postApplyForbidden": true, - "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + "preApply": ["TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE", "INVALID_TARGET"] } }, "outputSchema": { @@ -4420,10 +4546,22 @@ }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "before" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -4441,15 +4579,27 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "after" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -4467,7 +4617,7 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" } ] @@ -4498,7 +4648,9 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET", + "AMBIGUOUS_TARGET" ] } }, @@ -4725,10 +4877,22 @@ }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "before" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -4746,15 +4910,27 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" }, { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "const": "after" }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a full BlockNodeAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -4772,7 +4948,7 @@ "type": "object" } }, - "required": ["kind", "target"], + "required": ["kind"], "type": "object" } ] @@ -4797,7 +4973,9 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET", + "AMBIGUOUS_TARGET" ] } }, @@ -5074,7 +5252,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -5102,7 +5343,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "delete", @@ -5115,7 +5355,12 @@ "supportsTrackedMode": true, "throws": { "postApplyForbidden": true, - "preApply": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + "preApply": [ + "TARGET_NOT_FOUND", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" + ] } }, "outputSchema": { @@ -6296,7 +6541,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -6324,7 +6632,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.bold", @@ -6341,7 +6648,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -6955,7 +7263,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -6983,7 +7354,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.italic", @@ -7000,7 +7370,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -7614,7 +7985,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -7642,7 +8076,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.strikethrough", @@ -7659,7 +8092,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -8273,7 +8707,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -8301,7 +8798,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "format.underline", @@ -8318,7 +8814,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -9894,7 +10391,19 @@ }, "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -9912,7 +10421,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.exit", @@ -9929,7 +10437,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -10123,7 +10632,19 @@ }, "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -10141,7 +10662,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.indent", @@ -10158,7 +10678,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -10270,7 +10791,19 @@ }, "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "position": { "enum": ["before", "after"] }, @@ -10294,7 +10827,7 @@ "type": "string" } }, - "required": ["target", "position"], + "required": ["position"], "type": "object" }, "memberPath": "lists.insert", @@ -10311,7 +10844,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -10641,7 +11175,19 @@ }, "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -10659,7 +11205,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.outdent", @@ -10676,7 +11221,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -10788,7 +11334,19 @@ }, "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -10806,7 +11364,6 @@ "type": "object" } }, - "required": ["target"], "type": "object" }, "memberPath": "lists.restart", @@ -10823,7 +11380,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -10935,10 +11493,22 @@ }, "inputSchema": { "additionalProperties": false, + "oneOf": [ + { + "required": ["target"] + }, + { + "required": ["nodeId"] + } + ], "properties": { "kind": { "enum": ["ordered", "bullet"] }, + "nodeId": { + "description": "Node ID shorthand — adapter resolves to a ListItemAddress.", + "type": "string" + }, "target": { "additionalProperties": false, "properties": { @@ -10956,7 +11526,7 @@ "type": "object" } }, - "required": ["target", "kind"], + "required": ["kind"], "type": "object" }, "memberPath": "lists.setType", @@ -10973,7 +11543,8 @@ "TARGET_NOT_FOUND", "COMMAND_UNAVAILABLE", "TRACK_CHANGE_COMMAND_UNAVAILABLE", - "CAPABILITY_UNAVAILABLE" + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" ] } }, @@ -11160,7 +11731,70 @@ }, "inputSchema": { "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["target", "blockId"] + } + }, + { + "not": { + "required": ["target", "start"] + } + }, + { + "not": { + "required": ["target", "end"] + } + }, + { + "if": { + "required": ["start"] + }, + "then": { + "required": ["blockId", "end"] + } + }, + { + "if": { + "required": ["end"] + }, + "then": { + "required": ["blockId", "start"] + } + }, + { + "if": { + "required": ["blockId"] + }, + "then": { + "required": ["start", "end"] + } + } + ], + "anyOf": [ + { + "required": ["target"] + }, + { + "required": ["blockId", "start", "end"] + } + ], "properties": { + "blockId": { + "description": "Block ID for block-relative range targeting.", + "type": "string" + }, + "end": { + "description": "End offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, + "start": { + "description": "Start offset within the block identified by blockId.", + "minimum": 0, + "type": "integer" + }, "target": { "additionalProperties": false, "properties": { @@ -11191,7 +11825,7 @@ "type": "string" } }, - "required": ["target", "text"], + "required": ["text"], "type": "object" }, "memberPath": "replace", @@ -11204,7 +11838,12 @@ "supportsTrackedMode": true, "throws": { "postApplyForbidden": true, - "preApply": ["TARGET_NOT_FOUND", "TRACK_CHANGE_COMMAND_UNAVAILABLE", "CAPABILITY_UNAVAILABLE"] + "preApply": [ + "TARGET_NOT_FOUND", + "TRACK_CHANGE_COMMAND_UNAVAILABLE", + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET" + ] } }, "outputSchema": { @@ -13206,5 +13845,5 @@ } }, "sourceCommit": null, - "sourceHash": "d53e75c131bc35996800d18ff3b67961de5d6d031ba20f4a6a03106e1117acaa" + "sourceHash": "dd10c0218ba8a9148e8a3412053672bbbe88976f7a779650602aa98dec6efeee" } diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts index 719465a34..7ded34157 100644 --- a/packages/document-api/src/comments/comments.ts +++ b/packages/document-api/src/comments/comments.ts @@ -1,5 +1,7 @@ import type { Receipt, TextAddress } from '../types/index.js'; import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments.types.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, isTextAddress, assertNoUnknownFields, assertNonNegativeInteger } from '../validation-primitives.js'; /** * Input for adding a comment to a text range. @@ -12,9 +14,15 @@ export interface AddCommentInput { * block range (e.g., the first `textRanges` entry from `find`) until * multi-block comment targets are supported. */ - target: TextAddress; + target?: TextAddress; /** The comment body text. */ text: string; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; } export interface EditCommentInput { @@ -29,7 +37,13 @@ export interface ReplyToCommentInput { export interface MoveCommentInput { commentId: string; - target: TextAddress; + target?: TextAddress; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; } export interface ResolveCommentInput { @@ -90,6 +104,198 @@ export interface CommentsAdapter { */ export type CommentsApi = CommentsAdapter; +const ADD_COMMENT_ALLOWED_KEYS = new Set(['target', 'text', 'blockId', 'start', 'end']); + +/** + * Validates AddCommentInput and throws DocumentApiValidationError on violations. + */ +function validateAddCommentInput(input: unknown): asserts input is AddCommentInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'comments.add input must be a non-null object.'); + } + + assertNoUnknownFields(input, ADD_COMMENT_ALLOWED_KEYS, 'comments.add'); + + const { target, text, blockId, start, end } = input; + const hasTarget = target !== undefined; + const hasBlockId = blockId !== undefined; + const hasStart = start !== undefined; + const hasEnd = end !== undefined; + + if (hasTarget && !isTextAddress(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + field: 'target', + value: target, + }); + } + + if (typeof text !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `text must be a string, got ${typeof text}.`, { + field: 'text', + value: text, + }); + } + + if (hasBlockId && typeof blockId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `blockId must be a string, got ${typeof blockId}.`, { + field: 'blockId', + value: blockId, + }); + } + + if (!hasTarget && !hasBlockId && !hasStart && !hasEnd) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'comments.add requires a target. Provide either target or blockId + start + end.', + ); + } + + if (hasTarget && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Cannot combine target with blockId/start/end. Use exactly one locator mode.', + { + fields: [ + 'target', + ...(hasBlockId ? ['blockId'] : []), + ...(hasStart ? ['start'] : []), + ...(hasEnd ? ['end'] : []), + ], + }, + ); + } + + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'start/end require blockId.', { + fields: ['blockId', ...(hasStart ? ['start'] : []), ...(hasEnd ? ['end'] : [])], + }); + } + + if (hasBlockId && !hasTarget) { + if (!hasStart || !hasEnd) { + throw new DocumentApiValidationError('INVALID_TARGET', 'blockId requires both start and end for comments.add.', { + fields: ['blockId', 'start', 'end'], + }); + } + } + + if (hasStart) assertNonNegativeInteger(start, 'start'); + if (hasEnd) assertNonNegativeInteger(end, 'end'); + if (hasStart && hasEnd && (start as number) > (end as number)) { + throw new DocumentApiValidationError('INVALID_TARGET', `start must be <= end, got start=${start}, end=${end}.`, { + fields: ['start', 'end'], + start, + end, + }); + } +} + +const MOVE_COMMENT_ALLOWED_KEYS = new Set(['commentId', 'target', 'blockId', 'start', 'end']); + +/** + * Validates MoveCommentInput and throws DocumentApiValidationError on violations. + */ +function validateMoveCommentInput(input: unknown): asserts input is MoveCommentInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'comments.move input must be a non-null object.'); + } + + assertNoUnknownFields(input, MOVE_COMMENT_ALLOWED_KEYS, 'comments.move'); + + const { commentId, target, blockId, start, end } = input; + const hasTarget = target !== undefined; + const hasBlockId = blockId !== undefined; + const hasStart = start !== undefined; + const hasEnd = end !== undefined; + + if (typeof commentId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `commentId must be a string, got ${typeof commentId}.`, { + field: 'commentId', + value: commentId, + }); + } + + if (hasTarget && !isTextAddress(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + field: 'target', + value: target, + }); + } + + if (hasBlockId && typeof blockId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `blockId must be a string, got ${typeof blockId}.`, { + field: 'blockId', + value: blockId, + }); + } + + if (!hasTarget && !hasBlockId && !hasStart && !hasEnd) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'comments.move requires a target. Provide either target or blockId + start + end.', + ); + } + + if (hasTarget && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Cannot combine target with blockId/start/end. Use exactly one locator mode.', + { + fields: [ + 'target', + ...(hasBlockId ? ['blockId'] : []), + ...(hasStart ? ['start'] : []), + ...(hasEnd ? ['end'] : []), + ], + }, + ); + } + + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'start/end require blockId.', { + fields: ['blockId', ...(hasStart ? ['start'] : []), ...(hasEnd ? ['end'] : [])], + }); + } + + if (hasBlockId && !hasTarget) { + if (!hasStart || !hasEnd) { + throw new DocumentApiValidationError('INVALID_TARGET', 'blockId requires both start and end for comments.move.', { + fields: ['blockId', 'start', 'end'], + }); + } + } + + if (hasStart) assertNonNegativeInteger(start, 'start'); + if (hasEnd) assertNonNegativeInteger(end, 'end'); + if (hasStart && hasEnd && (start as number) > (end as number)) { + throw new DocumentApiValidationError('INVALID_TARGET', `start must be <= end, got start=${start}, end=${end}.`, { + fields: ['start', 'end'], + start, + end, + }); + } +} + +/** + * Normalizes friendly locator fields into canonical TextAddress for comment inputs. + * Returns the input with `target` resolved if blockId+start+end was provided. + */ +function normalizeCommentTarget( + input: T, +): T & { target: TextAddress } { + if (input.target) return input as T & { target: TextAddress }; + + const target: TextAddress = { + kind: 'text', + blockId: input.blockId!, + range: { start: input.start!, end: input.end! }, + }; + + // Return a clean object with the canonical target — no leftover friendly fields. + const { blockId: _b, start: _s, end: _e, ...rest } = input; + return { ...rest, target } as T & { target: TextAddress }; +} + /** * Execute wrappers below are the canonical interception point for input * normalization and validation. Query-only operations currently pass through @@ -97,7 +303,9 @@ export type CommentsApi = CommentsAdapter; * Keep the wrappers to preserve this extension surface. */ export function executeAddComment(adapter: CommentsAdapter, input: AddCommentInput): Receipt { - return adapter.add(input); + validateAddCommentInput(input); + const normalized = normalizeCommentTarget(input); + return adapter.add(normalized); } export function executeEditComment(adapter: CommentsAdapter, input: EditCommentInput): Receipt { @@ -109,7 +317,9 @@ export function executeReplyToComment(adapter: CommentsAdapter, input: ReplyToCo } export function executeMoveComment(adapter: CommentsAdapter, input: MoveCommentInput): Receipt { - return adapter.move(input); + validateMoveCommentInput(input); + const normalized = normalizeCommentTarget(input); + return adapter.move(normalized); } export function executeResolveComment(adapter: CommentsAdapter, input: ResolveCommentInput): Receipt { diff --git a/packages/document-api/src/contract/metadata-types.ts b/packages/document-api/src/contract/metadata-types.ts index d6e6abab8..574461b63 100644 --- a/packages/document-api/src/contract/metadata-types.ts +++ b/packages/document-api/src/contract/metadata-types.ts @@ -16,6 +16,7 @@ export const PRE_APPLY_THROW_CODES = [ 'TRACK_CHANGE_COMMAND_UNAVAILABLE', 'CAPABILITY_UNAVAILABLE', 'INVALID_TARGET', + 'AMBIGUOUS_TARGET', ] as const; export type PreApplyThrowCode = (typeof PRE_APPLY_THROW_CODES)[number]; diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 816935967..4b39eca36 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -190,7 +190,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_TRACKED, + throws: [...T_NOT_FOUND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'replace.mdx', referenceGroup: 'core', @@ -204,7 +204,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['NO_OP'], - throws: T_NOT_FOUND_TRACKED, + throws: [...T_NOT_FOUND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'delete.mdx', referenceGroup: 'core', @@ -219,7 +219,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'format/bold.mdx', referenceGroup: 'format', @@ -233,7 +233,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'format/italic.mdx', referenceGroup: 'format', @@ -247,7 +247,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'format/underline.mdx', referenceGroup: 'format', @@ -261,7 +261,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'format/strikethrough.mdx', referenceGroup: 'format', @@ -276,7 +276,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET', 'AMBIGUOUS_TARGET'], }), referenceDocPath: 'create/paragraph.mdx', referenceGroup: 'create', @@ -290,7 +290,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET', 'AMBIGUOUS_TARGET'], }), referenceDocPath: 'create/heading.mdx', referenceGroup: 'create', @@ -327,7 +327,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: true, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'lists/insert.mdx', referenceGroup: 'lists', @@ -341,7 +341,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'lists/set-type.mdx', referenceGroup: 'lists', @@ -355,7 +355,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'lists/indent.mdx', referenceGroup: 'lists', @@ -369,7 +369,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'lists/outdent.mdx', referenceGroup: 'lists', @@ -383,7 +383,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['NO_OP', 'INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'lists/restart.mdx', referenceGroup: 'lists', @@ -397,7 +397,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET'], - throws: T_NOT_FOUND_COMMAND_TRACKED, + throws: [...T_NOT_FOUND_COMMAND_TRACKED, 'INVALID_TARGET'], }), referenceDocPath: 'lists/exit.mdx', referenceGroup: 'lists', @@ -412,7 +412,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: false, supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_COMMAND, + throws: [...T_NOT_FOUND_COMMAND, 'INVALID_TARGET'], }), referenceDocPath: 'comments/add.mdx', referenceGroup: 'comments', @@ -454,7 +454,7 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: false, supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], - throws: T_NOT_FOUND_COMMAND, + throws: [...T_NOT_FOUND_COMMAND, 'INVALID_TARGET'], }), referenceDocPath: 'comments/move.mdx', referenceGroup: 'comments', diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index d90d2a5a1..32627e91d 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -618,6 +618,49 @@ const capabilitiesOutputSchema = objectSchema( ); const strictEmptyObjectSchema = objectSchema({}); + +/** + * Shared JSON Schema constraints for inputs that accept either a canonical `target` + * or a block-relative `blockId` + `start` + `end` locator — but not both. + * + * Used by: delete, replace, format.*, comments.add, comments.move. + */ +const rangeLocatorConstraints = { + allOf: [ + { not: { required: ['target', 'blockId'] } }, + { not: { required: ['target', 'start'] } }, + { not: { required: ['target', 'end'] } }, + { if: { required: ['start'] }, then: { required: ['blockId', 'end'] } }, + { if: { required: ['end'] }, then: { required: ['blockId', 'start'] } }, + { if: { required: ['blockId'] }, then: { required: ['start', 'end'] } }, + ], + anyOf: [{ required: ['target'] }, { required: ['blockId', 'start', 'end'] }], +}; + +const rangeLocatorProperties = { + blockId: { type: 'string', description: 'Block ID for block-relative range targeting.' } as JsonSchema, + start: { + type: 'integer', + minimum: 0, + description: 'Start offset within the block identified by blockId.', + } as JsonSchema, + end: { type: 'integer', minimum: 0, description: 'End offset within the block identified by blockId.' } as JsonSchema, +}; + +/** + * Shared input schema for format operations (bold, italic, underline, strikethrough). + * All four accept identical input shapes. + */ +const formatInputSchema: JsonSchema = { + ...objectSchema( + { + target: textAddressSchema, + ...rangeLocatorProperties, + }, + [], + ), + ...rangeLocatorConstraints, +}; const insertInputSchema: JsonSchema = { ...objectSchema( { @@ -669,68 +712,56 @@ const operationSchemas: Record = { failure: textMutationFailureSchemaFor('insert'), }, replace: { - input: objectSchema( - { - target: textAddressSchema, - text: { type: 'string' }, - }, - ['target', 'text'], - ), + input: { + ...objectSchema( + { + target: textAddressSchema, + text: { type: 'string' }, + ...rangeLocatorProperties, + }, + ['text'], + ), + ...rangeLocatorConstraints, + }, output: textMutationResultSchemaFor('replace'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('replace'), }, delete: { - input: objectSchema( - { - target: textAddressSchema, - }, - ['target'], - ), + input: { + ...objectSchema( + { + target: textAddressSchema, + ...rangeLocatorProperties, + }, + [], + ), + ...rangeLocatorConstraints, + }, output: textMutationResultSchemaFor('delete'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('delete'), }, 'format.bold': { - input: objectSchema( - { - target: textAddressSchema, - }, - ['target'], - ), + input: formatInputSchema, output: textMutationResultSchemaFor('format.bold'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.bold'), }, 'format.italic': { - input: objectSchema( - { - target: textAddressSchema, - }, - ['target'], - ), + input: formatInputSchema, output: textMutationResultSchemaFor('format.italic'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.italic'), }, 'format.underline': { - input: objectSchema( - { - target: textAddressSchema, - }, - ['target'], - ), + input: formatInputSchema, output: textMutationResultSchemaFor('format.underline'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.underline'), }, 'format.strikethrough': { - input: objectSchema( - { - target: textAddressSchema, - }, - ['target'], - ), + input: formatInputSchema, output: textMutationResultSchemaFor('format.strikethrough'), success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.strikethrough'), @@ -741,20 +772,34 @@ const operationSchemas: Record = { oneOf: [ objectSchema({ kind: { const: 'documentStart' } }, ['kind']), objectSchema({ kind: { const: 'documentEnd' } }, ['kind']), - objectSchema( - { - kind: { const: 'before' }, - target: blockNodeAddressSchema, - }, - ['kind', 'target'], - ), - objectSchema( - { - kind: { const: 'after' }, - target: blockNodeAddressSchema, - }, - ['kind', 'target'], - ), + { + ...objectSchema( + { + kind: { const: 'before' }, + target: blockNodeAddressSchema, + nodeId: { + type: 'string', + description: 'Node ID shorthand — adapter resolves to a full BlockNodeAddress.', + }, + }, + ['kind'], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, + { + ...objectSchema( + { + kind: { const: 'after' }, + target: blockNodeAddressSchema, + nodeId: { + type: 'string', + description: 'Node ID shorthand — adapter resolves to a full BlockNodeAddress.', + }, + }, + ['kind'], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, ], }, text: { type: 'string' }, @@ -771,20 +816,34 @@ const operationSchemas: Record = { oneOf: [ objectSchema({ kind: { const: 'documentStart' } }, ['kind']), objectSchema({ kind: { const: 'documentEnd' } }, ['kind']), - objectSchema( - { - kind: { const: 'before' }, - target: blockNodeAddressSchema, - }, - ['kind', 'target'], - ), - objectSchema( - { - kind: { const: 'after' }, - target: blockNodeAddressSchema, - }, - ['kind', 'target'], - ), + { + ...objectSchema( + { + kind: { const: 'before' }, + target: blockNodeAddressSchema, + nodeId: { + type: 'string', + description: 'Node ID shorthand — adapter resolves to a full BlockNodeAddress.', + }, + }, + ['kind'], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, + { + ...objectSchema( + { + kind: { const: 'after' }, + target: blockNodeAddressSchema, + nodeId: { + type: 'string', + description: 'Node ID shorthand — adapter resolves to a full BlockNodeAddress.', + }, + }, + ['kind'], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, ], }, text: { type: 'string' }, @@ -811,62 +870,110 @@ const operationSchemas: Record = { output: listItemInfoSchema, }, 'lists.insert': { - input: objectSchema( - { - target: listItemAddressSchema, - position: listInsertPositionSchema, - text: { type: 'string' }, - }, - ['target', 'position'], - ), + input: { + ...objectSchema( + { + target: listItemAddressSchema, + nodeId: { type: 'string', description: 'Node ID shorthand — adapter resolves to a ListItemAddress.' }, + position: listInsertPositionSchema, + text: { type: 'string' }, + }, + ['position'], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, output: listsInsertResultSchemaFor('lists.insert'), success: listsInsertSuccessSchema, failure: listsFailureSchemaFor('lists.insert'), }, 'lists.setType': { - input: objectSchema( - { - target: listItemAddressSchema, - kind: listKindSchema, - }, - ['target', 'kind'], - ), + input: { + ...objectSchema( + { + target: listItemAddressSchema, + nodeId: { type: 'string', description: 'Node ID shorthand — adapter resolves to a ListItemAddress.' }, + kind: listKindSchema, + }, + ['kind'], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, output: listsMutateItemResultSchemaFor('lists.setType'), success: listsMutateItemSuccessSchema, failure: listsFailureSchemaFor('lists.setType'), }, 'lists.indent': { - input: objectSchema({ target: listItemAddressSchema }, ['target']), + input: { + ...objectSchema( + { + target: listItemAddressSchema, + nodeId: { type: 'string', description: 'Node ID shorthand — adapter resolves to a ListItemAddress.' }, + }, + [], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, output: listsMutateItemResultSchemaFor('lists.indent'), success: listsMutateItemSuccessSchema, failure: listsFailureSchemaFor('lists.indent'), }, 'lists.outdent': { - input: objectSchema({ target: listItemAddressSchema }, ['target']), + input: { + ...objectSchema( + { + target: listItemAddressSchema, + nodeId: { type: 'string', description: 'Node ID shorthand — adapter resolves to a ListItemAddress.' }, + }, + [], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, output: listsMutateItemResultSchemaFor('lists.outdent'), success: listsMutateItemSuccessSchema, failure: listsFailureSchemaFor('lists.outdent'), }, 'lists.restart': { - input: objectSchema({ target: listItemAddressSchema }, ['target']), + input: { + ...objectSchema( + { + target: listItemAddressSchema, + nodeId: { type: 'string', description: 'Node ID shorthand — adapter resolves to a ListItemAddress.' }, + }, + [], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, output: listsMutateItemResultSchemaFor('lists.restart'), success: listsMutateItemSuccessSchema, failure: listsFailureSchemaFor('lists.restart'), }, 'lists.exit': { - input: objectSchema({ target: listItemAddressSchema }, ['target']), + input: { + ...objectSchema( + { + target: listItemAddressSchema, + nodeId: { type: 'string', description: 'Node ID shorthand — adapter resolves to a ListItemAddress.' }, + }, + [], + ), + oneOf: [{ required: ['target'] }, { required: ['nodeId'] }], + }, output: listsExitResultSchemaFor('lists.exit'), success: listsExitSuccessSchema, failure: listsFailureSchemaFor('lists.exit'), }, 'comments.add': { - input: objectSchema( - { - target: textAddressSchema, - text: { type: 'string' }, - }, - ['target', 'text'], - ), + input: { + ...objectSchema( + { + target: textAddressSchema, + text: { type: 'string' }, + ...rangeLocatorProperties, + }, + ['text'], + ), + ...rangeLocatorConstraints, + }, output: receiptResultSchemaFor('comments.add'), success: receiptSuccessSchema, failure: receiptFailureResultSchemaFor('comments.add'), @@ -896,13 +1003,17 @@ const operationSchemas: Record = { failure: receiptFailureResultSchemaFor('comments.reply'), }, 'comments.move': { - input: objectSchema( - { - commentId: { type: 'string' }, - target: textAddressSchema, - }, - ['commentId', 'target'], - ), + input: { + ...objectSchema( + { + commentId: { type: 'string' }, + target: textAddressSchema, + ...rangeLocatorProperties, + }, + ['commentId'], + ), + ...rangeLocatorConstraints, + }, output: receiptResultSchemaFor('comments.move'), success: receiptSuccessSchema, failure: receiptFailureResultSchemaFor('comments.move'), diff --git a/packages/document-api/src/create/create.ts b/packages/document-api/src/create/create.ts index 5a73665f2..4130ae8e9 100644 --- a/packages/document-api/src/create/create.ts +++ b/packages/document-api/src/create/create.ts @@ -8,6 +8,7 @@ import type { CreateHeadingResult, HeadingCreateLocation, } from '../types/create.types.js'; +import { DocumentApiValidationError } from '../errors.js'; export interface CreateApi { paragraph(input: CreateParagraphInput, options?: MutationOptions): CreateParagraphResult; @@ -16,6 +17,41 @@ export interface CreateApi { export type CreateAdapter = CreateApi; +/** + * Validates the `at` location for create operations when `before`/`after` is used. + * Ensures either `target` or `nodeId` is provided, not both. + */ +function validateCreateLocation(at: ParagraphCreateLocation | HeadingCreateLocation, operationName: string): void { + if (at.kind !== 'before' && at.kind !== 'after') return; + + const loc = at as { kind: string; target?: unknown; nodeId?: unknown }; + const hasTarget = loc.target !== undefined; + const hasNodeId = loc.nodeId !== undefined; + + if (hasTarget && hasNodeId) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `Cannot combine at.target with at.nodeId on ${operationName} request. Use exactly one locator mode.`, + { fields: ['at.target', 'at.nodeId'] }, + ); + } + + if (!hasTarget && !hasNodeId) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} with at.kind="${at.kind}" requires either at.target or at.nodeId.`, + { fields: ['at.target', 'at.nodeId'] }, + ); + } + + if (hasNodeId && typeof loc.nodeId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `at.nodeId must be a string, got ${typeof loc.nodeId}.`, { + field: 'at.nodeId', + value: loc.nodeId, + }); + } +} + function normalizeParagraphCreateLocation(location?: ParagraphCreateLocation): ParagraphCreateLocation { return location ?? { kind: 'documentEnd' }; } @@ -32,7 +68,9 @@ export function executeCreateParagraph( input: CreateParagraphInput, options?: MutationOptions, ): CreateParagraphResult { - return adapter.paragraph(normalizeCreateParagraphInput(input), normalizeMutationOptions(options)); + const normalized = normalizeCreateParagraphInput(input); + validateCreateLocation(normalized.at!, 'create.paragraph'); + return adapter.paragraph(normalized, normalizeMutationOptions(options)); } function normalizeHeadingCreateLocation(location?: HeadingCreateLocation): HeadingCreateLocation { @@ -52,5 +90,7 @@ export function executeCreateHeading( input: CreateHeadingInput, options?: MutationOptions, ): CreateHeadingResult { - return adapter.heading(normalizeCreateHeadingInput(input), normalizeMutationOptions(options)); + const normalized = normalizeCreateHeadingInput(input); + validateCreateLocation(normalized.at!, 'create.heading'); + return adapter.heading(normalized, normalizeMutationOptions(options)); } diff --git a/packages/document-api/src/delete/delete.ts b/packages/document-api/src/delete/delete.ts index 6616814a8..5a64b5872 100644 --- a/packages/document-api/src/delete/delete.ts +++ b/packages/document-api/src/delete/delete.ts @@ -1,8 +1,115 @@ import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; import type { TextAddress, TextMutationReceipt } from '../types/index.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, isTextAddress, assertNoUnknownFields, assertNonNegativeInteger } from '../validation-primitives.js'; export interface DeleteInput { - target: TextAddress; + target?: TextAddress; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; +} + +const DELETE_INPUT_ALLOWED_KEYS = new Set(['target', 'blockId', 'start', 'end']); + +/** + * Validates DeleteInput and throws DocumentApiValidationError on violations. + * + * Validation order: + * 0. Input shape guard + * 1. Unknown field rejection + * 2. Type checks (target shape, blockId type) + * 3. At least one locator mode required + * 4. Mode exclusivity (target vs blockId+start+end) + * 5. Range completeness (blockId requires start+end) + * 6. Orphaned start/end without blockId + * 7. Numeric bounds (start/end >= 0, integer, start <= end) + */ +function validateDeleteInput(input: unknown): asserts input is DeleteInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'Delete input must be a non-null object.'); + } + + assertNoUnknownFields(input, DELETE_INPUT_ALLOWED_KEYS, 'delete'); + + const { target, blockId, start, end } = input; + const hasTarget = target !== undefined; + const hasBlockId = blockId !== undefined; + const hasStart = start !== undefined; + const hasEnd = end !== undefined; + + // Type checks + if (hasTarget && !isTextAddress(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + field: 'target', + value: target, + }); + } + + if (hasBlockId && typeof blockId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `blockId must be a string, got ${typeof blockId}.`, { + field: 'blockId', + value: blockId, + }); + } + + // At least one locator mode required (delete has no default target) + if (!hasTarget && !hasBlockId && !hasStart && !hasEnd) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Delete requires a target. Provide either target or blockId + start + end.', + ); + } + + // Mode exclusivity — target vs blockId/start/end + if (hasTarget && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Cannot combine target with blockId/start/end. Use exactly one locator mode.', + { + fields: [ + 'target', + ...(hasBlockId ? ['blockId'] : []), + ...(hasStart ? ['start'] : []), + ...(hasEnd ? ['end'] : []), + ], + }, + ); + } + + // Orphaned start/end without blockId + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'start/end require blockId.', { + fields: ['blockId', ...(hasStart ? ['start'] : []), ...(hasEnd ? ['end'] : [])], + }); + } + + // Range completeness — blockId requires start+end + if (hasBlockId && !hasTarget) { + if (!hasStart || !hasEnd) { + throw new DocumentApiValidationError('INVALID_TARGET', 'blockId requires both start and end for delete.', { + fields: ['blockId', 'start', 'end'], + }); + } + } + + // Numeric bounds + if (hasStart) { + assertNonNegativeInteger(start, 'start'); + } + if (hasEnd) { + assertNonNegativeInteger(end, 'end'); + } + if (hasStart && hasEnd && (start as number) > (end as number)) { + throw new DocumentApiValidationError('INVALID_TARGET', `start must be <= end, got start=${start}, end=${end}.`, { + fields: ['start', 'end'], + start, + end, + }); + } } export function executeDelete( @@ -10,13 +117,15 @@ export function executeDelete( input: DeleteInput, options?: MutationOptions, ): TextMutationReceipt { - return executeWrite( - adapter, - { - kind: 'delete', - target: input.target, - text: '', - }, - options, - ); + validateDeleteInput(input); + + const { target, blockId, start, end } = input; + + // Pass friendly locator fields through to the adapter for normalization. + if (blockId !== undefined) { + return executeWrite(adapter, { kind: 'delete', blockId, start, end, text: '' }, options); + } + + // Canonical target path + return executeWrite(adapter, { kind: 'delete', target: target!, text: '' }, options); } diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts index 2b9f02da9..80d0b9ffe 100644 --- a/packages/document-api/src/format/format.ts +++ b/packages/document-api/src/format/format.ts @@ -1,32 +1,159 @@ import { normalizeMutationOptions, type MutationOptions } from '../write/write.js'; import type { TextAddress, TextMutationReceipt } from '../types/index.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, isTextAddress, assertNoUnknownFields, assertNonNegativeInteger } from '../validation-primitives.js'; /** * Input payload for `format.bold`. */ export interface FormatBoldInput { - target: TextAddress; + target?: TextAddress; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; } /** * Input payload for `format.italic`. */ export interface FormatItalicInput { - target: TextAddress; + target?: TextAddress; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; } /** * Input payload for `format.underline`. */ export interface FormatUnderlineInput { - target: TextAddress; + target?: TextAddress; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; } /** * Input payload for `format.strikethrough`. */ export interface FormatStrikethroughInput { - target: TextAddress; + target?: TextAddress; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; +} + +const FORMAT_INPUT_ALLOWED_KEYS = new Set(['target', 'blockId', 'start', 'end']); + +/** + * Validates a format operation input and throws DocumentApiValidationError on violations. + * + * Validation order: + * 0. Input shape guard + * 1. Unknown field rejection + * 2. Type checks (target shape, blockId type) + * 3. At least one locator mode required + * 4. Mode exclusivity (target vs blockId+start+end) + * 5. Range completeness (blockId requires start+end) + * 6. Orphaned start/end without blockId + * 7. Numeric bounds (start/end >= 0, integer, start <= end) + */ +function validateFormatInput(input: unknown, operationName: string): asserts input is FormatBoldInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_TARGET', `${operationName} input must be a non-null object.`); + } + + assertNoUnknownFields(input, FORMAT_INPUT_ALLOWED_KEYS, operationName); + + const { target, blockId, start, end } = input; + const hasTarget = target !== undefined; + const hasBlockId = blockId !== undefined; + const hasStart = start !== undefined; + const hasEnd = end !== undefined; + + // Type checks + if (hasTarget && !isTextAddress(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + field: 'target', + value: target, + }); + } + + if (hasBlockId && typeof blockId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `blockId must be a string, got ${typeof blockId}.`, { + field: 'blockId', + value: blockId, + }); + } + + // At least one locator mode required + if (!hasTarget && !hasBlockId && !hasStart && !hasEnd) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} requires a target. Provide either target or blockId + start + end.`, + ); + } + + // Mode exclusivity — target vs blockId/start/end + if (hasTarget && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Cannot combine target with blockId/start/end. Use exactly one locator mode.', + { + fields: [ + 'target', + ...(hasBlockId ? ['blockId'] : []), + ...(hasStart ? ['start'] : []), + ...(hasEnd ? ['end'] : []), + ], + }, + ); + } + + // Orphaned start/end without blockId + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'start/end require blockId.', { + fields: ['blockId', ...(hasStart ? ['start'] : []), ...(hasEnd ? ['end'] : [])], + }); + } + + // Range completeness — blockId requires start+end + if (hasBlockId && !hasTarget) { + if (!hasStart || !hasEnd) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `blockId requires both start and end for ${operationName}.`, + { fields: ['blockId', 'start', 'end'] }, + ); + } + } + + // Numeric bounds + if (hasStart) { + assertNonNegativeInteger(start, 'start'); + } + if (hasEnd) { + assertNonNegativeInteger(end, 'end'); + } + if (hasStart && hasEnd && (start as number) > (end as number)) { + throw new DocumentApiValidationError('INVALID_TARGET', `start must be <= end, got start=${start}, end=${end}.`, { + fields: ['start', 'end'], + start, + end, + }); + } } export interface FormatAdapter { @@ -63,6 +190,7 @@ export function executeFormatBold( input: FormatBoldInput, options?: MutationOptions, ): TextMutationReceipt { + validateFormatInput(input, 'format.bold'); return adapter.bold(input, normalizeMutationOptions(options)); } @@ -87,6 +215,7 @@ export function executeFormatItalic( input: FormatItalicInput, options?: MutationOptions, ): TextMutationReceipt { + validateFormatInput(input, 'format.italic'); return adapter.italic(input, normalizeMutationOptions(options)); } @@ -111,6 +240,7 @@ export function executeFormatUnderline( input: FormatUnderlineInput, options?: MutationOptions, ): TextMutationReceipt { + validateFormatInput(input, 'format.underline'); return adapter.underline(input, normalizeMutationOptions(options)); } @@ -135,5 +265,6 @@ export function executeFormatStrikethrough( input: FormatStrikethroughInput, options?: MutationOptions, ): TextMutationReceipt { + validateFormatInput(input, 'format.strikethrough'); return adapter.strikethrough(input, normalizeMutationOptions(options)); } diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 92fd76834..dfc3d71c9 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -1036,4 +1036,1132 @@ describe('createDocumentApi', () => { ); }); }); + + describe('replace friendly locator validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + // -- Truth table: valid cases -- + + it('accepts canonical target', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + const result = api.replace({ target, text: 'hello' }); + expect(result.success).toBe(true); + }); + + it('accepts blockId + start + end', () => { + const api = makeApi(); + const result = api.replace({ blockId: 'p1', start: 0, end: 5, text: 'hello' }); + expect(result.success).toBe(true); + }); + + it('allows collapsed range (start === end) through pre-apply', () => { + const api = makeApi(); + const result = api.replace({ blockId: 'p1', start: 3, end: 3, text: 'hello' }); + expect(result.success).toBe(true); + }); + + // -- Truth table: invalid cases -- + + it('rejects no target at all', () => { + const api = makeApi(); + expectValidationError(() => api.replace({ text: 'hello' } as any), 'Replace requires a target'); + }); + + it('rejects target + blockId (mixed modes)', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + expectValidationError( + () => api.replace({ target, blockId: 'p2', start: 0, end: 5, text: 'hello' }), + 'Cannot combine target with blockId/start/end', + ); + }); + + it('rejects blockId alone (incomplete range)', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', text: 'hello' } as any), + 'blockId requires both start and end', + ); + }); + + it('rejects blockId + start without end', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', start: 0, text: 'hello' } as any), + 'blockId requires both start and end', + ); + }); + + it('rejects blockId + end without start', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', end: 5, text: 'hello' } as any), + 'blockId requires both start and end', + ); + }); + + it('rejects start/end without blockId', () => { + const api = makeApi(); + expectValidationError(() => api.replace({ start: 0, end: 5, text: 'hello' } as any), 'start/end require blockId'); + }); + + it('rejects start without blockId', () => { + const api = makeApi(); + expectValidationError(() => api.replace({ start: 0, text: 'hello' } as any), 'start/end require blockId'); + }); + + // -- Numeric bounds -- + + it('rejects negative start', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', start: -1, end: 5, text: 'hello' }), + 'non-negative integer', + ); + }); + + it('rejects non-integer end', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', start: 0, end: 5.5, text: 'hello' }), + 'non-negative integer', + ); + }); + + it('rejects start > end', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', start: 10, end: 5, text: 'hello' }), + 'start must be <= end', + ); + }); + + // -- Type checks -- + + it('rejects non-string blockId', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 42, start: 0, end: 5, text: 'hello' } as any), + 'blockId must be a string', + ); + }); + + it('rejects non-string text', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', start: 0, end: 5, text: 42 } as any), + 'text must be a string', + ); + }); + + // -- Input shape -- + + it('rejects null input', () => { + const api = makeApi(); + expectValidationError(() => api.replace(null as any), 'non-null object'); + }); + + it('rejects unknown fields', () => { + const api = makeApi(); + expectValidationError( + () => api.replace({ blockId: 'p1', start: 0, end: 5, text: 'hi', block_id: 'x' } as any), + 'Unknown field "block_id"', + ); + }); + + // -- Error shape -- + + it('throws DocumentApiValidationError (not plain Error)', () => { + const api = makeApi(); + try { + api.replace({ text: 'hello' } as any); + expect.fail('Expected error'); + } catch (err: unknown) { + expect((err as Error).constructor.name).toBe('DocumentApiValidationError'); + expect((err as { code: string }).code).toBe('INVALID_TARGET'); + } + }); + + // -- Canonical payload parity -- + + it('sends same adapter request for replace({ target, text }) as before', () => { + const writeAdpt = makeWriteAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: writeAdpt, + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + api.replace({ target, text: 'Hello' }); + expect(writeAdpt.write).toHaveBeenCalledWith( + { kind: 'replace', target, text: 'Hello' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + + it('passes blockId + start + end through to adapter for normalization', () => { + const writeAdpt = makeWriteAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: writeAdpt, + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + api.replace({ blockId: 'p1', start: 0, end: 5, text: 'hello' }); + expect(writeAdpt.write).toHaveBeenCalledWith( + { kind: 'replace', blockId: 'p1', start: 0, end: 5, text: 'hello' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + }); + + describe('delete friendly locator validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + // -- Truth table: valid cases -- + + it('accepts canonical target', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + const result = api.delete({ target }); + expect(result.success).toBe(true); + }); + + it('accepts blockId + start + end', () => { + const api = makeApi(); + const result = api.delete({ blockId: 'p1', start: 0, end: 5 }); + expect(result.success).toBe(true); + }); + + it('allows collapsed range (start === end) through pre-apply', () => { + const api = makeApi(); + const result = api.delete({ blockId: 'p1', start: 3, end: 3 }); + expect(result.success).toBe(true); + }); + + // -- Truth table: invalid cases -- + + it('rejects no target at all', () => { + const api = makeApi(); + expectValidationError(() => api.delete({} as any), 'Delete requires a target'); + }); + + it('rejects target + blockId (mixed modes)', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + expectValidationError( + () => api.delete({ target, blockId: 'p2', start: 0, end: 5 }), + 'Cannot combine target with blockId/start/end', + ); + }); + + it('rejects blockId alone (incomplete range)', () => { + const api = makeApi(); + expectValidationError(() => api.delete({ blockId: 'p1' } as any), 'blockId requires both start and end'); + }); + + it('rejects start/end without blockId', () => { + const api = makeApi(); + expectValidationError(() => api.delete({ start: 0, end: 5 } as any), 'start/end require blockId'); + }); + + // -- Numeric bounds -- + + it('rejects negative start', () => { + const api = makeApi(); + expectValidationError(() => api.delete({ blockId: 'p1', start: -1, end: 5 }), 'non-negative integer'); + }); + + it('rejects start > end', () => { + const api = makeApi(); + expectValidationError(() => api.delete({ blockId: 'p1', start: 10, end: 5 }), 'start must be <= end'); + }); + + // -- Type checks -- + + it('rejects non-string blockId', () => { + const api = makeApi(); + expectValidationError(() => api.delete({ blockId: 42, start: 0, end: 5 } as any), 'blockId must be a string'); + }); + + // -- Input shape -- + + it('rejects null input', () => { + const api = makeApi(); + expectValidationError(() => api.delete(null as any), 'non-null object'); + }); + + it('rejects unknown fields', () => { + const api = makeApi(); + expectValidationError( + () => api.delete({ blockId: 'p1', start: 0, end: 5, offset: 3 } as any), + 'Unknown field "offset"', + ); + }); + + // -- Canonical payload parity -- + + it('sends same adapter request for delete({ target }) as before', () => { + const writeAdpt = makeWriteAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: writeAdpt, + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + api.delete({ target }); + expect(writeAdpt.write).toHaveBeenCalledWith( + { kind: 'delete', target, text: '' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + + it('passes blockId + start + end through to adapter for normalization', () => { + const writeAdpt = makeWriteAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: writeAdpt, + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + api.delete({ blockId: 'p1', start: 0, end: 5 }); + expect(writeAdpt.write).toHaveBeenCalledWith( + { kind: 'delete', blockId: 'p1', start: 0, end: 5, text: '' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + }); + + describe('format.* friendly locator validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + const FORMAT_METHODS = ['bold', 'italic', 'underline', 'strikethrough'] as const; + + for (const method of FORMAT_METHODS) { + describe(`format.${method}`, () => { + // -- Valid cases -- + + it('accepts canonical target', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + const result = api.format[method]({ target }); + expect(result.success).toBe(true); + }); + + it('accepts blockId + start + end', () => { + const api = makeApi(); + const result = api.format[method]({ blockId: 'p1', start: 0, end: 5 }); + expect(result.success).toBe(true); + }); + + it('allows collapsed range (start === end) through pre-apply', () => { + const api = makeApi(); + const result = api.format[method]({ blockId: 'p1', start: 3, end: 3 }); + expect(result.success).toBe(true); + }); + + // -- Invalid cases -- + + it('rejects no target at all', () => { + const api = makeApi(); + expectValidationError(() => api.format[method]({} as any), 'requires a target'); + }); + + it('rejects target + blockId (mixed modes)', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + expectValidationError( + () => api.format[method]({ target, blockId: 'p2', start: 0, end: 5 }), + 'Cannot combine target with blockId/start/end', + ); + }); + + it('rejects blockId alone (incomplete range)', () => { + const api = makeApi(); + expectValidationError( + () => api.format[method]({ blockId: 'p1' } as any), + 'blockId requires both start and end', + ); + }); + + it('rejects start/end without blockId', () => { + const api = makeApi(); + expectValidationError(() => api.format[method]({ start: 0, end: 5 } as any), 'start/end require blockId'); + }); + + // -- Numeric bounds -- + + it('rejects negative start', () => { + const api = makeApi(); + expectValidationError(() => api.format[method]({ blockId: 'p1', start: -1, end: 5 }), 'non-negative integer'); + }); + + it('rejects start > end', () => { + const api = makeApi(); + expectValidationError(() => api.format[method]({ blockId: 'p1', start: 10, end: 5 }), 'start must be <= end'); + }); + + // -- Input shape -- + + it('rejects null input', () => { + const api = makeApi(); + expectValidationError(() => api.format[method](null as any), 'non-null object'); + }); + + it('rejects unknown fields', () => { + const api = makeApi(); + expectValidationError( + () => api.format[method]({ blockId: 'p1', start: 0, end: 5, offset: 3 } as any), + 'Unknown field "offset"', + ); + }); + }); + } + + // -- Canonical payload parity -- + + it('passes canonical target through to format adapter unchanged', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; + api.format.bold({ target }); + expect(formatAdpt.bold).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + }); + + it('passes blockId + start + end through to format adapter for normalization', () => { + const formatAdpt = makeFormatAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: formatAdpt, + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + api.format.bold({ blockId: 'p1', start: 0, end: 5 }); + expect(formatAdpt.bold).toHaveBeenCalledWith( + { blockId: 'p1', start: 0, end: 5 }, + { changeMode: 'direct', dryRun: false }, + ); + }); + }); + + describe('comments.add friendly locator validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + // -- Valid cases -- + + it('accepts canonical target', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + const result = api.comments.add({ target, text: 'comment' }); + expect(result.success).toBe(true); + }); + + it('accepts blockId + start + end', () => { + const api = makeApi(); + const result = api.comments.add({ blockId: 'p1', start: 0, end: 5, text: 'comment' }); + expect(result.success).toBe(true); + }); + + // -- Invalid cases -- + + it('rejects no target at all', () => { + const api = makeApi(); + expectValidationError(() => api.comments.add({ text: 'comment' } as any), 'requires a target'); + }); + + it('rejects target + blockId (mixed modes)', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + expectValidationError( + () => api.comments.add({ target, blockId: 'p2', start: 0, end: 5, text: 'comment' }), + 'Cannot combine target with blockId/start/end', + ); + }); + + it('rejects blockId alone (incomplete range)', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.add({ blockId: 'p1', text: 'comment' } as any), + 'blockId requires both start and end', + ); + }); + + it('rejects start/end without blockId', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.add({ start: 0, end: 5, text: 'comment' } as any), + 'start/end require blockId', + ); + }); + + it('rejects negative start', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.add({ blockId: 'p1', start: -1, end: 5, text: 'comment' }), + 'non-negative integer', + ); + }); + + it('rejects start > end', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.add({ blockId: 'p1', start: 10, end: 5, text: 'comment' }), + 'start must be <= end', + ); + }); + + it('rejects null input', () => { + const api = makeApi(); + expectValidationError(() => api.comments.add(null as any), 'non-null object'); + }); + + it('rejects unknown fields', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.add({ blockId: 'p1', start: 0, end: 5, text: 'comment', offset: 3 } as any), + 'Unknown field "offset"', + ); + }); + + // -- Canonical payload parity -- + + it('normalizes blockId + start + end to target before passing to adapter', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + api.comments.add({ blockId: 'p1', start: 0, end: 5, text: 'comment' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'comment', + }); + }); + + it('sends canonical target through unchanged', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + api.comments.add({ target, text: 'comment' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }); + }); + }); + + describe('comments.move friendly locator validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + // -- Valid cases -- + + it('accepts canonical target', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + const result = api.comments.move({ commentId: 'c1', target }); + expect(result.success).toBe(true); + }); + + it('accepts blockId + start + end', () => { + const api = makeApi(); + const result = api.comments.move({ commentId: 'c1', blockId: 'p1', start: 0, end: 5 }); + expect(result.success).toBe(true); + }); + + // -- Invalid cases -- + + it('rejects no target at all', () => { + const api = makeApi(); + expectValidationError(() => api.comments.move({ commentId: 'c1' } as any), 'requires a target'); + }); + + it('rejects target + blockId (mixed modes)', () => { + const api = makeApi(); + const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } as const; + expectValidationError( + () => api.comments.move({ commentId: 'c1', target, blockId: 'p2', start: 0, end: 5 }), + 'Cannot combine target with blockId/start/end', + ); + }); + + it('rejects blockId alone (incomplete range)', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.move({ commentId: 'c1', blockId: 'p1' } as any), + 'blockId requires both start and end', + ); + }); + + it('rejects start > end', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.move({ commentId: 'c1', blockId: 'p1', start: 10, end: 5 }), + 'start must be <= end', + ); + }); + + it('rejects non-string commentId', () => { + const api = makeApi(); + expectValidationError( + () => api.comments.move({ commentId: 42, blockId: 'p1', start: 0, end: 5 } as any), + 'commentId must be a string', + ); + }); + + // -- Canonical payload parity -- + + it('normalizes blockId + start + end to target before passing to adapter', () => { + const commentsAdpt = makeCommentsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: commentsAdpt, + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + + api.comments.move({ commentId: 'c1', blockId: 'p1', start: 0, end: 5 }); + expect(commentsAdpt.move).toHaveBeenCalledWith({ + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + }); + }); + }); + + describe('create.* nodeId shorthand validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + // -- Valid cases -- + + it('accepts at.target (canonical) for create.paragraph', () => { + const api = makeApi(); + const result = api.create.paragraph({ + at: { kind: 'before', target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }, + text: 'Hello', + }); + expect(result.success).toBe(true); + }); + + it('accepts at.nodeId (shorthand) for create.paragraph', () => { + const api = makeApi(); + const result = api.create.paragraph({ + at: { kind: 'after', nodeId: 'p1' }, + text: 'Hello', + }); + expect(result.success).toBe(true); + }); + + it('accepts documentEnd (no target/nodeId needed)', () => { + const api = makeApi(); + const result = api.create.paragraph({ at: { kind: 'documentEnd' }, text: 'Hello' }); + expect(result.success).toBe(true); + }); + + it('accepts documentStart (no target/nodeId needed)', () => { + const api = makeApi(); + const result = api.create.paragraph({ at: { kind: 'documentStart' }, text: 'Hello' }); + expect(result.success).toBe(true); + }); + + // -- Invalid cases -- + + it('rejects at.target + at.nodeId (mixed modes) for create.paragraph', () => { + const api = makeApi(); + expectValidationError( + () => + api.create.paragraph({ + at: { + kind: 'before', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + nodeId: 'p2', + } as any, + text: 'Hello', + }), + 'Cannot combine at.target with at.nodeId', + ); + }); + + it('rejects before/after with neither target nor nodeId', () => { + const api = makeApi(); + expectValidationError( + () => api.create.paragraph({ at: { kind: 'before' } as any, text: 'Hello' }), + 'requires either at.target or at.nodeId', + ); + }); + + it('rejects non-string at.nodeId', () => { + const api = makeApi(); + expectValidationError( + () => api.create.paragraph({ at: { kind: 'before', nodeId: 42 } as any, text: 'Hello' }), + 'at.nodeId must be a string', + ); + }); + + // -- Heading -- + + it('accepts at.nodeId for create.heading', () => { + const api = makeApi(); + const result = api.create.heading({ + level: 2, + at: { kind: 'after', nodeId: 'p1' }, + text: 'Hello', + }); + expect(result.success).toBe(true); + }); + + it('rejects mixed modes for create.heading', () => { + const api = makeApi(); + expectValidationError( + () => + api.create.heading({ + level: 2, + at: { + kind: 'after', + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + nodeId: 'p2', + } as any, + text: 'Hello', + }), + 'Cannot combine at.target with at.nodeId', + ); + }); + + // -- Parity -- + + it('passes at.nodeId through to adapter for resolution', () => { + const createAdpt = makeCreateAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: createAdpt, + lists: makeListsAdapter(), + }); + + api.create.paragraph({ at: { kind: 'before', nodeId: 'abc' }, text: 'Hello' }); + expect(createAdpt.paragraph).toHaveBeenCalledWith( + { at: { kind: 'before', nodeId: 'abc' }, text: 'Hello' }, + { changeMode: 'direct', dryRun: false }, + ); + }); + }); + + describe('lists.* nodeId shorthand validation', () => { + function makeApi() { + return createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: makeListsAdapter(), + }); + } + + function expectValidationError(fn: () => void, messageMatch?: string | RegExp) { + try { + fn(); + expect.fail('Expected DocumentApiValidationError to be thrown'); + } catch (err: unknown) { + const e = err as { name: string; code: string; message: string }; + expect(e.name).toBe('DocumentApiValidationError'); + expect(e.code).toBe('INVALID_TARGET'); + if (messageMatch) { + if (typeof messageMatch === 'string') { + expect(e.message).toContain(messageMatch); + } else { + expect(e.message).toMatch(messageMatch); + } + } + } + } + + const target = { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } as const; + + // -- Valid cases -- + + it('accepts canonical target for lists.indent', () => { + const api = makeApi(); + const result = api.lists.indent({ target }); + expect(result.success).toBe(true); + }); + + it('accepts nodeId shorthand for lists.indent', () => { + const api = makeApi(); + const result = api.lists.indent({ nodeId: 'li-1' }); + expect(result.success).toBe(true); + }); + + it('accepts nodeId shorthand for lists.insert', () => { + const api = makeApi(); + const result = api.lists.insert({ nodeId: 'li-1', position: 'after', text: 'New' }); + expect(result.success).toBe(true); + }); + + it('accepts nodeId shorthand for lists.setType', () => { + const api = makeApi(); + const result = api.lists.setType({ nodeId: 'li-1', kind: 'bullet' }); + expect(result.success).toBe(true); + }); + + // -- Invalid cases -- + + it('rejects target + nodeId (mixed modes)', () => { + const api = makeApi(); + expectValidationError(() => api.lists.indent({ target, nodeId: 'li-2' }), 'Cannot combine target with nodeId'); + }); + + it('rejects no target and no nodeId', () => { + const api = makeApi(); + expectValidationError(() => api.lists.indent({} as any), 'requires a target'); + }); + + it('rejects non-string nodeId', () => { + const api = makeApi(); + expectValidationError(() => api.lists.indent({ nodeId: 42 } as any), 'nodeId must be a string'); + }); + + // -- All list mutation operations validate -- + + const LISTS_MUTATIONS = ['outdent', 'restart', 'exit'] as const; + for (const method of LISTS_MUTATIONS) { + it(`rejects mixed modes for lists.${method}`, () => { + const api = makeApi(); + expectValidationError(() => api.lists[method]({ target, nodeId: 'li-2' }), 'Cannot combine target with nodeId'); + }); + + it(`accepts nodeId for lists.${method}`, () => { + const api = makeApi(); + const result = api.lists[method]({ nodeId: 'li-1' }); + expect(result.success).toBe(true); + }); + } + + // -- Parity -- + + it('passes nodeId through to adapter for resolution', () => { + const listsAdpt = makeListsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: listsAdpt, + }); + + api.lists.indent({ nodeId: 'li-1' }); + expect(listsAdpt.indent).toHaveBeenCalledWith({ nodeId: 'li-1' }, { changeMode: 'direct', dryRun: false }); + }); + + it('passes canonical target through to adapter unchanged', () => { + const listsAdpt = makeListsAdapter(); + const api = createDocumentApi({ + find: makeFindAdapter(QUERY_RESULT), + getNode: makeGetNodeAdapter(PARAGRAPH_INFO), + getText: makeGetTextAdapter(), + info: makeInfoAdapter(), + capabilities: makeCapabilitiesAdapter(), + comments: makeCommentsAdapter(), + write: makeWriteAdapter(), + format: makeFormatAdapter(), + trackChanges: makeTrackChangesAdapter(), + create: makeCreateAdapter(), + lists: listsAdpt, + }); + + api.lists.indent({ target }); + expect(listsAdpt.indent).toHaveBeenCalledWith({ target }, { changeMode: 'direct', dryRun: false }); + }); + }); }); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 4025d6e48..b373d9bd2 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -180,6 +180,8 @@ export type { export type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments/comments.types.js'; export { DocumentApiValidationError } from './errors.js'; export type { InsertInput } from './insert/insert.js'; +export type { ReplaceInput } from './replace/replace.js'; +export type { DeleteInput } from './delete/delete.js'; /** * Callable capability accessor returned by `createDocumentApi`. diff --git a/packages/document-api/src/insert/insert.ts b/packages/document-api/src/insert/insert.ts index 5e5aaa8f5..f742d5f51 100644 --- a/packages/document-api/src/insert/insert.ts +++ b/packages/document-api/src/insert/insert.ts @@ -1,6 +1,7 @@ import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; import type { TextAddress, TextMutationReceipt } from '../types/index.js'; import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, isTextAddress, assertNoUnknownFields, assertNonNegativeInteger } from '../validation-primitives.js'; export interface InsertInput { target?: TextAddress; @@ -18,31 +19,9 @@ export interface InsertInput { */ const INSERT_INPUT_ALLOWED_KEYS = new Set(['text', 'target', 'blockId', 'offset']); -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value != null && !Array.isArray(value); -} - -function isInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isInteger(value); -} - -function isTextAddress(value: unknown): value is TextAddress { - if (!isRecord(value)) return false; - if (value.kind !== 'text') return false; - if (typeof value.blockId !== 'string') return false; - - const range = value.range; - if (!isRecord(range)) return false; - if (!isInteger(range.start) || !isInteger(range.end)) return false; - return range.start <= range.end; -} - /** * Validates InsertInput and throws DocumentApiValidationError on violations. * - * This is the first input validation in an execute* function in the document-api — - * a new pattern. Previously all validation lived in the adapter layer. - * * Validation order: * 0. Input shape guard (must be non-null plain object) * 1. `pos` runtime rejection (PR A: not yet supported) @@ -58,27 +37,17 @@ function validateInsertInput(input: unknown): asserts input is InsertInput { throw new DocumentApiValidationError('INVALID_TARGET', 'Insert input must be a non-null object.'); } - const inputObj = input; - // Step 1: pos runtime rejection (PR A — pos is not yet supported) - if ('pos' in inputObj) { + if ('pos' in input) { throw new DocumentApiValidationError('INVALID_TARGET', 'pos locator is not yet supported.', { field: 'pos', }); } // Step 2: Unknown field rejection (strict allowlist) - for (const key of Object.keys(inputObj)) { - if (!INSERT_INPUT_ALLOWED_KEYS.has(key)) { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - `Unknown field "${key}" on insert input. Allowed fields: ${[...INSERT_INPUT_ALLOWED_KEYS].join(', ')}.`, - { field: key }, - ); - } - } + assertNoUnknownFields(input, INSERT_INPUT_ALLOWED_KEYS, 'insert'); - const { target, text, blockId, offset } = inputObj; + const { target, text, blockId, offset } = input; const hasTarget = target !== undefined; const hasBlockId = blockId !== undefined; const hasOffset = offset !== undefined; @@ -130,13 +99,7 @@ function validateInsertInput(input: unknown): asserts input is InsertInput { // Step 6: Numeric bounds — offset must be a non-negative integer if (hasOffset) { - if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - `offset must be a non-negative integer, got ${JSON.stringify(offset)}.`, - { field: 'offset', value: offset }, - ); - } + assertNonNegativeInteger(offset, 'offset'); } } diff --git a/packages/document-api/src/lists/lists.ts b/packages/document-api/src/lists/lists.ts index ab78b4e58..f4a369dc3 100644 --- a/packages/document-api/src/lists/lists.ts +++ b/packages/document-api/src/lists/lists.ts @@ -1,5 +1,6 @@ import type { MutationOptions } from '../write/write.js'; import { normalizeMutationOptions } from '../write/write.js'; +import { DocumentApiValidationError } from '../errors.js'; import type { ListInsertInput, ListSetTypeInput, @@ -25,6 +26,37 @@ export type { ListItemInfo, } from './lists.types.js'; +/** + * Validates that a list operation input has exactly one target locator mode: + * either `target` (canonical ListItemAddress) or `nodeId` (shorthand). + */ +function validateListTarget(input: { target?: unknown; nodeId?: unknown }, operationName: string): void { + const hasTarget = input.target !== undefined; + const hasNodeId = input.nodeId !== undefined; + + if (hasTarget && hasNodeId) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `Cannot combine target with nodeId on ${operationName} request. Use exactly one locator mode.`, + { fields: ['target', 'nodeId'] }, + ); + } + + if (!hasTarget && !hasNodeId) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${operationName} requires a target. Provide either target or nodeId.`, + ); + } + + if (hasNodeId && typeof input.nodeId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `nodeId must be a string, got ${typeof input.nodeId}.`, { + field: 'nodeId', + value: input.nodeId, + }); + } +} + export interface ListsAdapter { /** List items matching the given query. */ list(query?: ListsListQuery): ListsListResult; @@ -59,6 +91,7 @@ export function executeListsInsert( input: ListInsertInput, options?: MutationOptions, ): ListsInsertResult { + validateListTarget(input, 'lists.insert'); return adapter.insert(input, normalizeMutationOptions(options)); } @@ -67,6 +100,7 @@ export function executeListsSetType( input: ListSetTypeInput, options?: MutationOptions, ): ListsMutateItemResult { + validateListTarget(input, 'lists.setType'); return adapter.setType(input, normalizeMutationOptions(options)); } @@ -75,6 +109,7 @@ export function executeListsIndent( input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { + validateListTarget(input, 'lists.indent'); return adapter.indent(input, normalizeMutationOptions(options)); } @@ -83,6 +118,7 @@ export function executeListsOutdent( input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { + validateListTarget(input, 'lists.outdent'); return adapter.outdent(input, normalizeMutationOptions(options)); } @@ -91,6 +127,7 @@ export function executeListsRestart( input: ListTargetInput, options?: MutationOptions, ): ListsMutateItemResult { + validateListTarget(input, 'lists.restart'); return adapter.restart(input, normalizeMutationOptions(options)); } @@ -99,5 +136,6 @@ export function executeListsExit( input: ListTargetInput, options?: MutationOptions, ): ListsExitResult { + validateListTarget(input, 'lists.exit'); return adapter.exit(input, normalizeMutationOptions(options)); } diff --git a/packages/document-api/src/lists/lists.types.ts b/packages/document-api/src/lists/lists.types.ts index 7d1ecb0a9..c892a9d6e 100644 --- a/packages/document-api/src/lists/lists.types.ts +++ b/packages/document-api/src/lists/lists.types.ts @@ -47,13 +47,17 @@ export interface ListsListResult { } export interface ListInsertInput { - target: ListItemAddress; + target?: ListItemAddress; + /** Node ID shorthand — resolves to a ListItemAddress by the adapter. */ + nodeId?: string; position: ListInsertPosition; text?: string; } export interface ListTargetInput { - target: ListItemAddress; + target?: ListItemAddress; + /** Node ID shorthand — resolves to a ListItemAddress by the adapter. */ + nodeId?: string; } export interface ListSetTypeInput extends ListTargetInput { diff --git a/packages/document-api/src/replace/replace.ts b/packages/document-api/src/replace/replace.ts index 60563e3b5..b3f691d1f 100644 --- a/packages/document-api/src/replace/replace.ts +++ b/packages/document-api/src/replace/replace.ts @@ -1,9 +1,123 @@ import { executeWrite, type MutationOptions, type WriteAdapter } from '../write/write.js'; import type { TextAddress, TextMutationReceipt } from '../types/index.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord, isTextAddress, assertNoUnknownFields, assertNonNegativeInteger } from '../validation-primitives.js'; export interface ReplaceInput { - target: TextAddress; + target?: TextAddress; text: string; + /** Block ID for block-relative range targeting. Requires `start` and `end`. */ + blockId?: string; + /** Start offset within the block. Requires `blockId` and `end`. Non-negative integer. */ + start?: number; + /** End offset within the block. Requires `blockId` and `start`. Non-negative integer, >= start. */ + end?: number; +} + +const REPLACE_INPUT_ALLOWED_KEYS = new Set(['text', 'target', 'blockId', 'start', 'end']); + +/** + * Validates ReplaceInput and throws DocumentApiValidationError on violations. + * + * Validation order: + * 0. Input shape guard + * 1. Unknown field rejection + * 2. Type checks (target shape, text, blockId types) + * 3. At least one locator mode required + * 4. Mode exclusivity (target vs blockId+start+end) + * 5. Range completeness (blockId requires start+end) + * 6. Orphaned start/end without blockId + * 7. Numeric bounds (start/end >= 0, integer, start <= end) + */ +function validateReplaceInput(input: unknown): asserts input is ReplaceInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'Replace input must be a non-null object.'); + } + + assertNoUnknownFields(input, REPLACE_INPUT_ALLOWED_KEYS, 'replace'); + + const { target, text, blockId, start, end } = input; + const hasTarget = target !== undefined; + const hasBlockId = blockId !== undefined; + const hasStart = start !== undefined; + const hasEnd = end !== undefined; + + // Type checks + if (hasTarget && !isTextAddress(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a text address object.', { + field: 'target', + value: target, + }); + } + + if (typeof text !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `text must be a string, got ${typeof text}.`, { + field: 'text', + value: text, + }); + } + + if (hasBlockId && typeof blockId !== 'string') { + throw new DocumentApiValidationError('INVALID_TARGET', `blockId must be a string, got ${typeof blockId}.`, { + field: 'blockId', + value: blockId, + }); + } + + // At least one locator mode required (replace has no default target) + if (!hasTarget && !hasBlockId && !hasStart && !hasEnd) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Replace requires a target. Provide either target or blockId + start + end.', + ); + } + + // Mode exclusivity — target vs blockId/start/end + if (hasTarget && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + 'Cannot combine target with blockId/start/end. Use exactly one locator mode.', + { + fields: [ + 'target', + ...(hasBlockId ? ['blockId'] : []), + ...(hasStart ? ['start'] : []), + ...(hasEnd ? ['end'] : []), + ], + }, + ); + } + + // Orphaned start/end without blockId + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'start/end require blockId.', { + fields: ['blockId', ...(hasStart ? ['start'] : []), ...(hasEnd ? ['end'] : [])], + }); + } + + // Range completeness — blockId requires start+end + if (hasBlockId && !hasTarget) { + if (!hasStart || !hasEnd) { + throw new DocumentApiValidationError('INVALID_TARGET', 'blockId requires both start and end for replace.', { + fields: ['blockId', 'start', 'end'], + }); + } + } + + // Numeric bounds + if (hasStart) { + assertNonNegativeInteger(start, 'start'); + } + if (hasEnd) { + assertNonNegativeInteger(end, 'end'); + } + if (hasStart && hasEnd && (start as number) > (end as number)) { + throw new DocumentApiValidationError('INVALID_TARGET', `start must be <= end, got start=${start}, end=${end}.`, { + fields: ['start', 'end'], + start, + end, + }); + } } export function executeReplace( @@ -11,13 +125,15 @@ export function executeReplace( input: ReplaceInput, options?: MutationOptions, ): TextMutationReceipt { - return executeWrite( - adapter, - { - kind: 'replace', - target: input.target, - text: input.text, - }, - options, - ); + validateReplaceInput(input); + + const { target, blockId, start, end, text } = input; + + // Pass friendly locator fields through to the adapter for normalization. + if (blockId !== undefined) { + return executeWrite(adapter, { kind: 'replace', blockId, start, end, text }, options); + } + + // Canonical target path + return executeWrite(adapter, { kind: 'replace', target: target!, text }, options); } diff --git a/packages/document-api/src/types/create.types.ts b/packages/document-api/src/types/create.types.ts index 797418115..da23e16ab 100644 --- a/packages/document-api/src/types/create.types.ts +++ b/packages/document-api/src/types/create.types.ts @@ -6,7 +6,9 @@ export type ParagraphCreateLocation = | { kind: 'documentStart' } | { kind: 'documentEnd' } | { kind: 'before'; target: BlockNodeAddress } - | { kind: 'after'; target: BlockNodeAddress }; + | { kind: 'after'; target: BlockNodeAddress } + | { kind: 'before'; nodeId: string } + | { kind: 'after'; nodeId: string }; export interface CreateParagraphInput { at?: ParagraphCreateLocation; diff --git a/packages/document-api/src/validation-primitives.test.ts b/packages/document-api/src/validation-primitives.test.ts new file mode 100644 index 000000000..04045bd17 --- /dev/null +++ b/packages/document-api/src/validation-primitives.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + isRecord, + isInteger, + isTextAddress, + assertNoUnknownFields, + assertNonNegativeInteger, +} from './validation-primitives.js'; +import { DocumentApiValidationError } from './errors.js'; + +describe('isRecord', () => { + it('returns true for plain objects', () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + }); + + it('returns false for null', () => { + expect(isRecord(null)).toBe(false); + }); + + it('returns false for arrays', () => { + expect(isRecord([])).toBe(false); + expect(isRecord([1, 2])).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isRecord(undefined)).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord('string')).toBe(false); + expect(isRecord(true)).toBe(false); + }); +}); + +describe('isInteger', () => { + it('returns true for integers', () => { + expect(isInteger(0)).toBe(true); + expect(isInteger(1)).toBe(true); + expect(isInteger(-5)).toBe(true); + }); + + it('returns false for non-integer numbers', () => { + expect(isInteger(1.5)).toBe(false); + expect(isInteger(NaN)).toBe(false); + expect(isInteger(Infinity)).toBe(false); + }); + + it('returns false for non-numbers', () => { + expect(isInteger('1')).toBe(false); + expect(isInteger(null)).toBe(false); + expect(isInteger(undefined)).toBe(false); + }); +}); + +describe('isTextAddress', () => { + it('returns true for valid text addresses', () => { + expect(isTextAddress({ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } })).toBe(true); + expect(isTextAddress({ kind: 'text', blockId: 'p1', range: { start: 3, end: 3 } })).toBe(true); + }); + + it('returns false for wrong kind', () => { + expect(isTextAddress({ kind: 'block', blockId: 'p1', range: { start: 0, end: 5 } })).toBe(false); + }); + + it('returns false for missing blockId', () => { + expect(isTextAddress({ kind: 'text', range: { start: 0, end: 5 } })).toBe(false); + }); + + it('returns false for missing range', () => { + expect(isTextAddress({ kind: 'text', blockId: 'p1' })).toBe(false); + }); + + it('returns false when start > end', () => { + expect(isTextAddress({ kind: 'text', blockId: 'p1', range: { start: 5, end: 3 } })).toBe(false); + }); + + it('returns false for non-integer range values', () => { + expect(isTextAddress({ kind: 'text', blockId: 'p1', range: { start: 0, end: 1.5 } })).toBe(false); + }); + + it('returns false for non-objects', () => { + expect(isTextAddress(null)).toBe(false); + expect(isTextAddress('text')).toBe(false); + expect(isTextAddress(42)).toBe(false); + }); +}); + +describe('assertNoUnknownFields', () => { + it('does not throw for known fields', () => { + const allowlist = new Set(['a', 'b']); + expect(() => assertNoUnknownFields({ a: 1, b: 2 }, allowlist, 'test')).not.toThrow(); + }); + + it('does not throw for empty input', () => { + const allowlist = new Set(['a']); + expect(() => assertNoUnknownFields({}, allowlist, 'test')).not.toThrow(); + }); + + it('throws INVALID_TARGET for unknown fields', () => { + const allowlist = new Set(['a']); + try { + assertNoUnknownFields({ a: 1, unknown: 2 }, allowlist, 'test'); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + const e = err as DocumentApiValidationError; + expect(e.code).toBe('INVALID_TARGET'); + expect(e.message).toContain('Unknown field "unknown"'); + expect(e.message).toContain('test'); + } + }); +}); + +describe('assertNonNegativeInteger', () => { + it('does not throw for valid non-negative integers', () => { + expect(() => assertNonNegativeInteger(0, 'offset')).not.toThrow(); + expect(() => assertNonNegativeInteger(10, 'offset')).not.toThrow(); + }); + + it('throws for negative numbers', () => { + try { + assertNonNegativeInteger(-1, 'offset'); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + const e = err as DocumentApiValidationError; + expect(e.code).toBe('INVALID_TARGET'); + expect(e.message).toContain('non-negative integer'); + } + }); + + it('throws for non-integer numbers', () => { + try { + assertNonNegativeInteger(1.5, 'start'); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + expect((err as DocumentApiValidationError).code).toBe('INVALID_TARGET'); + } + }); + + it('throws for non-numbers', () => { + try { + assertNonNegativeInteger('5', 'end'); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + expect((err as DocumentApiValidationError).code).toBe('INVALID_TARGET'); + } + }); + + it('includes field name in error message', () => { + try { + assertNonNegativeInteger(-1, 'myField'); + expect.fail('Should have thrown'); + } catch (err) { + expect((err as DocumentApiValidationError).message).toContain('myField'); + } + }); +}); diff --git a/packages/document-api/src/validation-primitives.ts b/packages/document-api/src/validation-primitives.ts new file mode 100644 index 000000000..be0489650 --- /dev/null +++ b/packages/document-api/src/validation-primitives.ts @@ -0,0 +1,63 @@ +/** + * Low-level type-guard primitives shared across operation validators. + * + * This module contains ONLY primitive type checks and generic assertions. + * Operation-specific truth tables, mode-exclusivity logic, and allowlists + * stay local to each operation file. + * + * Internal — not exported from the package root. + */ + +import type { TextAddress } from './types/index.js'; +import { DocumentApiValidationError } from './errors.js'; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value != null && !Array.isArray(value); +} + +export function isInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value); +} + +export function isTextAddress(value: unknown): value is TextAddress { + if (!isRecord(value)) return false; + if (value.kind !== 'text') return false; + if (typeof value.blockId !== 'string') return false; + + const range = value.range; + if (!isRecord(range)) return false; + if (!isInteger(range.start) || !isInteger(range.end)) return false; + return range.start <= range.end; +} + +/** + * Throws INVALID_TARGET if any key on the input object is not in the allowlist. + */ +export function assertNoUnknownFields( + input: Record, + allowlist: ReadonlySet, + operationName: string, +): void { + for (const key of Object.keys(input)) { + if (!allowlist.has(key)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `Unknown field "${key}" on ${operationName} input. Allowed fields: ${[...allowlist].join(', ')}.`, + { field: key }, + ); + } + } +} + +/** + * Throws INVALID_TARGET if the value is not a non-negative integer. + */ +export function assertNonNegativeInteger(value: unknown, fieldName: string): void { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `${fieldName} must be a non-negative integer, got ${JSON.stringify(value)}.`, + { field: fieldName, value }, + ); + } +} diff --git a/packages/document-api/src/write/locator.ts b/packages/document-api/src/write/locator.ts index 43a94315e..0db4b0a79 100644 --- a/packages/document-api/src/write/locator.ts +++ b/packages/document-api/src/write/locator.ts @@ -1,14 +1,27 @@ /** * Shared internal locator types for friendly target resolution. * - * These types capture the `blockId + offset` pattern (and `pos` in PR B) - * for use by write operations (insert, and later replace/delete). + * Point locators are used by insert. Range locators are used by replace, + * delete, format.*, comments.add, and comments.move. Block-ID shorthand + * is used by create.* and lists.*. * * NOT exported from the package root — internal use only. */ -/** Block-relative locator: a block ID with an optional character offset. */ +/** Block-relative point locator: a block ID with an optional character offset. Used by insert. */ export interface BlockRelativeLocator { blockId: string; offset?: number; } + +/** Block-relative range locator: a block ID with start and end offsets. Used by replace, delete, format.*, comments.add/move. */ +export interface BlockRelativeRange { + blockId: string; + start: number; + end: number; +} + +/** Block-ID shorthand: a bare node ID that the adapter resolves to a full address. Used by create.*, lists.*. */ +export interface BlockIdShorthand { + nodeId: string; +} diff --git a/packages/document-api/src/write/write.ts b/packages/document-api/src/write/write.ts index 8559de95d..c02732ec9 100644 --- a/packages/document-api/src/write/write.ts +++ b/packages/document-api/src/write/write.ts @@ -1,5 +1,5 @@ import type { TextAddress, TextMutationReceipt } from '../types/index.js'; -import type { BlockRelativeLocator } from './locator.js'; +import type { BlockRelativeLocator, BlockRelativeRange } from './locator.js'; export type ChangeMode = 'direct' | 'tracked'; @@ -30,15 +30,15 @@ export type InsertWriteRequest = { export type ReplaceWriteRequest = { kind: 'replace'; - target: TextAddress; + target?: TextAddress; text: string; -}; +} & Partial; export type DeleteWriteRequest = { kind: 'delete'; - target: TextAddress; + target?: TextAddress; text?: ''; -}; +} & Partial; export type WriteRequest = InsertWriteRequest | ReplaceWriteRequest | DeleteWriteRequest; diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts index a003830b8..b92ea786c 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/create-adapter.test.ts @@ -384,6 +384,81 @@ describe('createParagraphAdapter', () => { 'requires a user to be configured', ); }); + + it('creates a paragraph before a target resolved by nodeId shorthand', () => { + const { editor, insertParagraphAt } = makeEditor(); + + const result = createParagraphAdapter( + editor, + { + at: { kind: 'before', nodeId: 'p1' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(insertParagraphAt.mock.calls[0]?.[0]?.pos).toBe(0); + }); + + it('creates a paragraph after a target resolved by nodeId shorthand', () => { + const { editor, insertParagraphAt } = makeEditor(); + + const result = createParagraphAdapter( + editor, + { + at: { kind: 'after', nodeId: 'p1' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + // 'Hello' paragraph nodeSize = 7, so after position = 7 + expect(insertParagraphAt.mock.calls[0]?.[0]?.pos).toBe(7); + }); + + it('throws TARGET_NOT_FOUND when nodeId shorthand cannot be resolved', () => { + const { editor } = makeEditor(); + + expect(() => + createParagraphAdapter( + editor, + { + at: { kind: 'before', nodeId: 'missing' }, + }, + { changeMode: 'direct' }, + ), + ).toThrow('was not found'); + }); + + it('throws AMBIGUOUS_TARGET when nodeId shorthand matches multiple blocks', () => { + const doc = createDocNode([createParagraphNode('dup', 'First'), createParagraphNode('dup', 'Second')]); + const editor = { + state: { doc }, + commands: { insertParagraphAt: vi.fn(() => true) }, + can: () => ({ insertParagraphAt: () => true }), + options: {}, + } as unknown as Editor; + + expect(() => + createParagraphAdapter(editor, { at: { kind: 'before', nodeId: 'dup' } }, { changeMode: 'direct' }), + ).toThrow('Multiple blocks share nodeId'); + }); + + it('resolves by nodeId when location object has an undefined target key (object spread edge case)', () => { + const { editor, insertParagraphAt } = makeEditor(); + + // Simulates { ...defaults, kind: 'before', nodeId: 'p1' } where defaults = { target: undefined } + const location = { + kind: 'before' as const, + nodeId: 'p1', + target: undefined, + } as unknown as import('@superdoc/document-api').ParagraphCreateLocation; + + const result = createParagraphAdapter(editor, { at: location }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + expect(insertParagraphAt.mock.calls[0]?.[0]?.pos).toBe(0); + }); }); // --------------------------------------------------------------------------- @@ -677,6 +752,53 @@ describe('createHeadingAdapter', () => { ); }); + it('creates a heading before a target resolved by nodeId shorthand', () => { + const { editor, insertHeadingAt } = makeHeadingEditor(); + + const result = createHeadingAdapter( + editor, + { + level: 2, + at: { kind: 'before', nodeId: 'p1' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(insertHeadingAt.mock.calls[0]?.[0]?.pos).toBe(0); + }); + + it('creates a heading after a target resolved by nodeId shorthand', () => { + const { editor, insertHeadingAt } = makeHeadingEditor(); + + const result = createHeadingAdapter( + editor, + { + level: 1, + at: { kind: 'after', nodeId: 'p1' }, + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(insertHeadingAt.mock.calls[0]?.[0]?.pos).toBe(7); + }); + + it('throws TARGET_NOT_FOUND when heading nodeId shorthand cannot be resolved', () => { + const { editor } = makeHeadingEditor(); + + expect(() => + createHeadingAdapter( + editor, + { + level: 1, + at: { kind: 'before', nodeId: 'missing' }, + }, + { changeMode: 'direct' }, + ), + ).toThrow('was not found'); + }); + it('passes level through to the insertHeadingAt command', () => { const { editor, insertHeadingAt } = makeHeadingEditor(); diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.ts b/packages/super-editor/src/document-api-adapters/create-adapter.ts index 501ee8378..ba34ae137 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/create-adapter.ts @@ -10,7 +10,7 @@ import type { MutationOptions, } from '@superdoc/document-api'; import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; -import { findBlockById, type BlockCandidate } from './helpers/node-address-resolver.js'; +import { findBlockById, findBlockByNodeIdOnly, type BlockCandidate } from './helpers/node-address-resolver.js'; import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; import { DocumentApiAdapterError } from './errors.js'; import { requireEditorCommand, ensureTrackedCapability } from './helpers/mutation-helpers.js'; @@ -41,10 +41,13 @@ function resolveParagraphInsertPosition(editor: Editor, input: CreateParagraphIn if (location.kind === 'documentEnd') return editor.state.doc.content.size; const index = getBlockIndex(editor); - const target = findBlockById(index, location.target); + const hasTarget = 'target' in location && location.target != null; + const target = hasTarget + ? findBlockById(index, location.target) + : findBlockByNodeIdOnly(index, (location as { nodeId: string }).nodeId); if (!target) { throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Create paragraph target block was not found.', { - target: location.target, + target: hasTarget ? location.target : (location as { nodeId: string }).nodeId, }); } @@ -191,10 +194,13 @@ function resolveHeadingInsertPosition(editor: Editor, input: CreateHeadingInput) if (location.kind === 'documentEnd') return editor.state.doc.content.size; const index = getBlockIndex(editor); - const target = findBlockById(index, location.target); + const hasTarget = 'target' in location && location.target != null; + const target = hasTarget + ? findBlockById(index, location.target) + : findBlockByNodeIdOnly(index, (location as { nodeId: string }).nodeId); if (!target) { throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Create heading target block was not found.', { - target: location.target, + target: hasTarget ? location.target : (location as { nodeId: string }).nodeId, }); } diff --git a/packages/super-editor/src/document-api-adapters/errors.test.ts b/packages/super-editor/src/document-api-adapters/errors.test.ts index 2e8180081..e01a906dd 100644 --- a/packages/super-editor/src/document-api-adapters/errors.test.ts +++ b/packages/super-editor/src/document-api-adapters/errors.test.ts @@ -20,7 +20,7 @@ describe('DocumentApiAdapterError', () => { }); it('supports all error codes', () => { - const codes = ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'CAPABILITY_UNAVAILABLE'] as const; + const codes = ['TARGET_NOT_FOUND', 'INVALID_TARGET', 'AMBIGUOUS_TARGET', 'CAPABILITY_UNAVAILABLE'] as const; for (const code of codes) { const error = new DocumentApiAdapterError(code, `Error: ${code}`); diff --git a/packages/super-editor/src/document-api-adapters/errors.ts b/packages/super-editor/src/document-api-adapters/errors.ts index 20186c1bc..083a1526c 100644 --- a/packages/super-editor/src/document-api-adapters/errors.ts +++ b/packages/super-editor/src/document-api-adapters/errors.ts @@ -1,5 +1,9 @@ /** Error codes used by {@link DocumentApiAdapterError} to classify adapter failures. */ -export type DocumentApiAdapterErrorCode = 'TARGET_NOT_FOUND' | 'INVALID_TARGET' | 'CAPABILITY_UNAVAILABLE'; +export type DocumentApiAdapterErrorCode = + | 'TARGET_NOT_FOUND' + | 'INVALID_TARGET' + | 'AMBIGUOUS_TARGET' + | 'CAPABILITY_UNAVAILABLE'; /** * Structured error thrown by document-api adapter functions. diff --git a/packages/super-editor/src/document-api-adapters/format-adapter.ts b/packages/super-editor/src/document-api-adapters/format-adapter.ts index e0af67786..7a0420095 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -25,7 +25,52 @@ const FORMAT_OPERATION_LABEL = { type FormatOperationId = keyof typeof FORMAT_OPERATION_LABEL; type FormatMarkName = 'bold' | 'italic' | 'underline' | 'strike'; -type FormatOperationInput = { target: TextAddress }; +type FormatOperationInput = { target?: TextAddress; blockId?: string; start?: number; end?: number }; + +/** + * Normalize block-relative locator fields into a canonical TextAddress. + * + * blockId + start + end → TextAddress with range { start, end }. + * Returns the original input unchanged when no friendly locator is present. + */ +function normalizeFormatLocator(input: FormatOperationInput): FormatOperationInput { + const hasBlockId = input.blockId !== undefined; + const hasStart = input.start !== undefined; + const hasEnd = input.end !== undefined; + + // Defensive: reject range fields mixed with canonical target. + if (input.target && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + 'Cannot combine target with blockId/start/end on format request.', + { fields: ['target', 'blockId', 'start', 'end'] }, + ); + } + + // Defensive: reject orphaned start/end without blockId. + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'start/end require blockId on format request.', { + fields: ['blockId', 'start', 'end'], + }); + } + + if (!hasBlockId) return input; + + // Defensive: reject incomplete range. + if (!hasStart || !hasEnd) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'blockId requires both start and end on format request.', { + fields: ['blockId', 'start', 'end'], + }); + } + + const target: TextAddress = { + kind: 'text', + blockId: input.blockId!, + range: { start: input.start!, end: input.end! }, + }; + + return { target }; +} /** * Shared adapter logic for toggle-mark format operations. @@ -43,16 +88,17 @@ function formatMarkAdapter( input: FormatOperationInput, options?: MutationOptions, ): TextMutationReceipt { - const range = resolveTextTarget(editor, input.target); + const normalizedInput = normalizeFormatLocator(input); + const range = resolveTextTarget(editor, normalizedInput.target!); if (!range) { throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Format target could not be resolved.', { - target: input.target, + target: normalizedInput.target, }); } const resolution = buildTextMutationResolution({ requestedTarget: input.target, - target: input.target, + target: normalizedInput.target!, range, text: readTextAtResolvedRange(editor, range), }); diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts index 3bf9c210c..12106bba3 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts @@ -4,6 +4,7 @@ import type { BlockNodeAttributes } from '../../core/types/NodeCategories.js'; import type { BlockNodeAddress, BlockNodeType, NodeAddress, NodeType } from '@superdoc/document-api'; import type { ParagraphAttrs } from '../../extensions/types/node-attributes.js'; import { toId } from './value-utils.js'; +import { DocumentApiAdapterError } from '../errors.js'; /** Superset of all possible ID attributes across block node types. */ type BlockIdAttrs = BlockNodeAttributes & { @@ -192,6 +193,34 @@ export function findBlockById(index: BlockIndex, address: NodeAddress): BlockCan return index.byId.get(`${address.nodeType}:${address.nodeId}`); } +/** + * Finds a block candidate by raw nodeId without requiring a nodeType. + * + * This is needed for create operations that position relative to _any_ block type. + * + * @param index - The block index to search. + * @param nodeId - The node ID to resolve. + * @returns The single matching candidate. + * @throws {DocumentApiAdapterError} `TARGET_NOT_FOUND` if no candidate matches. + * @throws {DocumentApiAdapterError} `AMBIGUOUS_TARGET` if more than one candidate matches. + */ +export function findBlockByNodeIdOnly(index: BlockIndex, nodeId: string): BlockCandidate { + const matches = index.candidates.filter((candidate) => candidate.nodeId === nodeId); + + if (matches.length === 0) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Block with nodeId "${nodeId}" was not found.`, { nodeId }); + } + + if (matches.length > 1) { + throw new DocumentApiAdapterError('AMBIGUOUS_TARGET', `Multiple blocks share nodeId "${nodeId}".`, { + nodeId, + count: matches.length, + }); + } + + return matches[0]!; +} + /** * Returns true for block candidates that accept inline text content. */ diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts index 4bd5ee26d..caf097bcf 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts @@ -353,6 +353,97 @@ describe('lists adapter', () => { expect(restartNumbering).not.toHaveBeenCalled(); }); + it('inserts a list item resolved by nodeId shorthand', () => { + const editor = makeEditor([ + makeListParagraph({ + id: 'li-1', + text: 'One', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + ]); + + const result = listsInsertAdapter( + editor, + { + nodeId: 'li-1', + position: 'after', + text: 'Inserted', + }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.item.nodeType).toBe('listItem'); + expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); + }); + + it('indents a list item resolved by nodeId shorthand', () => { + vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeEditor([ + makeListParagraph({ + id: 'li-1', + text: 'One', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + ]); + + const result = listsIndentAdapter(editor, { nodeId: 'li-1' }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.item.nodeId).toBe('li-1'); + }); + + it('throws TARGET_NOT_FOUND when lists nodeId shorthand cannot be resolved', () => { + const editor = makeEditor([ + makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), + ]); + + expect(() => + listsInsertAdapter(editor, { nodeId: 'missing', position: 'after' }, { changeMode: 'direct' }), + ).toThrow('List item target was not found'); + }); + + it('throws INVALID_TARGET when nodeId shorthand resolves to a non-list-item block', () => { + // plain-para has no numbering, so it's indexed as 'paragraph', not 'listItem' + const editor = makeEditor([makeListParagraph({ id: 'plain-para', text: 'Not a list item' })]); + + expect(() => listsIndentAdapter(editor, { nodeId: 'plain-para' })).toThrow('not a listItem'); + }); + + it('resolves listItem when a paragraph with the same nodeId appears first in the document', () => { + vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); + const editor = makeEditor([ + // paragraph:dup appears before listItem:dup + makeListParagraph({ id: 'dup', text: 'plain paragraph' }), + makeListParagraph({ + id: 'dup', + text: 'list item', + numId: 1, + ilvl: 0, + markerText: '1.', + path: [1], + numberingType: 'decimal', + }), + ]); + + const result = listsIndentAdapter(editor, { nodeId: 'dup' }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.item.nodeType).toBe('listItem'); + expect(result.item.nodeId).toBe('dup'); + }); + it('throws TARGET_NOT_FOUND for stale list targets', () => { const editor = makeEditor([ makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.ts b/packages/super-editor/src/document-api-adapters/lists-adapter.ts index 99b5f0a1c..5dffc7e64 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/lists-adapter.ts @@ -114,7 +114,28 @@ function isRestartNoOp(editor: Editor, item: ListItemProjection): boolean { } function withListTarget(editor: Editor, input: ListTargetInput): ListItemProjection { - return resolveListItem(editor, input.target); + if (input.target) return resolveListItem(editor, input.target); + + const nodeId = input.nodeId!; + const index = getBlockIndex(editor); + + // Prefer a listItem match so that duplicate IDs across block types don't + // shadow a valid list item (e.g. paragraph:dup before listItem:dup). + const listMatch = index.candidates.find((c) => c.nodeType === 'listItem' && c.nodeId === nodeId); + if (listMatch) { + return resolveListItem(editor, { kind: 'block', nodeType: 'listItem', nodeId }); + } + + // No listItem found — distinguish "exists but wrong type" from "missing". + const anyMatch = index.candidates.find((c) => c.nodeId === nodeId); + if (anyMatch) { + throw new DocumentApiAdapterError('INVALID_TARGET', `Node "${nodeId}" is a ${anyMatch.nodeType}, not a listItem.`, { + nodeId, + actualNodeType: anyMatch.nodeType, + }); + } + + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'List item target was not found.', { nodeId }); } export function listsListAdapter(editor: Editor, query?: ListsListQuery): ListsListResult { @@ -131,7 +152,7 @@ export function listsInsertAdapter( input: ListInsertInput, options?: MutationOptions, ): ListsInsertResult { - const target = withListTarget(editor, { target: input.target }); + const target = withListTarget(editor, input); const changeMode = options?.changeMode ?? 'direct'; const mode = changeMode === 'tracked' ? 'tracked' : 'direct'; if (mode === 'tracked') ensureTrackedCapability(editor, { operation: 'lists.insert' }); @@ -209,7 +230,7 @@ export function listsSetTypeAdapter( options?: MutationOptions, ): ListsMutateItemResult { rejectTrackedMode('lists.setType', options); - const target = withListTarget(editor, { target: input.target }); + const target = withListTarget(editor, input); if (target.kind === input.kind) { return toListsFailure('NO_OP', 'List item already has the requested list kind.', { target: input.target, diff --git a/packages/super-editor/src/document-api-adapters/write-adapter.ts b/packages/super-editor/src/document-api-adapters/write-adapter.ts index 46a567ef6..09c4d3f4b 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -69,48 +69,99 @@ type ResolvedWriteTarget = { }; /** - * Normalize block-relative locator fields (blockId + offset) into a canonical TextAddress. + * Normalize block-relative locator fields into a canonical TextAddress. * This runs inside the adapter layer so that the resolution uses engine-specific block lookup. * + * - Insert: blockId + offset → collapsed TextAddress + * - Replace/Delete: blockId + start + end → ranged TextAddress + * * Returns the original request unchanged when no friendly locator is present. */ -function normalizeInsertLocator(request: WriteRequest): WriteRequest { - if (request.kind !== 'insert') return request; +function normalizeWriteLocator(request: WriteRequest): WriteRequest { + if (request.kind === 'insert') { + const hasBlockId = request.blockId !== undefined; + const hasOffset = request.offset !== undefined; + + // Defensive: reject offset mixed with canonical target. + if (hasOffset && request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { + fields: ['target', 'offset'], + }); + } - const hasBlockId = request.blockId !== undefined; - const hasOffset = request.offset !== undefined; + // Defensive: reject orphaned offset without blockId (safety net for direct adapter callers). + if (hasOffset && !hasBlockId) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { + fields: ['offset', 'blockId'], + }); + } - // Defensive: reject offset mixed with canonical target. - if (hasOffset && request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { - fields: ['target', 'offset'], - }); - } + if (!hasBlockId) return request; - // Defensive: reject orphaned offset without blockId (safety net for direct adapter callers). - if (hasOffset && !hasBlockId) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { - fields: ['offset', 'blockId'], - }); - } + // Defensive: reject mixed locator modes at adapter boundary (safety net). + if (request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { + fields: ['target', 'blockId'], + }); + } - if (!hasBlockId) return request; + const effectiveOffset = request.offset ?? 0; + const target: TextAddress = { + kind: 'text', + blockId: request.blockId!, + range: { start: effectiveOffset, end: effectiveOffset }, + }; - // Defensive: reject mixed locator modes at adapter boundary (safety net). - if (request.target) { - throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { - fields: ['target', 'blockId'], - }); + return { kind: 'insert', target, text: request.text }; } - const effectiveOffset = request.offset ?? 0; - const target: TextAddress = { - kind: 'text', - blockId: request.blockId, - range: { start: effectiveOffset, end: effectiveOffset }, - }; + // replace / delete: range normalization (blockId + start + end → TextAddress) + if (request.kind === 'replace' || request.kind === 'delete') { + const hasBlockId = request.blockId !== undefined; + const hasStart = request.start !== undefined; + const hasEnd = request.end !== undefined; + + // Defensive: reject range fields mixed with canonical target. + if (request.target && (hasBlockId || hasStart || hasEnd)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `Cannot combine target with blockId/start/end on ${request.kind} request.`, + { fields: ['target', 'blockId', 'start', 'end'] }, + ); + } + + // Defensive: reject orphaned start/end without blockId. + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiAdapterError('INVALID_TARGET', `start/end require blockId on ${request.kind} request.`, { + fields: ['blockId', 'start', 'end'], + }); + } + + if (!hasBlockId) return request; + + // Defensive: reject incomplete range. + if (!hasStart || !hasEnd) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `blockId requires both start and end on ${request.kind} request.`, + { fields: ['blockId', 'start', 'end'] }, + ); + } + + const target: TextAddress = { + kind: 'text', + blockId: request.blockId!, + range: { start: request.start!, end: request.end! }, + }; + + // Construct clean canonical objects — no leftover friendly fields. + if (request.kind === 'replace') { + return { kind: 'replace', target, text: request.text }; + } + return { kind: 'delete', target, text: '' }; + } - return { kind: 'insert', target, text: request.text }; + return request; } function resolveWriteTarget(editor: Editor, request: WriteRequest): ResolvedWriteTarget | null { @@ -231,7 +282,7 @@ function toFailureReceipt(failure: ReceiptFailure, resolvedTarget: ResolvedWrite export function writeAdapter(editor: Editor, request: WriteRequest, options?: MutationOptions): TextMutationReceipt { // Normalize friendly locator fields (blockId + offset) into canonical TextAddress // before resolution. This is the adapter-layer normalization per the contract. - const normalizedRequest = normalizeInsertLocator(request); + const normalizedRequest = normalizeWriteLocator(request); const resolvedTarget = resolveWriteTarget(editor, normalizedRequest); if (!resolvedTarget) {