diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index f12b650ab9..942620076f 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -89,6 +89,9 @@ const INTENT_NAMES = { 'doc.trackChanges.reject': 'reject_tracked_change', 'doc.trackChanges.acceptAll': 'accept_all_tracked_changes', 'doc.trackChanges.rejectAll': 'reject_all_tracked_changes', + 'doc.query.match': 'query_match', + 'doc.mutations.preview': 'preview_mutations', + 'doc.mutations.apply': 'apply_mutations', } as const satisfies Record; // --------------------------------------------------------------------------- diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index be99d27d00..07b255d030 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -253,6 +253,94 @@ export const SUCCESS_SCENARIOS = { const docPath = await harness.copyFixtureDoc('doc-get-text'); return { stateDir, args: ['get-text', docPath] }; }, + 'doc.query.match': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-query-match-success'); + const docPath = await harness.copyFixtureDoc('doc-query-match'); + return { + stateDir, + args: [ + 'query', + 'match', + docPath, + '--select-json', + JSON.stringify({ type: 'node', nodeType: 'paragraph' }), + '--require', + 'any', + '--limit', + '1', + ], + }; + }, + 'doc.mutations.preview': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-mutations-preview-success'); + const docPath = await harness.copyFixtureDoc('doc-mutations-preview'); + const steps = [ + { + id: 'preview-insert', + op: 'text.insert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + require: 'first', + }, + args: { + position: 'before', + content: { text: 'PREVIEW_MUTATION_TOKEN' }, + }, + }, + ]; + return { + stateDir, + args: [ + 'mutations', + 'preview', + docPath, + '--expected-revision', + '0', + '--atomic-json', + 'true', + '--change-mode', + 'direct', + '--steps-json', + JSON.stringify(steps), + ], + }; + }, + 'doc.mutations.apply': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-mutations-apply-success'); + const docPath = await harness.copyFixtureDoc('doc-mutations-apply'); + const steps = [ + { + id: 'apply-insert', + op: 'text.insert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + require: 'first', + }, + args: { + position: 'before', + content: { text: 'APPLY_MUTATION_TOKEN' }, + }, + }, + ]; + return { + stateDir, + args: [ + 'mutations', + 'apply', + docPath, + '--atomic-json', + 'true', + '--change-mode', + 'direct', + '--steps-json', + JSON.stringify(steps), + '--out', + harness.createOutputPath('doc-mutations-apply-output'), + ], + }; + }, 'doc.capabilities.get': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-capabilities-get-success'); await harness.openSessionFixture(stateDir, 'doc-capabilities-get', 'capabilities-session'); diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index d0d7a39881..db04978eab 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -267,6 +267,23 @@ const PARAM_FLAG_OVERRIDES: Partial>> = { + 'doc.mutations.preview': { + steps: { type: 'json' }, + }, + 'doc.mutations.apply': { + steps: { type: 'json' }, + }, +}; + // --------------------------------------------------------------------------- // Schema-derived param exclusions // @@ -453,6 +470,7 @@ function buildDocBackedMetadata(): Record Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `mutations.apply` +- API member path: `editor.doc.mutations.apply(...)` +- Mutates document: `yes` +- Idempotency: `non-idempotent` +- Supports tracked mode: `yes` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `REVISION_MISMATCH` +- `MATCH_NOT_FOUND` +- `AMBIGUOUS_MATCH` +- `STYLE_CONFLICT` +- `PRECONDITION_FAILED` +- `INVALID_INPUT` +- `CROSS_BLOCK_MATCH` +- `PLAN_CONFLICT_OVERLAP` +- `INVALID_STEP_COMBINATION` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "atomic": { + "const": true + }, + "changeMode": { + "enum": [ + "direct", + "tracked" + ] + }, + "expectedRevision": { + "type": "string" + }, + "steps": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "expectedRevision", + "atomic", + "changeMode", + "steps" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "revision": { + "additionalProperties": false, + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + } + }, + "required": [ + "before", + "after" + ], + "type": "object" + }, + "steps": { + "items": { + "type": "object" + }, + "type": "array" + }, + "success": { + "const": true + }, + "timing": { + "additionalProperties": false, + "properties": { + "totalMs": { + "type": "number" + } + }, + "required": [ + "totalMs" + ], + "type": "object" + }, + "trackedChanges": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "success", + "revision", + "steps", + "timing" + ], + "type": "object" +} +``` + +## Success schema + +```json +{ + "additionalProperties": false, + "properties": { + "revision": { + "additionalProperties": false, + "properties": { + "after": { + "type": "string" + }, + "before": { + "type": "string" + } + }, + "required": [ + "before", + "after" + ], + "type": "object" + }, + "steps": { + "items": { + "type": "object" + }, + "type": "array" + }, + "success": { + "const": true + }, + "timing": { + "additionalProperties": false, + "properties": { + "totalMs": { + "type": "number" + } + }, + "required": [ + "totalMs" + ], + "type": "object" + } + }, + "required": [ + "success", + "revision", + "steps", + "timing" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/mutations/index.mdx b/apps/docs/document-api/reference/mutations/index.mdx new file mode 100644 index 0000000000..2d76503ad5 --- /dev/null +++ b/apps/docs/document-api/reference/mutations/index.mdx @@ -0,0 +1,18 @@ +--- +title: Mutations operations +sidebarTitle: Mutations +description: Generated Mutations operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Atomic mutation plan preview and execution. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`mutations.preview`](./preview) | `mutations.preview` | No | `idempotent` | No | No | +| [`mutations.apply`](./apply) | `mutations.apply` | Yes | `non-idempotent` | Yes | No | diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx new file mode 100644 index 0000000000..69e3224f7b --- /dev/null +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -0,0 +1,105 @@ +--- +title: mutations.preview +sidebarTitle: mutations.preview +description: Generated reference for mutations.preview +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `mutations.preview` +- API member path: `editor.doc.mutations.preview(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `REVISION_MISMATCH` +- `MATCH_NOT_FOUND` +- `AMBIGUOUS_MATCH` +- `STYLE_CONFLICT` +- `PRECONDITION_FAILED` +- `INVALID_INPUT` +- `CROSS_BLOCK_MATCH` +- `PLAN_CONFLICT_OVERLAP` +- `INVALID_STEP_COMBINATION` +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "atomic": { + "const": true + }, + "changeMode": { + "enum": [ + "direct", + "tracked" + ] + }, + "expectedRevision": { + "type": "string" + }, + "steps": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "expectedRevision", + "atomic", + "changeMode", + "steps" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "evaluatedRevision": { + "type": "string" + }, + "failures": { + "items": { + "type": "object" + }, + "type": "array" + }, + "steps": { + "items": { + "type": "object" + }, + "type": "array" + }, + "valid": { + "type": "boolean" + } + }, + "required": [ + "evaluatedRevision", + "steps", + "valid" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-api/reference/query/index.mdx b/apps/docs/document-api/reference/query/index.mdx new file mode 100644 index 0000000000..30a735b453 --- /dev/null +++ b/apps/docs/document-api/reference/query/index.mdx @@ -0,0 +1,17 @@ +--- +title: Query operations +sidebarTitle: Query +description: Generated Query operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Deterministic selector-based queries for mutation targeting. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| [`query.match`](./match) | `query.match` | No | `idempotent` | No | No | diff --git a/apps/docs/document-api/reference/query/match.mdx b/apps/docs/document-api/reference/query/match.mdx new file mode 100644 index 0000000000..087f92dd7f --- /dev/null +++ b/apps/docs/document-api/reference/query/match.mdx @@ -0,0 +1,443 @@ +--- +title: query.match +sidebarTitle: query.match +description: Generated reference for query.match +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `query.match` +- API member path: `editor.doc.query.match(...)` +- Mutates document: `no` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Pre-apply throws + +- `MATCH_NOT_FOUND` +- `AMBIGUOUS_MATCH` +- `INVALID_INPUT` + +## Non-applied failure codes + +- None + +## Input schema + +```json +{ + "additionalProperties": false, + "properties": { + "includeNodes": { + "type": "boolean" + }, + "includeStyle": { + "type": "boolean" + }, + "limit": { + "minimum": 1, + "type": "integer" + }, + "offset": { + "minimum": 0, + "type": "integer" + }, + "require": { + "enum": [ + "any", + "first", + "exactlyOne", + "all" + ] + }, + "select": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "caseSensitive": { + "type": "boolean" + }, + "mode": { + "enum": [ + "contains", + "regex" + ] + }, + "pattern": { + "type": "string" + }, + "type": { + "const": "text" + } + }, + "required": [ + "type", + "pattern" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "block", + "inline" + ] + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt", + "run", + "bookmark", + "comment", + "hyperlink", + "footnoteRef", + "tab", + "lineBreak" + ] + }, + "type": { + "const": "node" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "within": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] + } + }, + "required": [ + "select" + ], + "type": "object" +} +``` + +## Output schema + +```json +{ + "additionalProperties": false, + "properties": { + "evaluatedRevision": { + "type": "string" + }, + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "block" + }, + "nodeId": { + "type": "string" + }, + "nodeType": { + "enum": [ + "paragraph", + "heading", + "listItem", + "table", + "tableRow", + "tableCell", + "image", + "sdt" + ] + } + }, + "required": [ + "kind", + "nodeType", + "nodeId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "anchor": { + "additionalProperties": false, + "properties": { + "end": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + }, + "start": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "blockId", + "offset" + ], + "type": "object" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + }, + "kind": { + "const": "inline" + }, + "nodeType": { + "enum": [ + "run", + "bookmark", + "comment", + "hyperlink", + "sdt", + "image", + "footnoteRef", + "tab", + "lineBreak" + ] + } + }, + "required": [ + "kind", + "nodeType", + "anchor" + ], + "type": "object" + } + ] + }, + "ref": { + "type": "string" + }, + "refStability": { + "enum": [ + "ephemeral", + "stable" + ] + }, + "style": { + "additionalProperties": false, + "properties": { + "isUniform": { + "type": "boolean" + }, + "marks": { + "additionalProperties": false, + "properties": { + "bold": { + "type": "boolean" + }, + "italic": { + "type": "boolean" + }, + "strike": { + "type": "boolean" + }, + "underline": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "marks", + "isUniform" + ], + "type": "object" + }, + "textRanges": { + "items": { + "additionalProperties": false, + "properties": { + "blockId": { + "type": "string" + }, + "kind": { + "const": "text" + }, + "range": { + "additionalProperties": false, + "properties": { + "end": { + "type": "integer" + }, + "start": { + "type": "integer" + } + }, + "required": [ + "start", + "end" + ], + "type": "object" + } + }, + "required": [ + "kind", + "blockId", + "range" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "totalMatches": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "evaluatedRevision", + "matches", + "totalMatches" + ], + "type": "object" +} +``` diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 73fc4fca97..4ab79c4f4d 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -114,7 +114,10 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.find` | `find` | Search the document for nodes matching type, text, or attribute criteria. | | `doc.getNode` | `get-node` | Retrieve a single node by target position. | | `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. | +| `doc.getText` | `get-text` | Extract the plain-text content of the document. | | `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. | +| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. | #### Mutation @@ -123,6 +126,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.insert` | `insert` | Insert text or inline content at a target position. | | `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | | `doc.delete` | `delete` | Delete content at a target position. | +| `doc.mutations.apply` | `mutations apply` | Execute a mutation plan atomically against the document. | #### Format @@ -138,6 +142,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | | `doc.create.paragraph` | `create paragraph` | Create a new paragraph at the target position. | +| `doc.create.heading` | `create heading` | Create a new heading at the target position. | #### Lists diff --git a/packages/document-api/src/capabilities/capabilities.ts b/packages/document-api/src/capabilities/capabilities.ts index 2a446311f9..9869f252f6 100644 --- a/packages/document-api/src/capabilities/capabilities.ts +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -29,11 +29,27 @@ export interface OperationRuntimeCapability { export type OperationCapabilities = Record; +/** Runtime capabilities exposed by the plan engine (mutations.apply / mutations.preview). */ +export interface PlanEngineCapabilities { + /** Step op codes the engine can execute (e.g., 'text.rewrite', 'style.apply'). */ + supportedStepOps: readonly string[]; + /** Non-uniform style resolution strategies available for `onNonUniform`. */ + supportedNonUniformStrategies: readonly string[]; + /** Mark names that `setMarks` can override (e.g., 'bold', 'italic'). */ + supportedSetMarks: readonly string[]; + /** Regex safety limits enforced by the selector engine. */ + regex: { + maxPatternLength: number; + maxExecutionMs?: number; + }; +} + /** * Complete runtime capability snapshot for a Document API editor instance. * * `global` contains namespace-level flags (track changes, comments, lists, dry-run). * `operations` contains per-operation availability details keyed by {@link OperationId}. + * `planEngine` describes plan engine capabilities (step ops, style strategies, limits). */ export interface DocumentApiCapabilities { global: { @@ -43,6 +59,7 @@ export interface DocumentApiCapabilities { dryRun: CapabilityFlag; }; operations: OperationCapabilities; + planEngine: PlanEngineCapabilities; } /** Engine-specific adapter that resolves runtime capabilities for the current editor instance. */ diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts index 7ded341571..7682ba9a05 100644 --- a/packages/document-api/src/comments/comments.ts +++ b/packages/document-api/src/comments/comments.ts @@ -1,5 +1,6 @@ import type { Receipt, TextAddress } from '../types/index.js'; import type { CommentInfo, CommentsListQuery, CommentsListResult } from './comments.types.js'; +import type { RevisionGuardOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, isTextAddress, assertNoUnknownFields, assertNonNegativeInteger } from '../validation-primitives.js'; @@ -76,21 +77,21 @@ export interface GetCommentInput { */ export interface CommentsAdapter { /** Add a comment at the specified text range. */ - add(input: AddCommentInput): Receipt; + add(input: AddCommentInput, options?: RevisionGuardOptions): Receipt; /** Edit the body text of an existing comment. */ - edit(input: EditCommentInput): Receipt; + edit(input: EditCommentInput, options?: RevisionGuardOptions): Receipt; /** Reply to an existing comment thread. */ - reply(input: ReplyToCommentInput): Receipt; + reply(input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt; /** Move a comment to a different text range. */ - move(input: MoveCommentInput): Receipt; + move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt; /** Resolve an open comment. */ - resolve(input: ResolveCommentInput): Receipt; + resolve(input: ResolveCommentInput, options?: RevisionGuardOptions): Receipt; /** Remove a comment from the document. */ - remove(input: RemoveCommentInput): Receipt; + remove(input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt; /** Set the internal/private flag on a comment. */ - setInternal(input: SetCommentInternalInput): Receipt; + setInternal(input: SetCommentInternalInput, options?: RevisionGuardOptions): Receipt; /** Set which comment is currently active/focused. Pass `null` to clear. */ - setActive(input: SetCommentActiveInput): Receipt; + setActive(input: SetCommentActiveInput, options?: RevisionGuardOptions): Receipt; /** Scroll to and focus a comment in the document. */ goTo(input: GoToCommentInput): Receipt; /** Retrieve full information for a single comment. */ @@ -302,40 +303,72 @@ function normalizeCommentTarget { 'lists', 'comments', 'trackChanges', + 'query', + 'mutations', ]; for (const id of OPERATION_IDS) { expect(validGroups, `${id} has invalid referenceGroup`).toContain(OPERATION_DEFINITIONS[id].referenceGroup); diff --git a/packages/document-api/src/contract/metadata-types.ts b/packages/document-api/src/contract/metadata-types.ts index 574461b631..b0ef83a22f 100644 --- a/packages/document-api/src/contract/metadata-types.ts +++ b/packages/document-api/src/contract/metadata-types.ts @@ -17,6 +17,15 @@ export const PRE_APPLY_THROW_CODES = [ 'CAPABILITY_UNAVAILABLE', 'INVALID_TARGET', 'AMBIGUOUS_TARGET', + 'REVISION_MISMATCH', + 'MATCH_NOT_FOUND', + 'AMBIGUOUS_MATCH', + 'STYLE_CONFLICT', + 'PRECONDITION_FAILED', + 'INVALID_INPUT', + 'CROSS_BLOCK_MATCH', + 'PLAN_CONFLICT_OVERLAP', + 'INVALID_STEP_COMBINATION', ] 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 4b39eca36d..c81323741d 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -31,7 +31,16 @@ import type { CommandStaticMetadata, OperationIdempotency, PreApplyThrowCode } f // Reference group key // --------------------------------------------------------------------------- -export type ReferenceGroupKey = 'core' | 'capabilities' | 'create' | 'format' | 'lists' | 'comments' | 'trackChanges'; +export type ReferenceGroupKey = + | 'core' + | 'capabilities' + | 'create' + | 'format' + | 'lists' + | 'comments' + | 'trackChanges' + | 'query' + | 'mutations'; // --------------------------------------------------------------------------- // Entry shape @@ -112,6 +121,22 @@ const T_NOT_FOUND_COMMAND_TRACKED = [ 'CAPABILITY_UNAVAILABLE', ] as const; +// Plan-engine throw-code arrays +const T_PLAN_ENGINE = [ + 'REVISION_MISMATCH', + 'MATCH_NOT_FOUND', + 'AMBIGUOUS_MATCH', + 'STYLE_CONFLICT', + 'PRECONDITION_FAILED', + 'INVALID_INPUT', + 'CROSS_BLOCK_MATCH', + 'PLAN_CONFLICT_OVERLAP', + 'INVALID_STEP_COMBINATION', + 'CAPABILITY_UNAVAILABLE', +] as const; + +const T_QUERY_MATCH = ['MATCH_NOT_FOUND', 'AMBIGUOUS_MATCH', 'INVALID_INPUT'] as const; + // --------------------------------------------------------------------------- // Canonical definitions // --------------------------------------------------------------------------- @@ -626,6 +651,48 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'trackChanges', }, + 'query.match': { + memberPath: 'query.match', + description: 'Deterministic selector-based search with cardinality contracts for mutation targeting.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_QUERY_MATCH, + deterministicTargetResolution: true, + }), + referenceDocPath: 'query/match.mdx', + referenceGroup: 'query', + }, + + 'mutations.preview': { + memberPath: 'mutations.preview', + description: 'Dry-run a mutation plan, returning resolved targets without applying changes.', + requiresDocumentContext: true, + metadata: readOperation({ + idempotency: 'idempotent', + throws: T_PLAN_ENGINE, + deterministicTargetResolution: true, + }), + referenceDocPath: 'mutations/preview.mdx', + referenceGroup: 'mutations', + }, + + 'mutations.apply': { + memberPath: 'mutations.apply', + description: 'Execute a mutation plan atomically against the document.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'non-idempotent', + supportsDryRun: false, + supportsTrackedMode: true, + possibleFailureCodes: NONE_FAILURES, + throws: T_PLAN_ENGINE, + deterministicTargetResolution: true, + }), + referenceDocPath: 'mutations/apply.mdx', + referenceGroup: 'mutations', + }, + 'capabilities.get': { memberPath: 'capabilities', description: 'Query runtime capabilities supported by the current document engine.', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 05c5d5efa0..0f8bd10c1e 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -25,7 +25,7 @@ import type { InfoInput } from '../info/info.js'; import type { InsertInput } from '../insert/insert.js'; import type { ReplaceInput } from '../replace/replace.js'; import type { DeleteInput } from '../delete/delete.js'; -import type { MutationOptions } from '../write/write.js'; +import type { MutationOptions, RevisionGuardOptions } from '../write/write.js'; import type { FormatBoldInput, FormatItalicInput, @@ -67,6 +67,13 @@ import type { ListTargetInput, ListsExitResult, } from '../lists/lists.types.js'; +import type { QueryMatchInput, QueryMatchOutput } from '../types/query-match.types.js'; +import type { + MutationsApplyInput, + MutationsPreviewInput, + MutationsPreviewOutput, + PlanReceipt, +} from '../types/mutation-plan.types.js'; export interface OperationRegistry { // --- Singleton reads --- @@ -102,14 +109,14 @@ export interface OperationRegistry { 'lists.exit': { input: ListTargetInput; options: MutationOptions; output: ListsExitResult }; // --- comments.* --- - 'comments.add': { input: AddCommentInput; options: never; output: Receipt }; - 'comments.edit': { input: EditCommentInput; options: never; output: Receipt }; - 'comments.reply': { input: ReplyToCommentInput; options: never; output: Receipt }; - 'comments.move': { input: MoveCommentInput; options: never; output: Receipt }; - 'comments.resolve': { input: ResolveCommentInput; options: never; output: Receipt }; - 'comments.remove': { input: RemoveCommentInput; options: never; output: Receipt }; - 'comments.setInternal': { input: SetCommentInternalInput; options: never; output: Receipt }; - 'comments.setActive': { input: SetCommentActiveInput; options: never; output: Receipt }; + 'comments.add': { input: AddCommentInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.edit': { input: EditCommentInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.reply': { input: ReplyToCommentInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.move': { input: MoveCommentInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.resolve': { input: ResolveCommentInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.remove': { input: RemoveCommentInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.setInternal': { input: SetCommentInternalInput; options: RevisionGuardOptions; output: Receipt }; + 'comments.setActive': { input: SetCommentActiveInput; options: RevisionGuardOptions; output: Receipt }; 'comments.goTo': { input: GoToCommentInput; options: never; output: Receipt }; 'comments.get': { input: GetCommentInput; options: never; output: CommentInfo }; 'comments.list': { input: CommentsListQuery | undefined; options: never; output: CommentsListResult }; @@ -117,10 +124,17 @@ export interface OperationRegistry { // --- trackChanges.* --- 'trackChanges.list': { input: TrackChangesListInput | undefined; options: never; output: TrackChangesListResult }; 'trackChanges.get': { input: TrackChangesGetInput; options: never; output: TrackChangeInfo }; - 'trackChanges.accept': { input: TrackChangesAcceptInput; options: never; output: Receipt }; - 'trackChanges.reject': { input: TrackChangesRejectInput; options: never; output: Receipt }; - 'trackChanges.acceptAll': { input: TrackChangesAcceptAllInput; options: never; output: Receipt }; - 'trackChanges.rejectAll': { input: TrackChangesRejectAllInput; options: never; output: Receipt }; + 'trackChanges.accept': { input: TrackChangesAcceptInput; options: RevisionGuardOptions; output: Receipt }; + 'trackChanges.reject': { input: TrackChangesRejectInput; options: RevisionGuardOptions; output: Receipt }; + 'trackChanges.acceptAll': { input: TrackChangesAcceptAllInput; options: RevisionGuardOptions; output: Receipt }; + 'trackChanges.rejectAll': { input: TrackChangesRejectAllInput; options: RevisionGuardOptions; output: Receipt }; + + // --- query.* --- + 'query.match': { input: QueryMatchInput; options: never; output: QueryMatchOutput }; + + // --- mutations.* --- + 'mutations.preview': { input: MutationsPreviewInput; options: never; output: MutationsPreviewOutput }; + 'mutations.apply': { input: MutationsApplyInput; options: never; output: PlanReceipt }; // --- capabilities --- 'capabilities.get': { input: undefined; options: never; output: DocumentApiCapabilities }; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index 4f29d4123e..1739602491 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -56,6 +56,16 @@ const GROUP_METADATA: Record = { success: receiptSuccessSchema, failure: receiptFailureResultSchemaFor('trackChanges.rejectAll'), }, + 'query.match': { + input: objectSchema( + { + select: { oneOf: [textSelectorSchema, nodeSelectorSchema] }, + within: nodeAddressSchema, + require: { enum: ['any', 'first', 'exactlyOne', 'all'] }, + includeNodes: { type: 'boolean' }, + includeStyle: { type: 'boolean' }, + limit: { type: 'integer', minimum: 1 }, + offset: { type: 'integer', minimum: 0 }, + }, + ['select'], + ), + output: objectSchema( + { + evaluatedRevision: { type: 'string' }, + matches: arraySchema( + objectSchema({ + address: nodeAddressSchema, + textRanges: arraySchema(textAddressSchema), + ref: { type: 'string' }, + refStability: { enum: ['ephemeral', 'stable'] }, + style: objectSchema( + { + marks: objectSchema({ + bold: { type: 'boolean' }, + italic: { type: 'boolean' }, + underline: { type: 'boolean' }, + strike: { type: 'boolean' }, + }), + isUniform: { type: 'boolean' }, + }, + ['marks', 'isUniform'], + ), + }), + ), + totalMatches: { type: 'integer', minimum: 0 }, + }, + ['evaluatedRevision', 'matches', 'totalMatches'], + ), + }, + 'mutations.preview': { + input: objectSchema( + { + expectedRevision: { type: 'string' }, + atomic: { const: true }, + changeMode: { enum: ['direct', 'tracked'] }, + steps: arraySchema({ type: 'object' }), + }, + ['expectedRevision', 'atomic', 'changeMode', 'steps'], + ), + output: objectSchema( + { + evaluatedRevision: { type: 'string' }, + steps: arraySchema({ type: 'object' }), + valid: { type: 'boolean' }, + failures: arraySchema({ type: 'object' }), + }, + ['evaluatedRevision', 'steps', 'valid'], + ), + }, + 'mutations.apply': { + input: objectSchema( + { + expectedRevision: { type: 'string' }, + atomic: { const: true }, + changeMode: { enum: ['direct', 'tracked'] }, + steps: arraySchema({ type: 'object' }), + }, + ['expectedRevision', 'atomic', 'changeMode', 'steps'], + ), + output: objectSchema( + { + success: { const: true }, + revision: objectSchema({ before: { type: 'string' }, after: { type: 'string' } }, ['before', 'after']), + steps: arraySchema({ type: 'object' }), + trackedChanges: arraySchema({ type: 'object' }), + timing: objectSchema({ totalMs: { type: 'number' } }, ['totalMs']), + }, + ['success', 'revision', 'steps', 'timing'], + ), + success: objectSchema( + { + success: { const: true }, + revision: objectSchema({ before: { type: 'string' }, after: { type: 'string' } }, ['before', 'after']), + steps: arraySchema({ type: 'object' }), + timing: objectSchema({ totalMs: { type: 'number' } }, ['totalMs']), + }, + ['success', 'revision', 'steps', 'timing'], + ), + }, 'capabilities.get': { input: strictEmptyObjectSchema, output: capabilitiesOutputSchema, diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index dfc3d71c94..d2e8b7ee8d 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -205,6 +205,12 @@ function makeCapabilitiesAdapter(overrides?: Partial): dryRun: { enabled: false }, }, operations: {} as DocumentApiCapabilities['operations'], + planEngine: { + supportedStepOps: [], + supportedNonUniformStrategies: [], + supportedSetMarks: [], + regex: { maxPatternLength: 1024, maxExecutionMs: 100 }, + }, }; return { get: vi.fn(() => ({ ...defaultCapabilities, ...overrides })), @@ -378,7 +384,7 @@ describe('createDocumentApi', () => { const receipt = api.comments.add(input); expect(receipt.success).toBe(true); - expect(commentsAdpt.add).toHaveBeenCalledWith(input); + expect(commentsAdpt.add).toHaveBeenCalledWith(input, undefined); }); it('delegates all comments namespace commands through the comments adapter', () => { @@ -432,13 +438,13 @@ describe('createDocumentApi', () => { expect((getResult as CommentInfo).commentId).toBe('c1'); expect((listResult as CommentsListResult).total).toBe(0); - expect(commentsAdpt.edit).toHaveBeenCalledWith(editInput); - expect(commentsAdpt.reply).toHaveBeenCalledWith(replyInput); - expect(commentsAdpt.move).toHaveBeenCalledWith(moveInput); - expect(commentsAdpt.resolve).toHaveBeenCalledWith(resolveInput); - expect(commentsAdpt.remove).toHaveBeenCalledWith(removeInput); - expect(commentsAdpt.setInternal).toHaveBeenCalledWith(setInternalInput); - expect(commentsAdpt.setActive).toHaveBeenCalledWith(setActiveInput); + expect(commentsAdpt.edit).toHaveBeenCalledWith(editInput, undefined); + expect(commentsAdpt.reply).toHaveBeenCalledWith(replyInput, undefined); + expect(commentsAdpt.move).toHaveBeenCalledWith(moveInput, undefined); + expect(commentsAdpt.resolve).toHaveBeenCalledWith(resolveInput, undefined); + expect(commentsAdpt.remove).toHaveBeenCalledWith(removeInput, undefined); + expect(commentsAdpt.setInternal).toHaveBeenCalledWith(setInternalInput, undefined); + expect(commentsAdpt.setActive).toHaveBeenCalledWith(setActiveInput, undefined); expect(commentsAdpt.goTo).toHaveBeenCalledWith(goToInput); expect(commentsAdpt.get).toHaveBeenCalledWith(getInput); expect(commentsAdpt.list).toHaveBeenCalledWith(listQuery); @@ -1726,10 +1732,13 @@ describe('createDocumentApi', () => { }); 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', - }); + expect(commentsAdpt.add).toHaveBeenCalledWith( + { + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + text: 'comment', + }, + undefined, + ); }); it('sends canonical target through unchanged', () => { @@ -1750,7 +1759,7 @@ describe('createDocumentApi', () => { 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' }); + expect(commentsAdpt.add).toHaveBeenCalledWith({ target, text: 'comment' }, undefined); }); }); @@ -1863,10 +1872,13 @@ describe('createDocumentApi', () => { }); 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 } }, - }); + expect(commentsAdpt.move).toHaveBeenCalledWith( + { + commentId: 'c1', + target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, + }, + undefined, + ); }); }); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index b373d9bd22..9f7594d08a 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -10,9 +10,15 @@ import type { CreateParagraphInput, CreateParagraphResult, DocumentInfo, + MutationsApplyInput, + MutationsPreviewInput, + MutationsPreviewOutput, NodeAddress, NodeInfo, + PlanReceipt, Query, + QueryMatchInput, + QueryMatchOutput, QueryResult, Receipt, Selector, @@ -116,7 +122,7 @@ import { executeTrackChangesReject, executeTrackChangesRejectAll, } from './track-changes/track-changes.js'; -import type { MutationOptions, WriteAdapter } from './write/write.js'; +import type { MutationOptions, RevisionGuardOptions, WriteAdapter } from './write/write.js'; import { executeCapabilities, type CapabilitiesAdapter, @@ -130,7 +136,7 @@ export type { FindAdapter, FindOptions } from './find/find.js'; export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; export type { GetTextAdapter, GetTextInput } from './get-text/get-text.js'; export type { InfoAdapter, InfoInput } from './info/info.js'; -export type { MutationOptions, WriteAdapter, WriteRequest } from './write/write.js'; +export type { WriteAdapter, WriteRequest } from './write/write.js'; export type { FormatAdapter, FormatBoldInput, @@ -193,6 +199,24 @@ export interface CapabilitiesApi { get(): DocumentApiCapabilities; } +export interface QueryApi { + match(input: QueryMatchInput): QueryMatchOutput; +} + +export interface MutationsApi { + preview(input: MutationsPreviewInput): MutationsPreviewOutput; + apply(input: MutationsApplyInput): PlanReceipt; +} + +export interface QueryAdapter { + match(input: QueryMatchInput): QueryMatchOutput; +} + +export interface MutationsAdapter { + preview(input: MutationsPreviewInput): MutationsPreviewOutput; + apply(input: MutationsApplyInput): PlanReceipt; +} + /** * The Document API interface for querying and inspecting document nodes. */ @@ -263,6 +287,14 @@ export interface DocumentApi { * List item operations. */ lists: ListsApi; + /** + * Selector-based query with cardinality contracts for mutation targeting. + */ + query: QueryApi; + /** + * Mutation plan engine — preview and apply atomic mutation plans. + */ + mutations: MutationsApi; /** * Runtime capability introspection. * @@ -296,6 +328,8 @@ export interface DocumentApiAdapters { trackChanges: TrackChangesAdapter; create: CreateAdapter; lists: ListsAdapter; + query: QueryAdapter; + mutations: MutationsAdapter; } /** @@ -335,29 +369,29 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeInfo(adapters.info, input); }, comments: { - add(input: AddCommentInput): Receipt { - return executeAddComment(adapters.comments, input); + add(input: AddCommentInput, options?: RevisionGuardOptions): Receipt { + return executeAddComment(adapters.comments, input, options); }, - edit(input: EditCommentInput): Receipt { - return executeEditComment(adapters.comments, input); + edit(input: EditCommentInput, options?: RevisionGuardOptions): Receipt { + return executeEditComment(adapters.comments, input, options); }, - reply(input: ReplyToCommentInput): Receipt { - return executeReplyToComment(adapters.comments, input); + reply(input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt { + return executeReplyToComment(adapters.comments, input, options); }, - move(input: MoveCommentInput): Receipt { - return executeMoveComment(adapters.comments, input); + move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt { + return executeMoveComment(adapters.comments, input, options); }, - resolve(input: ResolveCommentInput): Receipt { - return executeResolveComment(adapters.comments, input); + resolve(input: ResolveCommentInput, options?: RevisionGuardOptions): Receipt { + return executeResolveComment(adapters.comments, input, options); }, - remove(input: RemoveCommentInput): Receipt { - return executeRemoveComment(adapters.comments, input); + remove(input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt { + return executeRemoveComment(adapters.comments, input, options); }, - setInternal(input: SetCommentInternalInput): Receipt { - return executeSetCommentInternal(adapters.comments, input); + setInternal(input: SetCommentInternalInput, options?: RevisionGuardOptions): Receipt { + return executeSetCommentInternal(adapters.comments, input, options); }, - setActive(input: SetCommentActiveInput): Receipt { - return executeSetCommentActive(adapters.comments, input); + setActive(input: SetCommentActiveInput, options?: RevisionGuardOptions): Receipt { + return executeSetCommentActive(adapters.comments, input, options); }, goTo(input: GoToCommentInput): Receipt { return executeGoToComment(adapters.comments, input); @@ -399,17 +433,17 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { get(input: TrackChangesGetInput): TrackChangeInfo { return executeTrackChangesGet(adapters.trackChanges, input); }, - accept(input: TrackChangesAcceptInput): Receipt { - return executeTrackChangesAccept(adapters.trackChanges, input); + accept(input: TrackChangesAcceptInput, options?: RevisionGuardOptions): Receipt { + return executeTrackChangesAccept(adapters.trackChanges, input, options); }, - reject(input: TrackChangesRejectInput): Receipt { - return executeTrackChangesReject(adapters.trackChanges, input); + reject(input: TrackChangesRejectInput, options?: RevisionGuardOptions): Receipt { + return executeTrackChangesReject(adapters.trackChanges, input, options); }, - acceptAll(input: TrackChangesAcceptAllInput): Receipt { - return executeTrackChangesAcceptAll(adapters.trackChanges, input); + acceptAll(input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions): Receipt { + return executeTrackChangesAcceptAll(adapters.trackChanges, input, options); }, - rejectAll(input: TrackChangesRejectAllInput): Receipt { - return executeTrackChangesRejectAll(adapters.trackChanges, input); + rejectAll(input: TrackChangesRejectAllInput, options?: RevisionGuardOptions): Receipt { + return executeTrackChangesRejectAll(adapters.trackChanges, input, options); }, }, create: { @@ -420,6 +454,19 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeCreateHeading(adapters.create, input, options); }, }, + query: { + match(input: QueryMatchInput): QueryMatchOutput { + return adapters.query.match(input); + }, + }, + mutations: { + preview(input: MutationsPreviewInput): MutationsPreviewOutput { + return adapters.mutations.preview(input); + }, + apply(input: MutationsApplyInput): PlanReceipt { + return adapters.mutations.apply(input); + }, + }, capabilities, lists: { list(query?: ListsListQuery): ListsListResult { diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 73e9fd97f5..ae2eed7319 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -36,6 +36,12 @@ function makeAdapters() { dryRun: { enabled: false }, }, operations: {} as DocumentApiCapabilities['operations'], + planEngine: { + supportedStepOps: [], + supportedNonUniformStrategies: [], + supportedSetMarks: [], + regex: { maxPatternLength: 1024, maxExecutionMs: 100 }, + }, }), ), }; diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index e64cb1cf63..f25b884990 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -65,14 +65,14 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'lists.exit': (input, options) => api.lists.exit(input, options), // --- comments.* --- - 'comments.add': (input) => api.comments.add(input), - 'comments.edit': (input) => api.comments.edit(input), - 'comments.reply': (input) => api.comments.reply(input), - 'comments.move': (input) => api.comments.move(input), - 'comments.resolve': (input) => api.comments.resolve(input), - 'comments.remove': (input) => api.comments.remove(input), - 'comments.setInternal': (input) => api.comments.setInternal(input), - 'comments.setActive': (input) => api.comments.setActive(input), + 'comments.add': (input, options) => api.comments.add(input, options), + 'comments.edit': (input, options) => api.comments.edit(input, options), + 'comments.reply': (input, options) => api.comments.reply(input, options), + 'comments.move': (input, options) => api.comments.move(input, options), + 'comments.resolve': (input, options) => api.comments.resolve(input, options), + 'comments.remove': (input, options) => api.comments.remove(input, options), + 'comments.setInternal': (input, options) => api.comments.setInternal(input, options), + 'comments.setActive': (input, options) => api.comments.setActive(input, options), 'comments.goTo': (input) => api.comments.goTo(input), 'comments.get': (input) => api.comments.get(input), 'comments.list': (input) => api.comments.list(input), @@ -80,10 +80,17 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { // --- trackChanges.* --- 'trackChanges.list': (input) => api.trackChanges.list(input), 'trackChanges.get': (input) => api.trackChanges.get(input), - 'trackChanges.accept': (input) => api.trackChanges.accept(input), - 'trackChanges.reject': (input) => api.trackChanges.reject(input), - 'trackChanges.acceptAll': (input) => api.trackChanges.acceptAll(input), - 'trackChanges.rejectAll': (input) => api.trackChanges.rejectAll(input), + 'trackChanges.accept': (input, options) => api.trackChanges.accept(input, options), + 'trackChanges.reject': (input, options) => api.trackChanges.reject(input, options), + 'trackChanges.acceptAll': (input, options) => api.trackChanges.acceptAll(input, options), + 'trackChanges.rejectAll': (input, options) => api.trackChanges.rejectAll(input, options), + + // --- query.* --- + 'query.match': (input) => api.query.match(input), + + // --- mutations.* --- + 'mutations.preview': (input) => api.mutations.preview(input), + 'mutations.apply': (input) => api.mutations.apply(input), // --- capabilities --- 'capabilities.get': () => api.capabilities(), diff --git a/packages/document-api/src/track-changes/track-changes.ts b/packages/document-api/src/track-changes/track-changes.ts index a46bc40f17..7215406f23 100644 --- a/packages/document-api/src/track-changes/track-changes.ts +++ b/packages/document-api/src/track-changes/track-changes.ts @@ -1,4 +1,5 @@ import type { Receipt, TrackChangeInfo, TrackChangesListQuery, TrackChangesListResult } from '../types/index.js'; +import type { RevisionGuardOptions } from '../write/write.js'; export type TrackChangesListInput = TrackChangesListQuery; @@ -24,13 +25,13 @@ export interface TrackChangesAdapter { /** Retrieve full information for a single tracked change. */ get(input: TrackChangesGetInput): TrackChangeInfo; /** Accept a tracked change, applying it to the document. */ - accept(input: TrackChangesAcceptInput): Receipt; + accept(input: TrackChangesAcceptInput, options?: RevisionGuardOptions): Receipt; /** Reject a tracked change, reverting it from the document. */ - reject(input: TrackChangesRejectInput): Receipt; + reject(input: TrackChangesRejectInput, options?: RevisionGuardOptions): Receipt; /** Accept all tracked changes in the document. */ - acceptAll(input: TrackChangesAcceptAllInput): Receipt; + acceptAll(input: TrackChangesAcceptAllInput, options?: RevisionGuardOptions): Receipt; /** Reject all tracked changes in the document. */ - rejectAll(input: TrackChangesRejectAllInput): Receipt; + rejectAll(input: TrackChangesRejectAllInput, options?: RevisionGuardOptions): Receipt; } export type TrackChangesApi = TrackChangesAdapter; @@ -52,18 +53,34 @@ export function executeTrackChangesGet(adapter: TrackChangesAdapter, input: Trac return adapter.get(input); } -export function executeTrackChangesAccept(adapter: TrackChangesAdapter, input: TrackChangesAcceptInput): Receipt { - return adapter.accept(input); +export function executeTrackChangesAccept( + adapter: TrackChangesAdapter, + input: TrackChangesAcceptInput, + options?: RevisionGuardOptions, +): Receipt { + return adapter.accept(input, options); } -export function executeTrackChangesReject(adapter: TrackChangesAdapter, input: TrackChangesRejectInput): Receipt { - return adapter.reject(input); +export function executeTrackChangesReject( + adapter: TrackChangesAdapter, + input: TrackChangesRejectInput, + options?: RevisionGuardOptions, +): Receipt { + return adapter.reject(input, options); } -export function executeTrackChangesAcceptAll(adapter: TrackChangesAdapter, input: TrackChangesAcceptAllInput): Receipt { - return adapter.acceptAll(input); +export function executeTrackChangesAcceptAll( + adapter: TrackChangesAdapter, + input: TrackChangesAcceptAllInput, + options?: RevisionGuardOptions, +): Receipt { + return adapter.acceptAll(input, options); } -export function executeTrackChangesRejectAll(adapter: TrackChangesAdapter, input: TrackChangesRejectAllInput): Receipt { - return adapter.rejectAll(input); +export function executeTrackChangesRejectAll( + adapter: TrackChangesAdapter, + input: TrackChangesRejectAllInput, + options?: RevisionGuardOptions, +): Receipt { + return adapter.rejectAll(input, options); } diff --git a/packages/document-api/src/types/index.ts b/packages/document-api/src/types/index.ts index a109a63979..1cc84b258a 100644 --- a/packages/document-api/src/types/index.ts +++ b/packages/document-api/src/types/index.ts @@ -13,3 +13,7 @@ export * from './references.types.js'; export * from './track-changes.types.js'; export * from './create.types.js'; export * from './info.types.js'; +export * from './style-policy.types.js'; +export * from './mutation-plan.types.js'; +export * from './query-match.types.js'; +export * from './step-manifest.types.js'; diff --git a/packages/document-api/src/types/info.types.ts b/packages/document-api/src/types/info.types.ts index 48b7344d99..539848f6b2 100644 --- a/packages/document-api/src/types/info.types.ts +++ b/packages/document-api/src/types/info.types.ts @@ -24,4 +24,6 @@ export interface DocumentInfo { counts: DocumentInfoCounts; outline: DocumentInfoOutlineItem[]; capabilities: DocumentInfoCapabilities; + /** Monotonic decimal-string revision counter. Increments on every document change. */ + revision: string; } diff --git a/packages/document-api/src/types/mutation-plan.types.ts b/packages/document-api/src/types/mutation-plan.types.ts new file mode 100644 index 0000000000..201f70447f --- /dev/null +++ b/packages/document-api/src/types/mutation-plan.types.ts @@ -0,0 +1,228 @@ +/** + * Mutation plan types — core input model for the plan engine. + * + * All mutating behavior executes through `mutations.apply`. Every operation + * that changes document state is a step dispatched by the plan engine. + */ + +import type { NodeAddress } from './base.js'; +import type { TextAddress, TrackedChangeAddress } from './address.js'; +import type { TextSelector, NodeSelector } from './query.js'; +import type { InsertStylePolicy, StylePolicy, SetMarks } from './style-policy.types.js'; + +// --------------------------------------------------------------------------- +// Universal targeting model +// --------------------------------------------------------------------------- + +export type SelectWhere = { + by: 'select'; + select: TextSelector | NodeSelector; + within?: NodeAddress; + require: 'first' | 'exactlyOne' | 'all'; +}; + +export type RefWhere = { + by: 'ref'; + ref: string; + within?: NodeAddress; + require: 'first' | 'exactlyOne' | 'all'; +}; + +export type StepWhere = SelectWhere | RefWhere; + +export type AssertWhere = { + by: 'select'; + select: TextSelector | NodeSelector; + within?: NodeAddress; +}; + +// --------------------------------------------------------------------------- +// Step types (first registered step family) +// --------------------------------------------------------------------------- + +export type TextRewriteStep = { + id: string; + op: 'text.rewrite'; + where: SelectWhere; + args: { + replacement: { text: string }; + /** + * Style policy for the replacement text. + * When omitted, defaults to preserve mode: + * inline: { mode: 'preserve', onNonUniform: 'majority' } + * paragraph: { mode: 'preserve' } + */ + style?: StylePolicy; + }; +}; + +export type TextInsertStep = { + id: string; + op: 'text.insert'; + where: { + by: 'select'; + select: TextSelector | NodeSelector; + within?: NodeAddress; + require: 'first' | 'exactlyOne'; + }; + args: { + position: 'before' | 'after'; + content: { text: string }; + style?: InsertStylePolicy; + }; +}; + +export type TextDeleteStep = { + id: string; + op: 'text.delete'; + where: SelectWhere; + args: Record; +}; + +export type StyleApplyStep = { + id: string; + op: 'style.apply'; + where: SelectWhere; + args: { + marks: SetMarks; + }; +}; + +export type AssertStep = { + id: string; + op: 'assert'; + where: AssertWhere; + args: { + expectCount: number; + }; +}; + +export type DomainStep = { + id: string; + op: string; + where: StepWhere; + args: Record; +}; + +export type MutationStep = TextRewriteStep | TextInsertStep | TextDeleteStep | StyleApplyStep | AssertStep | DomainStep; + +// --------------------------------------------------------------------------- +// Plan input +// --------------------------------------------------------------------------- + +export type ChangeMode = 'direct' | 'tracked'; + +export type MutationsApplyInput = { + expectedRevision: string; + atomic: true; + changeMode: ChangeMode; + steps: MutationStep[]; +}; + +export type MutationsPreviewInput = { + expectedRevision: string; + atomic: true; + changeMode: ChangeMode; + steps: MutationStep[]; +}; + +// --------------------------------------------------------------------------- +// Plan output — receipts +// --------------------------------------------------------------------------- + +export type StepEffect = 'changed' | 'noop' | 'assert_passed' | 'assert_failed'; + +export type TextStepResolution = { + target: TextAddress; + range: { from: number; to: number }; + text: string; +}; + +export type TextStepData = { + domain: 'text'; + resolutions: TextStepResolution[]; +}; + +export type AssertStepData = { + domain: 'assert'; + expectedCount: number; + actualCount: number; +}; + +export type DomainStepData = { domain: 'command'; commandDispatched: boolean }; + +export type StepOutcomeData = TextStepData | AssertStepData | DomainStepData; + +export type StepOutcome = { + stepId: string; + op: string; + effect: StepEffect; + matchCount: number; + trackedChangeIds?: string[]; + data: StepOutcomeData; +}; + +export type PlanReceipt = { + success: true; + revision: { + before: string; + after: string; + }; + steps: StepOutcome[]; + trackedChanges?: TrackedChangeAddress[]; + timing: { + totalMs: number; + }; +}; + +// --------------------------------------------------------------------------- +// Preview output +// --------------------------------------------------------------------------- + +export type PreviewFailurePhase = 'compile' | 'execute' | 'assert'; + +export type PreviewFailure = { + code: string; + stepId: string; + phase: PreviewFailurePhase; + message: string; + details?: unknown; +}; + +export type StepPreview = { + stepId: string; + op: string; + resolutions?: TextStepResolution[]; + style?: unknown; +}; + +export type MutationsPreviewOutput = { + evaluatedRevision: string; + steps: StepPreview[]; + valid: boolean; + failures?: PreviewFailure[]; +}; + +// --------------------------------------------------------------------------- +// Plan execution error +// --------------------------------------------------------------------------- + +export type PlanExecutionError = { + code: string; + message: string; + stepId?: string; + details?: unknown; +}; + +// --------------------------------------------------------------------------- +// Revision guard options +// --------------------------------------------------------------------------- + +export type RevisionGuardOptions = { + expectedRevision?: string; +}; + +export type MutationOptions = RevisionGuardOptions & { + changeMode?: ChangeMode; + dryRun?: boolean; +}; diff --git a/packages/document-api/src/types/query-match.types.ts b/packages/document-api/src/types/query-match.types.ts new file mode 100644 index 0000000000..372336a0bb --- /dev/null +++ b/packages/document-api/src/types/query-match.types.ts @@ -0,0 +1,49 @@ +/** + * Types for the `query.match` operation — deterministic matching with + * strict cardinality semantics for mutation targeting and agent planning. + */ + +import type { NodeAddress } from './base.js'; +import type { TextAddress } from './address.js'; +import type { TextSelector, NodeSelector } from './query.js'; +import type { SetMarks } from './style-policy.types.js'; + +export type CardinalityRequirement = 'any' | 'first' | 'exactlyOne' | 'all'; + +export type RefStability = 'ephemeral' | 'stable'; + +export interface QueryMatchInput { + select: TextSelector | NodeSelector; + within?: NodeAddress; + require?: CardinalityRequirement; + includeNodes?: boolean; + includeStyle?: boolean; + limit?: number; + offset?: number; +} + +/** + * Summary of inline marks active on a matched text range. + * Returned when `includeStyle: true` is set on the query input. + */ +export interface MatchStyleSummary { + /** Which core marks are active (true = present on majority of matched text). */ + marks: SetMarks; + /** True when all runs in the matched range share the same mark set. */ + isUniform: boolean; +} + +export interface MatchResult { + address: NodeAddress; + textRanges?: TextAddress[]; + ref?: string; + refStability?: RefStability; + /** Inline style summary for the matched range. Present when `includeStyle: true`. */ + style?: MatchStyleSummary; +} + +export interface QueryMatchOutput { + evaluatedRevision: string; + matches: MatchResult[]; + totalMatches: number; +} diff --git a/packages/document-api/src/types/receipt.ts b/packages/document-api/src/types/receipt.ts index 78a358102b..d721f2302d 100644 --- a/packages/document-api/src/types/receipt.ts +++ b/packages/document-api/src/types/receipt.ts @@ -3,7 +3,20 @@ import type { EntityAddress, TextAddress, TrackedChangeAddress } from './address export type ReceiptInsert = TrackedChangeAddress; export type ReceiptEntity = EntityAddress; -export type ReceiptFailureCode = 'NO_OP' | 'INVALID_TARGET' | 'TARGET_NOT_FOUND' | 'CAPABILITY_UNAVAILABLE'; +export type ReceiptFailureCode = + | 'NO_OP' + | 'INVALID_TARGET' + | 'TARGET_NOT_FOUND' + | 'CAPABILITY_UNAVAILABLE' + | 'REVISION_MISMATCH' + | 'MATCH_NOT_FOUND' + | 'AMBIGUOUS_MATCH' + | 'STYLE_CONFLICT' + | 'PRECONDITION_FAILED' + | 'INVALID_INPUT' + | 'CROSS_BLOCK_MATCH' + | 'PLAN_CONFLICT_OVERLAP' + | 'INVALID_STEP_COMBINATION'; export type ReceiptFailure = { code: ReceiptFailureCode; diff --git a/packages/document-api/src/types/step-manifest.types.ts b/packages/document-api/src/types/step-manifest.types.ts new file mode 100644 index 0000000000..4c2cc3fb91 --- /dev/null +++ b/packages/document-api/src/types/step-manifest.types.ts @@ -0,0 +1,33 @@ +/** + * Step manifest types — public, engine-agnostic metadata for step ops. + * + * `StepManifest` is the single source of truth for schema generation, + * docs, tool catalogs, capabilities, and wrapper generation. + * It lives in document-api (engine-agnostic) and is consumed by + * super-editor executor registration at runtime. + */ + +export interface IdentityStrategy { + refType: string; + stableAcrossUndoRedo: boolean; + stableAcrossConcurrentEdits: boolean; + usableInWhere: boolean; +} + +export interface StepCapabilities { + idempotency: 'idempotent' | 'non-idempotent'; + supportsDryRun: boolean; + supportsTrackedMode: boolean; + possibleFailureCodes: string[]; + deterministicTargetResolution: boolean; + identityStrategy: IdentityStrategy; +} + +export interface StepManifest { + opId: string; + domain: string; + argsSchema: Record; + outcomeSchema: Record; + capabilities: StepCapabilities; + compatibleDomains?: string[]; +} diff --git a/packages/document-api/src/types/style-policy.types.ts b/packages/document-api/src/types/style-policy.types.ts new file mode 100644 index 0000000000..a8131850a2 --- /dev/null +++ b/packages/document-api/src/types/style-policy.types.ts @@ -0,0 +1,37 @@ +/** + * Style policy types for mutation plan steps. + * + * Defines how inline and paragraph styles are handled during text rewrites. + */ + +export type NonUniformStrategy = 'error' | 'useLeadingRun' | 'majority' | 'union'; + +export interface SetMarks { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; +} + +export interface InlineStylePolicy { + mode: 'preserve' | 'set' | 'clear' | 'merge'; + requireUniform?: boolean; + onNonUniform?: NonUniformStrategy; + setMarks?: SetMarks; +} + +export interface ParagraphStylePolicy { + mode: 'preserve' | 'set' | 'clear'; +} + +export interface StylePolicy { + inline: InlineStylePolicy; + paragraph?: ParagraphStylePolicy; +} + +export interface InsertStylePolicy { + inline: { + mode: 'inherit' | 'set' | 'clear'; + setMarks?: SetMarks; + }; +} diff --git a/packages/document-api/src/write/write.ts b/packages/document-api/src/write/write.ts index c02732ec9c..e071758604 100644 --- a/packages/document-api/src/write/write.ts +++ b/packages/document-api/src/write/write.ts @@ -3,7 +3,12 @@ import type { BlockRelativeLocator, BlockRelativeRange } from './locator.js'; export type ChangeMode = 'direct' | 'tracked'; -export interface MutationOptions { +export interface RevisionGuardOptions { + /** When provided, the engine rejects with REVISION_MISMATCH if the document has advanced past this revision. */ + expectedRevision?: string; +} + +export interface MutationOptions extends RevisionGuardOptions { /** * Controls whether mutation applies directly or as a tracked change. * Defaults to `direct`. @@ -48,6 +53,7 @@ export interface WriteAdapter { export function normalizeMutationOptions(options?: MutationOptions): MutationOptions { return { + expectedRevision: options?.expectedRevision, changeMode: options?.changeMode ?? 'direct', dryRun: options?.dryRun ?? false, }; diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 89953c6e76..7c4720ffd1 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -14,33 +14,38 @@ import { TrackInsertMarkName, } from '../../extensions/track-changes/constants.js'; import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; -import { createCommentsAdapter } from '../comments-adapter.js'; -import { createParagraphAdapter, createHeadingAdapter } from '../create-adapter.js'; +import { createCommentsWrapper } from '../plan-engine/comments-wrappers.js'; +import { createParagraphWrapper, createHeadingWrapper } from '../plan-engine/create-wrappers.js'; import { - formatBoldAdapter, - formatItalicAdapter, - formatUnderlineAdapter, - formatStrikethroughAdapter, -} from '../format-adapter.js'; + writeWrapper, + formatBoldWrapper, + formatItalicWrapper, + formatUnderlineWrapper, + formatStrikethroughWrapper, +} from '../plan-engine/plan-wrappers.js'; import { getDocumentApiCapabilities } from '../capabilities-adapter.js'; import { - listsExitAdapter, - listsIndentAdapter, - listsInsertAdapter, - listsOutdentAdapter, - listsRestartAdapter, - listsSetTypeAdapter, -} from '../lists-adapter.js'; + listsExitWrapper, + listsIndentWrapper, + listsInsertWrapper, + listsOutdentWrapper, + listsRestartWrapper, + listsSetTypeWrapper, +} from '../plan-engine/lists-wrappers.js'; import { - trackChangesAcceptAdapter, - trackChangesAcceptAllAdapter, - trackChangesRejectAdapter, - trackChangesRejectAllAdapter, -} from '../track-changes-adapter.js'; + trackChangesAcceptWrapper, + trackChangesAcceptAllWrapper, + trackChangesRejectWrapper, + trackChangesRejectAllWrapper, +} from '../plan-engine/track-changes-wrappers.js'; import { toCanonicalTrackedChangeId } from '../helpers/tracked-change-resolver.js'; -import { writeAdapter } from '../write-adapter.js'; +import { executePlan, executeCompiledPlan } from '../plan-engine/executor.js'; +import { registerBuiltInExecutors } from '../plan-engine/register-executors.js'; import { validateJsonSchema } from './schema-validator.js'; +// Ensure built-in executors are registered for tests that call executePlan directly +registerBuiltInExecutors(); + const mockedDeps = vi.hoisted(() => ({ resolveCommentAnchorsById: vi.fn(() => []), listCommentAnchors: vi.fn(() => []), @@ -60,7 +65,7 @@ const INTERNAL_SCHEMAS = buildInternalContractSchemas(); type MutationVector = { throwCase: () => unknown; - failureCase: () => unknown; + failureCase?: () => unknown; applyCase: () => unknown; }; @@ -133,6 +138,9 @@ function makeTextEditor( insertText: ReturnType; delete: ReturnType; addMark: ReturnType; + removeMark: ReturnType; + replaceWith: ReturnType; + insert: ReturnType; setMeta: ReturnType; }; } { @@ -148,11 +156,22 @@ function makeTextEditor( insertText: vi.fn(), delete: vi.fn(), addMark: vi.fn(), + removeMark: vi.fn(), + replaceWith: vi.fn(), + insert: vi.fn(), setMeta: vi.fn(), + mapping: { map: (pos: number) => pos }, + docChanged: false, + doc: { + resolve: () => ({ marks: () => [] }), + }, }; tr.insertText.mockReturnValue(tr); tr.delete.mockReturnValue(tr); tr.addMark.mockReturnValue(tr); + tr.removeMark.mockReturnValue(tr); + tr.replaceWith.mockReturnValue(tr); + tr.insert.mockReturnValue(tr); tr.setMeta.mockReturnValue(tr); const dispatch = vi.fn(); @@ -183,22 +202,41 @@ function makeTextEditor( exitListItemAt: vi.fn(() => true), }; - const baseSchema = { - marks: { - bold: { - create: vi.fn(() => ({ type: 'bold' })), - }, - italic: { - create: vi.fn(() => ({ type: 'italic' })), - }, - underline: { - create: vi.fn(() => ({ type: 'underline' })), - }, - strike: { - create: vi.fn(() => ({ type: 'strike' })), - }, - [TrackFormatMarkName]: { - create: vi.fn(() => ({ type: TrackFormatMarkName })), + const baseMarks = { + bold: { + create: vi.fn(() => ({ type: 'bold' })), + }, + italic: { + create: vi.fn(() => ({ type: 'italic' })), + }, + underline: { + create: vi.fn(() => ({ type: 'underline' })), + }, + strike: { + create: vi.fn(() => ({ type: 'strike' })), + }, + [TrackFormatMarkName]: { + create: vi.fn(() => ({ type: TrackFormatMarkName })), + }, + }; + + const stateSchema = { + marks: baseMarks, + text: (t: string, m?: unknown[]) => ({ type: { name: 'text' }, text: t, marks: m ?? [] }), + nodes: { + paragraph: { + createAndFill: vi.fn((attrs?: unknown, content?: unknown) => ({ + type: { name: 'paragraph' }, + attrs, + content, + nodeSize: 2, + })), + create: vi.fn((attrs?: unknown, content?: unknown) => ({ + type: { name: 'paragraph' }, + attrs, + content, + nodeSize: 2, + })), }, }, }; @@ -212,8 +250,15 @@ function makeTextEditor( const end = Math.max(start, to - 1); return text.slice(start, end); }), + nodesBetween: vi.fn((_from: number, _to: number, callback: (node: any, pos: number) => boolean | void) => { + // Visit paragraph at pos 0, then text child at pos 1 + if (callback({ ...paragraph, marks: [] }, 0) !== false) { + callback({ ...textNode, marks: [] }, 1); + } + }), }, tr, + schema: stateSchema, }, can: vi.fn(() => ({ insertParagraphAt: vi.fn(() => true), @@ -228,7 +273,7 @@ function makeTextEditor( dispatch, ...overrides, schema: { - ...baseSchema, + marks: baseMarks, ...(overrides.schema ?? {}), }, commands: { @@ -313,8 +358,15 @@ function makeListEditor(children: MockParagraphNode[], commandOverrides: Record< insertTrackedChange: vi.fn(() => true), }; + const tr = { + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: false, + }; + return { - state: { doc }, + state: { doc, tr }, + dispatch: vi.fn(), commands: { ...baseCommands, ...commandOverrides, @@ -407,7 +459,7 @@ const mutationVectors: Partial> = { insert: { throwCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'insert', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 0 } }, text: 'X' }, { changeMode: 'direct' }, @@ -415,7 +467,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'insert', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, text: '' }, { changeMode: 'direct' }, @@ -423,7 +475,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'insert', target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, text: 'X' }, { changeMode: 'direct' }, @@ -433,7 +485,7 @@ const mutationVectors: Partial> = { replace: { throwCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'replace', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, text: 'X' }, { changeMode: 'direct' }, @@ -441,7 +493,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor('Hello'); - return writeAdapter( + return writeWrapper( editor, { kind: 'replace', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, text: 'Hello' }, { changeMode: 'direct' }, @@ -449,7 +501,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor('Hello'); - return writeAdapter( + return writeWrapper( editor, { kind: 'replace', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, text: 'World' }, { changeMode: 'direct' }, @@ -459,7 +511,7 @@ const mutationVectors: Partial> = { delete: { throwCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'delete', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } } }, { changeMode: 'direct' }, @@ -467,7 +519,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'delete', target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } } }, { changeMode: 'direct' }, @@ -475,7 +527,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor(); - return writeAdapter( + return writeWrapper( editor, { kind: 'delete', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } }, { changeMode: 'direct' }, @@ -485,7 +537,7 @@ const mutationVectors: Partial> = { 'format.bold': { throwCase: () => { const { editor } = makeTextEditor(); - return formatBoldAdapter( + return formatBoldWrapper( editor, { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, @@ -495,7 +547,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor(); - return formatBoldAdapter( + return formatBoldWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, @@ -505,7 +557,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor(); - return formatBoldAdapter( + return formatBoldWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, @@ -517,7 +569,7 @@ const mutationVectors: Partial> = { 'format.italic': { throwCase: () => { const { editor } = makeTextEditor(); - return formatItalicAdapter( + return formatItalicWrapper( editor, { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } } }, { changeMode: 'direct' }, @@ -525,7 +577,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor(); - return formatItalicAdapter( + return formatItalicWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } } }, { changeMode: 'direct' }, @@ -533,7 +585,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor(); - return formatItalicAdapter( + return formatItalicWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct' }, @@ -543,7 +595,7 @@ const mutationVectors: Partial> = { 'format.underline': { throwCase: () => { const { editor } = makeTextEditor(); - return formatUnderlineAdapter( + return formatUnderlineWrapper( editor, { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } } }, { changeMode: 'direct' }, @@ -551,7 +603,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor(); - return formatUnderlineAdapter( + return formatUnderlineWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } } }, { changeMode: 'direct' }, @@ -559,7 +611,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor(); - return formatUnderlineAdapter( + return formatUnderlineWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct' }, @@ -569,7 +621,7 @@ const mutationVectors: Partial> = { 'format.strikethrough': { throwCase: () => { const { editor } = makeTextEditor(); - return formatStrikethroughAdapter( + return formatStrikethroughWrapper( editor, { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } } }, { changeMode: 'direct' }, @@ -577,7 +629,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor(); - return formatStrikethroughAdapter( + return formatStrikethroughWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } } }, { changeMode: 'direct' }, @@ -585,7 +637,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor(); - return formatStrikethroughAdapter( + return formatStrikethroughWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct' }, @@ -595,21 +647,21 @@ const mutationVectors: Partial> = { 'create.paragraph': { throwCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: undefined } }); - return createParagraphAdapter(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); + return createParagraphWrapper(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); }, failureCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: vi.fn(() => false) } }); - return createParagraphAdapter(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); + return createParagraphWrapper(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); }, applyCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt: vi.fn(() => true) } }); - return createParagraphAdapter(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); + return createParagraphWrapper(editor, { at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }); }, }, 'create.heading': { throwCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertHeadingAt: undefined } }); - return createHeadingAdapter( + return createHeadingWrapper( editor, { level: 1, at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }, @@ -617,7 +669,7 @@ const mutationVectors: Partial> = { }, failureCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertHeadingAt: vi.fn(() => false) } }); - return createHeadingAdapter( + return createHeadingWrapper( editor, { level: 1, at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }, @@ -625,7 +677,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const { editor } = makeTextEditor('Hello', { commands: { insertHeadingAt: vi.fn(() => true) } }); - return createHeadingAdapter( + return createHeadingWrapper( editor, { level: 2, at: { kind: 'documentEnd' }, text: 'X' }, { changeMode: 'direct' }, @@ -635,7 +687,7 @@ const mutationVectors: Partial> = { 'lists.insert': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })]); - return listsInsertAdapter( + return listsInsertWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'missing' }, position: 'after', text: 'X' }, { changeMode: 'direct' }, @@ -645,7 +697,7 @@ const mutationVectors: Partial> = { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })], { insertListItemAt: vi.fn(() => false), }); - return listsInsertAdapter( + return listsInsertWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, position: 'after', text: 'X' }, { changeMode: 'direct' }, @@ -653,7 +705,7 @@ const mutationVectors: Partial> = { }, applyCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })]); - return listsInsertAdapter( + return listsInsertWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, position: 'after', text: 'X' }, { changeMode: 'direct' }, @@ -663,7 +715,7 @@ const mutationVectors: Partial> = { 'lists.setType': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - return listsSetTypeAdapter( + return listsSetTypeWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered' }, { changeMode: 'tracked' }, @@ -671,14 +723,14 @@ const mutationVectors: Partial> = { }, failureCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - return listsSetTypeAdapter(editor, { + return listsSetTypeWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'bullet', }); }, applyCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); - return listsSetTypeAdapter(editor, { + return listsSetTypeWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered', }); @@ -687,7 +739,7 @@ const mutationVectors: Partial> = { 'lists.indent': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsIndentAdapter( + return listsIndentWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, @@ -696,14 +748,14 @@ const mutationVectors: Partial> = { failureCase: () => { const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(false); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - const result = listsIndentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + const result = listsIndentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); hasDefinitionSpy.mockRestore(); return result; }, applyCase: () => { const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - const result = listsIndentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + const result = listsIndentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); hasDefinitionSpy.mockRestore(); return result; }, @@ -711,7 +763,7 @@ const mutationVectors: Partial> = { 'lists.outdent': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); - return listsOutdentAdapter( + return listsOutdentWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, @@ -719,17 +771,17 @@ const mutationVectors: Partial> = { }, failureCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsOutdentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + return listsOutdentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); }, applyCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); - return listsOutdentAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + return listsOutdentWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); }, }, 'lists.restart': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsRestartAdapter( + return listsRestartWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, @@ -737,20 +789,20 @@ const mutationVectors: Partial> = { }, failureCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsRestartAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + return listsRestartWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); }, applyCase: () => { const editor = makeListEditor([ makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '1.', path: [1] }), makeListParagraph({ id: 'li-2', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '2.', path: [2] }), ]); - return listsRestartAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }); + return listsRestartWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }); }, }, 'lists.exit': { throwCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsExitAdapter( + return listsExitWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'tracked' }, @@ -760,31 +812,31 @@ const mutationVectors: Partial> = { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })], { exitListItemAt: vi.fn(() => false), }); - return listsExitAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + return listsExitWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); }, applyCase: () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); - return listsExitAdapter(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); + return listsExitWrapper(editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }); }, }, 'comments.add': { throwCase: () => { const editor = makeCommentsEditor([], { addComment: undefined }); - return createCommentsAdapter(editor).add({ + return createCommentsWrapper(editor).add({ target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }, text: 'X', }); }, failureCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).add({ + return createCommentsWrapper(editor).add({ target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, text: 'X', }); }, applyCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).add({ + return createCommentsWrapper(editor).add({ target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }, text: 'X', }); @@ -793,35 +845,35 @@ const mutationVectors: Partial> = { 'comments.edit': { throwCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).edit({ commentId: 'missing', text: 'X' }); + return createCommentsWrapper(editor).edit({ commentId: 'missing', text: 'X' }); }, failureCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1', { commentText: 'Same' })]); - return createCommentsAdapter(editor).edit({ commentId: 'c1', text: 'Same' }); + return createCommentsWrapper(editor).edit({ commentId: 'c1', text: 'Same' }); }, applyCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1', { commentText: 'Old' })]); - return createCommentsAdapter(editor).edit({ commentId: 'c1', text: 'New' }); + return createCommentsWrapper(editor).edit({ commentId: 'c1', text: 'New' }); }, }, 'comments.reply': { throwCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).reply({ parentCommentId: 'missing', text: 'X' }); + return createCommentsWrapper(editor).reply({ parentCommentId: 'missing', text: 'X' }); }, failureCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1')]); - return createCommentsAdapter(editor).reply({ parentCommentId: '', text: 'X' }); + return createCommentsWrapper(editor).reply({ parentCommentId: '', text: 'X' }); }, applyCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1')]); - return createCommentsAdapter(editor).reply({ parentCommentId: 'c1', text: 'Reply' }); + return createCommentsWrapper(editor).reply({ parentCommentId: 'c1', text: 'Reply' }); }, }, 'comments.move': { throwCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1')]); - return createCommentsAdapter(editor).move({ + return createCommentsWrapper(editor).move({ commentId: 'c1', target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 2 } }, }); @@ -829,7 +881,7 @@ const mutationVectors: Partial> = { failureCase: () => { mockedDeps.resolveCommentAnchorsById.mockImplementation(() => []); const editor = makeCommentsEditor([makeCommentRecord('c1')]); - return createCommentsAdapter(editor).move({ + return createCommentsWrapper(editor).move({ commentId: 'c1', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, }); @@ -850,7 +902,7 @@ const mutationVectors: Partial> = { : [], ); const editor = makeCommentsEditor([makeCommentRecord('c1')]); - return createCommentsAdapter(editor).move({ + return createCommentsWrapper(editor).move({ commentId: 'c1', target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 3 } }, }); @@ -859,21 +911,21 @@ const mutationVectors: Partial> = { 'comments.resolve': { throwCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).resolve({ commentId: 'missing' }); + return createCommentsWrapper(editor).resolve({ commentId: 'missing' }); }, failureCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1', { isDone: true })]); - return createCommentsAdapter(editor).resolve({ commentId: 'c1' }); + return createCommentsWrapper(editor).resolve({ commentId: 'c1' }); }, applyCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1', { isDone: false })]); - return createCommentsAdapter(editor).resolve({ commentId: 'c1' }); + return createCommentsWrapper(editor).resolve({ commentId: 'c1' }); }, }, 'comments.remove': { throwCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).remove({ commentId: 'missing' }); + return createCommentsWrapper(editor).remove({ commentId: 'missing' }); }, failureCase: () => { mockedDeps.resolveCommentAnchorsById.mockImplementation((_editor, id) => @@ -891,103 +943,118 @@ const mutationVectors: Partial> = { : [], ); const editor = makeCommentsEditor([], { removeComment: vi.fn(() => false) }); - return createCommentsAdapter(editor).remove({ commentId: 'c1' }); + return createCommentsWrapper(editor).remove({ commentId: 'c1' }); }, applyCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1')], { removeComment: vi.fn(() => true) }); - return createCommentsAdapter(editor).remove({ commentId: 'c1' }); + return createCommentsWrapper(editor).remove({ commentId: 'c1' }); }, }, 'comments.setInternal': { throwCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).setInternal({ commentId: 'missing', isInternal: true }); + return createCommentsWrapper(editor).setInternal({ commentId: 'missing', isInternal: true }); }, failureCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1', { isInternal: true })]); - return createCommentsAdapter(editor).setInternal({ commentId: 'c1', isInternal: true }); + return createCommentsWrapper(editor).setInternal({ commentId: 'c1', isInternal: true }); }, applyCase: () => { const editor = makeCommentsEditor([makeCommentRecord('c1', { isInternal: false })]); - return createCommentsAdapter(editor).setInternal({ commentId: 'c1', isInternal: true }); + return createCommentsWrapper(editor).setInternal({ commentId: 'c1', isInternal: true }); }, }, 'comments.setActive': { throwCase: () => { const editor = makeCommentsEditor(); - return createCommentsAdapter(editor).setActive({ commentId: 'missing' }); + return createCommentsWrapper(editor).setActive({ commentId: 'missing' }); }, failureCase: () => { const editor = makeCommentsEditor([], { setActiveComment: vi.fn(() => false) }); - return createCommentsAdapter(editor).setActive({ commentId: null }); + return createCommentsWrapper(editor).setActive({ commentId: null }); }, applyCase: () => { const editor = makeCommentsEditor([], { setActiveComment: vi.fn(() => true) }); - return createCommentsAdapter(editor).setActive({ commentId: null }); + return createCommentsWrapper(editor).setActive({ commentId: null }); }, }, 'trackChanges.accept': { throwCase: () => { setTrackChanges([]); const { editor } = makeTextEditor(); - return trackChangesAcceptAdapter(editor, { id: 'missing' }); + return trackChangesAcceptWrapper(editor, { id: 'missing' }); }, failureCase: () => { setTrackChanges([makeTrackedChange('tc-1')]); const { editor } = makeTextEditor('Hello', { commands: { acceptTrackedChangeById: vi.fn(() => false) } }); - return trackChangesAcceptAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + return trackChangesAcceptWrapper(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); }, applyCase: () => { setTrackChanges([makeTrackedChange('tc-1')]); const { editor } = makeTextEditor('Hello', { commands: { acceptTrackedChangeById: vi.fn(() => true) } }); - return trackChangesAcceptAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + return trackChangesAcceptWrapper(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); }, }, 'trackChanges.reject': { throwCase: () => { setTrackChanges([]); const { editor } = makeTextEditor(); - return trackChangesRejectAdapter(editor, { id: 'missing' }); + return trackChangesRejectWrapper(editor, { id: 'missing' }); }, failureCase: () => { setTrackChanges([makeTrackedChange('tc-1')]); const { editor } = makeTextEditor('Hello', { commands: { rejectTrackedChangeById: vi.fn(() => false) } }); - return trackChangesRejectAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + return trackChangesRejectWrapper(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); }, applyCase: () => { setTrackChanges([makeTrackedChange('tc-1')]); const { editor } = makeTextEditor('Hello', { commands: { rejectTrackedChangeById: vi.fn(() => true) } }); - return trackChangesRejectAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); + return trackChangesRejectWrapper(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-1') }); }, }, 'trackChanges.acceptAll': { throwCase: () => { const { editor } = makeTextEditor('Hello', { commands: { acceptAllTrackedChanges: undefined } }); - return trackChangesAcceptAllAdapter(editor, {}); + return trackChangesAcceptAllWrapper(editor, {}); }, failureCase: () => { const { editor } = makeTextEditor('Hello', { commands: { acceptAllTrackedChanges: vi.fn(() => false) } }); - return trackChangesAcceptAllAdapter(editor, {}); + return trackChangesAcceptAllWrapper(editor, {}); }, applyCase: () => { setTrackChanges([makeTrackedChange('tc-1')]); const { editor } = makeTextEditor('Hello', { commands: { acceptAllTrackedChanges: vi.fn(() => true) } }); - return trackChangesAcceptAllAdapter(editor, {}); + return trackChangesAcceptAllWrapper(editor, {}); }, }, 'trackChanges.rejectAll': { throwCase: () => { const { editor } = makeTextEditor('Hello', { commands: { rejectAllTrackedChanges: undefined } }); - return trackChangesRejectAllAdapter(editor, {}); + return trackChangesRejectAllWrapper(editor, {}); }, failureCase: () => { const { editor } = makeTextEditor('Hello', { commands: { rejectAllTrackedChanges: vi.fn(() => false) } }); - return trackChangesRejectAllAdapter(editor, {}); + return trackChangesRejectAllWrapper(editor, {}); }, applyCase: () => { setTrackChanges([makeTrackedChange('tc-1')]); const { editor } = makeTextEditor('Hello', { commands: { rejectAllTrackedChanges: vi.fn(() => true) } }); - return trackChangesRejectAllAdapter(editor, {}); + return trackChangesRejectAllWrapper(editor, {}); + }, + }, + 'mutations.apply': { + throwCase: () => { + const { editor } = makeTextEditor(); + return executePlan(editor, { + expectedRevision: '0', + atomic: true, + changeMode: 'direct', + steps: [], + }); + }, + applyCase: () => { + const { editor } = makeTextEditor(); + return executeCompiledPlan(editor, { mutationSteps: [], assertSteps: [] }, { changeMode: 'direct' }); }, }, }; @@ -995,7 +1062,7 @@ const mutationVectors: Partial> = { const dryRunVectors: Partial unknown>> = { insert: () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = writeAdapter( + const result = writeWrapper( editor, { kind: 'insert', target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, text: 'X' }, { changeMode: 'direct', dryRun: true }, @@ -1006,7 +1073,7 @@ const dryRunVectors: Partial unknown>> = { }, replace: () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = writeAdapter( + const result = writeWrapper( editor, { kind: 'replace', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, text: 'World' }, { changeMode: 'direct', dryRun: true }, @@ -1017,7 +1084,7 @@ const dryRunVectors: Partial unknown>> = { }, delete: () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = writeAdapter( + const result = writeWrapper( editor, { kind: 'delete', target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } }, { changeMode: 'direct', dryRun: true }, @@ -1028,7 +1095,7 @@ const dryRunVectors: Partial unknown>> = { }, 'format.bold': () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = formatBoldAdapter( + const result = formatBoldWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct', dryRun: true }, @@ -1039,7 +1106,7 @@ const dryRunVectors: Partial unknown>> = { }, 'format.italic': () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = formatItalicAdapter( + const result = formatItalicWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct', dryRun: true }, @@ -1050,7 +1117,7 @@ const dryRunVectors: Partial unknown>> = { }, 'format.underline': () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = formatUnderlineAdapter( + const result = formatUnderlineWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct', dryRun: true }, @@ -1061,7 +1128,7 @@ const dryRunVectors: Partial unknown>> = { }, 'format.strikethrough': () => { const { editor, dispatch, tr } = makeTextEditor(); - const result = formatStrikethroughAdapter( + const result = formatStrikethroughWrapper( editor, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }, { changeMode: 'direct', dryRun: true }, @@ -1073,7 +1140,7 @@ const dryRunVectors: Partial unknown>> = { 'create.paragraph': () => { const insertParagraphAt = vi.fn(() => true); const { editor } = makeTextEditor('Hello', { commands: { insertParagraphAt } }); - const result = createParagraphAdapter( + const result = createParagraphWrapper( editor, { at: { kind: 'documentEnd' }, text: 'Dry run paragraph' }, { changeMode: 'direct', dryRun: true }, @@ -1084,7 +1151,7 @@ const dryRunVectors: Partial unknown>> = { 'create.heading': () => { const insertHeadingAt = vi.fn(() => true); const { editor } = makeTextEditor('Hello', { commands: { insertHeadingAt } }); - const result = createHeadingAdapter( + const result = createHeadingWrapper( editor, { level: 1, at: { kind: 'documentEnd' }, text: 'Dry run heading' }, { changeMode: 'direct', dryRun: true }, @@ -1095,7 +1162,7 @@ const dryRunVectors: Partial unknown>> = { 'lists.insert': () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'decimal' })]); const insertListItemAt = editor.commands!.insertListItemAt as ReturnType; - const result = listsInsertAdapter( + const result = listsInsertWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, position: 'after', text: 'X' }, { changeMode: 'direct', dryRun: true }, @@ -1106,7 +1173,7 @@ const dryRunVectors: Partial unknown>> = { 'lists.setType': () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, numberingType: 'bullet' })]); const setListTypeAt = editor.commands!.setListTypeAt as ReturnType; - const result = listsSetTypeAdapter( + const result = listsSetTypeWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, kind: 'ordered' }, { changeMode: 'direct', dryRun: true }, @@ -1118,7 +1185,7 @@ const dryRunVectors: Partial unknown>> = { const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); const increaseListIndent = editor.commands!.increaseListIndent as ReturnType; - const result = listsIndentAdapter( + const result = listsIndentWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'direct', dryRun: true }, @@ -1130,7 +1197,7 @@ const dryRunVectors: Partial unknown>> = { 'lists.outdent': () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 1, numberingType: 'decimal' })]); const decreaseListIndent = editor.commands!.decreaseListIndent as ReturnType; - const result = listsOutdentAdapter( + const result = listsOutdentWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'direct', dryRun: true }, @@ -1144,7 +1211,7 @@ const dryRunVectors: Partial unknown>> = { makeListParagraph({ id: 'li-2', numId: 1, ilvl: 0, numberingType: 'decimal', markerText: '2.', path: [2] }), ]); const restartNumbering = editor.commands!.restartNumbering as ReturnType; - const result = listsRestartAdapter( + const result = listsRestartWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }, { changeMode: 'direct', dryRun: true }, @@ -1155,7 +1222,7 @@ const dryRunVectors: Partial unknown>> = { 'lists.exit': () => { const editor = makeListEditor([makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, numberingType: 'decimal' })]); const exitListItemAt = editor.commands!.exitListItemAt as ReturnType; - const result = listsExitAdapter( + const result = listsExitWrapper( editor, { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, { changeMode: 'direct', dryRun: true }, @@ -1186,7 +1253,9 @@ describe('document-api adapter conformance', () => { if (!COMMAND_CATALOG[operationId].mutates) continue; expect(COMMAND_CATALOG[operationId].throws.postApplyForbidden).toBe(true); expect(schema.success).toBeDefined(); - expect(schema.failure).toBeDefined(); + if (COMMAND_CATALOG[operationId].possibleFailureCodes.length > 0) { + expect(schema.failure).toBeDefined(); + } } }); @@ -1207,6 +1276,7 @@ describe('document-api adapter conformance', () => { it('enforces structured non-applied outcomes for every mutating operation', () => { for (const operationId of MUTATING_OPERATION_IDS) { const vector = mutationVectors[operationId]!; + if (!vector.failureCase) continue; const result = vector.failureCase() as { success?: boolean; failure?: { code: string } }; expect(result.success).toBe(false); if (result.success !== false || !result.failure) continue; @@ -1287,7 +1357,7 @@ describe('document-api adapter conformance', () => { }; setTrackChanges([change]); const { editor } = makeTextEditor(); - const reject = trackChangesRejectAdapter(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-delete-1') }); + const reject = trackChangesRejectWrapper(editor, { id: requireCanonicalTrackChangeId(editor, 'tc-delete-1') }); expect(reject.success).toBe(true); assertSchema('trackChanges.reject', 'output', reject); assertSchema('trackChanges.reject', 'success', reject); diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts index 714fc50cd0..04dc898c65 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.test.ts @@ -8,6 +8,7 @@ function makeEditor(): Editor { commands: {}, schema: { marks: {} }, options: {}, + on: () => {}, } as unknown as Editor; } diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 4de0d35591..b18fb092c2 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -5,33 +5,38 @@ import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; import { infoAdapter } from './info-adapter.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; -import { createCommentsAdapter } from './comments-adapter.js'; -import { writeAdapter } from './write-adapter.js'; +import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { - formatBoldAdapter, - formatItalicAdapter, - formatUnderlineAdapter, - formatStrikethroughAdapter, -} from './format-adapter.js'; + writeWrapper, + formatBoldWrapper, + formatItalicWrapper, + formatUnderlineWrapper, + formatStrikethroughWrapper, +} from './plan-engine/plan-wrappers.js'; import { - trackChangesListAdapter, - trackChangesGetAdapter, - trackChangesAcceptAdapter, - trackChangesRejectAdapter, - trackChangesAcceptAllAdapter, - trackChangesRejectAllAdapter, -} from './track-changes-adapter.js'; -import { createParagraphAdapter, createHeadingAdapter } from './create-adapter.js'; + trackChangesListWrapper, + trackChangesGetWrapper, + trackChangesAcceptWrapper, + trackChangesRejectWrapper, + trackChangesAcceptAllWrapper, + trackChangesRejectAllWrapper, +} from './plan-engine/track-changes-wrappers.js'; +import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; import { - listsListAdapter, - listsGetAdapter, - listsInsertAdapter, - listsSetTypeAdapter, - listsIndentAdapter, - listsOutdentAdapter, - listsRestartAdapter, - listsExitAdapter, -} from './lists-adapter.js'; + listsListWrapper, + listsGetWrapper, + listsInsertWrapper, + listsSetTypeWrapper, + listsIndentWrapper, + listsOutdentWrapper, + listsRestartWrapper, + listsExitWrapper, +} from './plan-engine/lists-wrappers.js'; +import { executePlan } from './plan-engine/executor.js'; +import { previewPlan } from './plan-engine/preview.js'; +import { queryMatchAdapter } from './plan-engine/query-match-adapter.js'; +import { initRevision, trackRevisions } from './plan-engine/revision-tracker.js'; +import { registerBuiltInExecutors } from './plan-engine/register-executors.js'; /** * Assembles all document-api adapters for the given editor instance. @@ -40,6 +45,10 @@ import { * @returns A {@link DocumentApiAdapters} object ready to pass to `createDocumentApi()`. */ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters { + registerBuiltInExecutors(); + initRevision(editor); + trackRevisions(editor); + return { find: { find: (query) => findAdapter(editor, query), @@ -57,37 +66,44 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters capabilities: { get: () => getDocumentApiCapabilities(editor), }, - comments: createCommentsAdapter(editor), + comments: createCommentsWrapper(editor), write: { - write: (request, options) => writeAdapter(editor, request, options), + write: (request, options) => writeWrapper(editor, request, options), }, format: { - bold: (input, options) => formatBoldAdapter(editor, input, options), - italic: (input, options) => formatItalicAdapter(editor, input, options), - underline: (input, options) => formatUnderlineAdapter(editor, input, options), - strikethrough: (input, options) => formatStrikethroughAdapter(editor, input, options), + bold: (input, options) => formatBoldWrapper(editor, input, options), + italic: (input, options) => formatItalicWrapper(editor, input, options), + underline: (input, options) => formatUnderlineWrapper(editor, input, options), + strikethrough: (input, options) => formatStrikethroughWrapper(editor, input, options), }, trackChanges: { - list: (input) => trackChangesListAdapter(editor, input), - get: (input) => trackChangesGetAdapter(editor, input), - accept: (input) => trackChangesAcceptAdapter(editor, input), - reject: (input) => trackChangesRejectAdapter(editor, input), - acceptAll: (input) => trackChangesAcceptAllAdapter(editor, input), - rejectAll: (input) => trackChangesRejectAllAdapter(editor, input), + list: (input) => trackChangesListWrapper(editor, input), + get: (input) => trackChangesGetWrapper(editor, input), + accept: (input, options) => trackChangesAcceptWrapper(editor, input, options), + reject: (input, options) => trackChangesRejectWrapper(editor, input, options), + acceptAll: (input, options) => trackChangesAcceptAllWrapper(editor, input, options), + rejectAll: (input, options) => trackChangesRejectAllWrapper(editor, input, options), }, create: { - paragraph: (input, options) => createParagraphAdapter(editor, input, options), - heading: (input, options) => createHeadingAdapter(editor, input, options), + paragraph: (input, options) => createParagraphWrapper(editor, input, options), + heading: (input, options) => createHeadingWrapper(editor, input, options), }, lists: { - list: (query) => listsListAdapter(editor, query), - get: (input) => listsGetAdapter(editor, input), - insert: (input, options) => listsInsertAdapter(editor, input, options), - setType: (input, options) => listsSetTypeAdapter(editor, input, options), - indent: (input, options) => listsIndentAdapter(editor, input, options), - outdent: (input, options) => listsOutdentAdapter(editor, input, options), - restart: (input, options) => listsRestartAdapter(editor, input, options), - exit: (input, options) => listsExitAdapter(editor, input, options), + list: (query) => listsListWrapper(editor, query), + get: (input) => listsGetWrapper(editor, input), + insert: (input, options) => listsInsertWrapper(editor, input, options), + setType: (input, options) => listsSetTypeWrapper(editor, input, options), + indent: (input, options) => listsIndentWrapper(editor, input, options), + outdent: (input, options) => listsOutdentWrapper(editor, input, options), + restart: (input, options) => listsRestartWrapper(editor, input, options), + exit: (input, options) => listsExitWrapper(editor, input, options), + }, + query: { + match: (input) => queryMatchAdapter(editor, input), + }, + mutations: { + preview: (input) => previewPlan(editor, input), + apply: (input) => executePlan(editor, input), }, }; } diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index e19617f581..7eab9a878e 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -4,6 +4,7 @@ import { COMMAND_CATALOG, type CapabilityReasonCode, type DocumentApiCapabilities, + type PlanEngineCapabilities, type OperationId, OPERATION_IDS, } from '@superdoc/document-api'; @@ -156,6 +157,34 @@ function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['op return operations; } +// --------------------------------------------------------------------------- +// Plan engine capabilities +// --------------------------------------------------------------------------- + +const SUPPORTED_STEP_OPS = [ + 'text.rewrite', + 'text.insert', + 'text.delete', + 'style.apply', + 'assert', + 'create.paragraph', + 'create.heading', +] as const; +const SUPPORTED_NON_UNIFORM_STRATEGIES = ['error', 'useLeadingRun', 'majority', 'union'] as const; +const SUPPORTED_SET_MARKS = ['bold', 'italic', 'underline', 'strike'] as const; +const REGEX_MAX_PATTERN_LENGTH = 1024; + +function buildPlanEngineCapabilities(): PlanEngineCapabilities { + return { + supportedStepOps: SUPPORTED_STEP_OPS, + supportedNonUniformStrategies: SUPPORTED_NON_UNIFORM_STRATEGIES, + supportedSetMarks: SUPPORTED_SET_MARKS, + regex: { + maxPatternLength: REGEX_MAX_PATTERN_LENGTH, + }, + }; +} + /** * Builds a {@link DocumentApiCapabilities} snapshot by introspecting the editor's * registered commands and schema marks. @@ -190,5 +219,6 @@ export function getDocumentApiCapabilities(editor: Editor): DocumentApiCapabilit }, }, operations, + planEngine: buildPlanEngineCapabilities(), }; } diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts b/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts deleted file mode 100644 index 9ec502f18d..0000000000 --- a/packages/super-editor/src/document-api-adapters/comments-adapter.test.ts +++ /dev/null @@ -1,693 +0,0 @@ -import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import { Schema } from 'prosemirror-model'; -import { EditorState } from 'prosemirror-state'; -import type { Editor } from '../core/Editor.js'; -import { CommentMarkName } from '../extensions/comment/comments-constants.js'; -import { createCommentsAdapter } from './comments-adapter.js'; - -type NodeOptions = { - attrs?: Record; - text?: string; - isInline?: boolean; - isBlock?: boolean; - isLeaf?: boolean; - inlineContent?: boolean; - nodeSize?: number; -}; - -function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { - const attrs = options.attrs ?? {}; - const text = options.text ?? ''; - const isText = typeName === 'text'; - const isInline = options.isInline ?? isText; - const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); - const inlineContent = options.inlineContent ?? isBlock; - const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); - - const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); - const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; - - return { - type: { name: typeName }, - attrs, - text: isText ? text : undefined, - nodeSize, - isText, - isInline, - isBlock, - inlineContent, - isTextblock: inlineContent, - isLeaf, - childCount: children.length, - child(index: number) { - return children[index]!; - }, - descendants(callback: (node: ProseMirrorNode, pos: number) => void) { - let offset = 0; - for (const child of children) { - callback(child, offset); - offset += child.nodeSize; - } - }, - } as unknown as ProseMirrorNode; -} - -function makeEditor(docNode: ProseMirrorNode, commands: Record): Editor { - return { - state: { doc: docNode }, - commands, - } as unknown as Editor; -} - -describe('addCommentAdapter', () => { - it('adds a comment when commands and range are valid', () => { - const textNode = createNode('text', [], { text: 'Hello' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const setTextSelection = vi.fn(() => true); - const commands: Record = { setTextSelection }; - const editor = makeEditor(doc, commands); - const addComment = vi.fn(() => { - (editor as unknown as { converter?: { comments?: Array> } }).converter = { - comments: [ - { - commentId: 'new-comment-id', - commentText: 'Review this', - createdTime: Date.now(), - }, - ], - }; - return true; - }); - commands.addComment = addComment; - - const receipt = createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - text: 'Review this', - }); - - expect(setTextSelection).toHaveBeenCalledWith({ from: 1, to: 6 }); - expect(addComment).toHaveBeenCalledWith(expect.objectContaining({ content: 'Review this', isInternal: false })); - const passedCommentId = addComment.mock.calls[0]?.[0]?.commentId; - expect(typeof passedCommentId).toBe('string'); - expect(receipt.success).toBe(true); - expect(receipt.inserted?.[0]?.entityType).toBe('comment'); - expect(receipt.inserted?.[0]?.entityId).toBe(passedCommentId); - }); - - it('reads addComment from a fresh command snapshot after applying selection', () => { - const textNode = createNode('text', [], { text: 'Hello' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const editor = { - state: { doc }, - converter: { - comments: [] as Array>, - }, - options: { - documentId: 'doc-1', - user: { - name: 'Test User', - email: 'test.user@example.com', - }, - }, - } as unknown as Editor & { - converter: { - comments: Array>; - }; - }; - - let activeSelection = { from: 0, to: 0 }; - const setTextSelection = vi.fn(({ from, to }: { from: number; to: number }) => { - activeSelection = { from, to }; - return true; - }); - const addCommentWithSnapshot = vi.fn( - ( - selectionSnapshot: { from: number; to: number }, - options: { content: string; isInternal: boolean; commentId?: string }, - ) => { - if (selectionSnapshot.from === selectionSnapshot.to) return false; - - editor.converter.comments.push({ - commentId: options.commentId ?? 'fresh-command-id', - commentText: options.content, - createdTime: Date.now(), - }); - return true; - }, - ); - - Object.defineProperty(editor, 'commands', { - configurable: true, - get() { - const selectionSnapshot = { ...activeSelection }; - return { - setTextSelection, - addComment: (options: { content: string; isInternal: boolean; commentId?: string }) => - addCommentWithSnapshot(selectionSnapshot, options), - }; - }, - }); - - const receipt = createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - text: 'Review this', - }); - - expect(setTextSelection).toHaveBeenCalledWith({ from: 1, to: 6 }); - expect(addCommentWithSnapshot).toHaveBeenCalledWith( - { from: 1, to: 6 }, - expect.objectContaining({ content: 'Review this', isInternal: false }), - ); - const passedId = addCommentWithSnapshot.mock.calls[0]?.[1]?.commentId; - expect(typeof passedId).toBe('string'); - expect(receipt.success).toBe(true); - expect(receipt.inserted?.[0]).toMatchObject({ entityType: 'comment', entityId: passedId }); - }); - - it('returns false when commands are missing', () => { - const doc = createNode('doc', [], { isBlock: false }); - const editor = makeEditor(doc, {}); - - expect(() => - createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, - text: 'No commands', - }), - ).toThrow('command is not available'); - }); - - it('returns false when blockId is not found', () => { - const textNode = createNode('text', [], { text: 'Hi' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const setTextSelection = vi.fn(() => true); - const addComment = vi.fn(() => true); - const editor = makeEditor(doc, { setTextSelection, addComment }); - - expect(() => - createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, - text: 'Missing', - }), - ).toThrow('Comment target could not be resolved.'); - }); - - it('returns false for empty ranges', () => { - const textNode = createNode('text', [], { text: 'Hi' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const setTextSelection = vi.fn(() => true); - const addComment = vi.fn(() => true); - const editor = makeEditor(doc, { setTextSelection, addComment }); - - const receipt = createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 1 } }, - text: 'Empty', - }); - - expect(receipt.success).toBe(false); - expect(receipt.failure).toMatchObject({ - code: 'INVALID_TARGET', - }); - }); - - it('returns false for out-of-range offsets', () => { - const textNode = createNode('text', [], { text: 'Hi' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const setTextSelection = vi.fn(() => true); - const addComment = vi.fn(() => true); - const editor = makeEditor(doc, { setTextSelection, addComment }); - - expect(() => - createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - text: 'Out of range', - }), - ).toThrow('Comment target could not be resolved.'); - }); - - it('returns INVALID_TARGET when text selection cannot be applied', () => { - const textNode = createNode('text', [], { text: 'Hi' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const setTextSelection = vi.fn(() => false); - const addComment = vi.fn(() => true); - const editor = makeEditor(doc, { setTextSelection, addComment }); - - const receipt = createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, - text: 'Selection failure', - }); - - expect(receipt.success).toBe(false); - expect(receipt.failure).toMatchObject({ - code: 'INVALID_TARGET', - }); - }); - - it('returns NO_OP when addComment does not apply a comment', () => { - const textNode = createNode('text', [], { text: 'Hi' }); - const paragraph = createNode('paragraph', [textNode], { - attrs: { sdBlockId: 'p1' }, - isBlock: true, - inlineContent: true, - }); - const doc = createNode('doc', [paragraph], { isBlock: false }); - - const setTextSelection = vi.fn(() => true); - const addComment = vi.fn(() => false); - const editor = makeEditor(doc, { setTextSelection, addComment }); - - const receipt = createCommentsAdapter(editor).add({ - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, - text: 'Insert failure', - }); - - expect(receipt.success).toBe(false); - expect(receipt.failure).toMatchObject({ - code: 'NO_OP', - }); - }); -}); - -function createCommentSchema(): Schema { - return new Schema({ - nodes: { - doc: { content: 'block+' }, - paragraph: { - attrs: { paraId: { default: null }, sdBlockId: { default: null } }, - content: 'inline*', - group: 'block', - toDOM: () => ['p', 0], - parseDOM: [{ tag: 'p' }], - }, - text: { group: 'inline' }, - commentRangeStart: { - inline: true, - group: 'inline', - atom: true, - attrs: { 'w:id': {} }, - toDOM: () => ['commentRangeStart'], - parseDOM: [{ tag: 'commentRangeStart' }], - }, - commentRangeEnd: { - inline: true, - group: 'inline', - atom: true, - attrs: { 'w:id': {} }, - toDOM: () => ['commentRangeEnd'], - parseDOM: [{ tag: 'commentRangeEnd' }], - }, - }, - marks: { - [CommentMarkName]: { - attrs: { commentId: {}, importedId: { default: null }, internal: { default: false } }, - inclusive: false, - toDOM: () => [CommentMarkName], - parseDOM: [{ tag: CommentMarkName }], - }, - }, - }); -} - -function createPmEditor( - doc: ProseMirrorNode, - commands: Record = {}, - comments: Array> = [], -): Editor { - const state = EditorState.create({ - schema: doc.type.schema, - doc, - }); - - return { - state, - commands, - converter: { - comments, - }, - options: { - documentId: 'doc-1', - user: { - name: 'Test User', - email: 'test.user@example.com', - }, - }, - } as unknown as Editor; -} - -describe('commentsAdapter additional operations', () => { - it('edits a comment text and returns updated receipt', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const editComment = vi.fn(() => true); - const editor = createPmEditor(doc, { editComment }, [{ commentId: 'c1', commentText: 'Before' }]); - - const receipt = createCommentsAdapter(editor).edit({ commentId: 'c1', text: 'After' }); - - expect(editComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined, content: 'After' }); - expect(receipt.success).toBe(true); - expect(receipt.updated?.[0]).toMatchObject({ entityType: 'comment', entityId: 'c1' }); - expect( - (editor as unknown as { converter: { comments: Array<{ commentText?: string }> } }).converter.comments[0] - ?.commentText, - ).toBe('After'); - }); - - it('replies to a comment and returns inserted receipt', () => { - const schema = createCommentSchema(); - const parentMark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [parentMark])]); - const doc = schema.node('doc', null, [paragraph]); - const addCommentReply = vi.fn(() => true); - const editor = createPmEditor(doc, { addCommentReply }, [{ commentId: 'c1', commentText: 'Root comment' }]); - - const receipt = createCommentsAdapter(editor).reply({ parentCommentId: 'c1', text: 'Reply body' }); - - expect(addCommentReply).toHaveBeenCalledWith( - expect.objectContaining({ - parentId: 'c1', - content: 'Reply body', - }), - ); - expect(receipt.success).toBe(true); - expect(receipt.inserted?.[0]).toMatchObject({ entityType: 'comment' }); - }); - - it('throws TARGET_NOT_FOUND when replying to a missing parent comment', () => { - const schema = createCommentSchema(); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello')]); - const doc = schema.node('doc', null, [paragraph]); - const addCommentReply = vi.fn(() => true); - const editor = createPmEditor(doc, { addCommentReply }, []); - - expect(() => - createCommentsAdapter(editor).reply({ - parentCommentId: 'missing-parent', - text: 'Reply body', - }), - ).toThrow('Comment target could not be resolved.'); - expect(addCommentReply).not.toHaveBeenCalled(); - }); - - it('moves a comment to a new target range', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const moveComment = vi.fn(() => true); - const editor = createPmEditor(doc, { moveComment }, [{ commentId: 'c1', commentText: 'Move me' }]); - - const receipt = createCommentsAdapter(editor).move({ - commentId: 'c1', - target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, - }); - - expect(moveComment).toHaveBeenCalledWith({ commentId: 'c1', from: 2, to: 5 }); - expect(receipt.success).toBe(true); - expect(receipt.updated?.[0]).toMatchObject({ entityType: 'comment', entityId: 'c1' }); - }); - - it('returns NO_OP when move command resolves but does not apply changes', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const moveComment = vi.fn(() => false); - const editor = createPmEditor(doc, { moveComment }, [{ commentId: 'c1', commentText: 'Move me' }]); - - const receipt = createCommentsAdapter(editor).move({ - commentId: 'c1', - target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, - }); - - expect(moveComment).toHaveBeenCalledWith({ commentId: 'c1', from: 2, to: 5 }); - expect(receipt.success).toBe(false); - expect(receipt.failure).toMatchObject({ code: 'NO_OP' }); - }); - - it('resolves and removes comments, including replies', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const resolveComment = vi.fn(() => true); - const removeComment = vi.fn(() => true); - const editor = createPmEditor(doc, { resolveComment, removeComment }, [ - { commentId: 'c1', commentText: 'Root', isDone: false }, - { commentId: 'c2', parentCommentId: 'c1', commentText: 'Child' }, - ]); - - const api = createCommentsAdapter(editor); - const resolveReceipt = api.resolve({ commentId: 'c1' }); - const removeReceipt = api.remove({ commentId: 'c1' }); - - expect(resolveComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); - expect(resolveReceipt.success).toBe(true); - expect(resolveReceipt.updated?.[0]).toMatchObject({ entityId: 'c1' }); - - expect(removeComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); - expect(removeReceipt.success).toBe(true); - const removedIds = (removeReceipt.removed ?? []).map((entry) => entry.entityId).sort(); - expect(removedIds).toEqual(['c1', 'c2']); - }); - - it('returns NO_OP when resolve command resolves but does not apply changes', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const resolveComment = vi.fn(() => false); - const editor = createPmEditor(doc, { resolveComment }, [{ commentId: 'c1', commentText: 'Root', isDone: false }]); - - const receipt = createCommentsAdapter(editor).resolve({ commentId: 'c1' }); - - expect(resolveComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); - expect(receipt.success).toBe(false); - expect(receipt.failure).toMatchObject({ code: 'NO_OP' }); - }); - - it('returns NO_OP when remove command does not apply and no records are removed', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const removeComment = vi.fn(() => false); - const editor = createPmEditor(doc, { removeComment }, []); - - const receipt = createCommentsAdapter(editor).remove({ commentId: 'c1' }); - - expect(removeComment).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined }); - expect(receipt.success).toBe(false); - expect(receipt.failure).toMatchObject({ code: 'NO_OP' }); - }); - - it('removes anchorless reply records even when remove command is not applied', () => { - const schema = createCommentSchema(); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello')]); - const doc = schema.node('doc', null, [paragraph]); - const removeComment = vi.fn(() => false); - const editor = createPmEditor(doc, { removeComment }, [ - { commentId: 'reply-1', parentCommentId: 'c1', commentText: 'Reply' }, - ]); - - const receipt = createCommentsAdapter(editor).remove({ commentId: 'reply-1' }); - - expect(removeComment).toHaveBeenCalledWith({ commentId: 'reply-1', importedId: undefined }); - expect(receipt.success).toBe(true); - expect((receipt.removed ?? []).map((entry) => entry.entityId)).toEqual(['reply-1']); - }); - - it('updates internal metadata for anchorless comments via entity store mutation', () => { - const schema = createCommentSchema(); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello')]); - const doc = schema.node('doc', null, [paragraph]); - const setCommentInternal = vi.fn(() => false); - const editor = createPmEditor(doc, { setCommentInternal }, [ - { commentId: 'c1', commentText: 'Root', isInternal: false }, - ]); - - const receipt = createCommentsAdapter(editor).setInternal({ commentId: 'c1', isInternal: true }); - - expect(setCommentInternal).not.toHaveBeenCalled(); - expect(receipt.success).toBe(true); - const updated = ( - editor as unknown as { converter: { comments: Array<{ commentId: string; isInternal?: boolean }> } } - ).converter.comments.find((comment) => comment.commentId === 'c1'); - expect(updated?.isInternal).toBe(true); - }); - - it('sets internal, active, and cursor target comment operations', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: false }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const setCommentInternal = vi.fn(() => true); - const setActiveComment = vi.fn(() => true); - const setCursorById = vi.fn(() => true); - const editor = createPmEditor( - doc, - { - setCommentInternal, - setActiveComment, - setCursorById, - }, - [{ commentId: 'c1', commentText: 'Root', isInternal: false }], - ); - - const api = createCommentsAdapter(editor); - - const internalReceipt = api.setInternal({ commentId: 'c1', isInternal: true }); - const activeReceipt = api.setActive({ commentId: 'c1' }); - const clearActiveReceipt = api.setActive({ commentId: null }); - const goToReceipt = api.goTo({ commentId: 'c1' }); - - expect(setCommentInternal).toHaveBeenCalledWith({ commentId: 'c1', importedId: undefined, isInternal: true }); - expect(internalReceipt.success).toBe(true); - expect(activeReceipt.success).toBe(true); - expect(clearActiveReceipt.success).toBe(true); - expect(goToReceipt.success).toBe(true); - expect(setActiveComment).toHaveBeenNthCalledWith(1, { commentId: 'c1' }); - expect(setActiveComment).toHaveBeenNthCalledWith(2, { commentId: null }); - expect(setCursorById).toHaveBeenCalledWith('c1'); - }); - - it('gets and lists comments across open and resolved anchors', () => { - const schema = createCommentSchema(); - const openMark = schema.marks[CommentMarkName].create({ commentId: 'c1', internal: true }); - const openParagraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Open comment', [openMark])]); - const resolvedParagraph = schema.node('paragraph', { paraId: 'p2' }, [ - schema.nodes.commentRangeStart.create({ 'w:id': 'c2' }), - schema.text('Resolved comment'), - schema.nodes.commentRangeEnd.create({ 'w:id': 'c2' }), - ]); - const doc = schema.node('doc', null, [openParagraph, resolvedParagraph]); - - const editor = createPmEditor(doc, {}, [ - { commentId: 'c1', commentText: 'Open body', isDone: false, isInternal: true }, - { commentId: 'c2', commentText: 'Resolved body', isDone: true }, - ]); - const api = createCommentsAdapter(editor); - - const open = api.get({ commentId: 'c1' }); - const resolved = api.get({ commentId: 'c2' }); - const openOnly = api.list({ includeResolved: false }); - const all = api.list(); - - expect(open.status).toBe('open'); - expect(open.commentId).toBe('c1'); - expect(resolved.status).toBe('resolved'); - expect(resolved.commentId).toBe('c2'); - expect(openOnly.matches.map((comment) => comment.commentId)).toEqual(['c1']); - expect(all.total).toBeGreaterThanOrEqual(2); - }); -}); - -describe('invariant: imported comment ID normalization', () => { - // These tests verify that comments with both a canonical commentId and an - // importedId (the w:id from DOCX) are treated as a single identity throughout - // the adapter. The import pipeline (prepareCommentsForImport) guarantees this - // today; these tests guard against regressions if a new code path creates - // marks or store entries with inconsistent IDs. - - it('invariant: list() returns one record when mark carries both commentId and importedId', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ - commentId: 'canonical-uuid', - importedId: 'imported-5', - internal: false, - }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - - const editor = createPmEditor(doc, {}, [ - { commentId: 'canonical-uuid', importedId: 'imported-5', commentText: 'Body' }, - ]); - const api = createCommentsAdapter(editor); - const result = api.list(); - - const matchingRecords = result.matches.filter( - (c) => c.commentId === 'canonical-uuid' || c.importedId === 'imported-5', - ); - expect(matchingRecords).toHaveLength(1); - expect(matchingRecords[0]!.commentId).toBe('canonical-uuid'); - }); - - it('invariant: get() by importedId returns the canonical record', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ - commentId: 'canonical-uuid', - importedId: 'imported-5', - internal: false, - }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - - const editor = createPmEditor(doc, {}, [ - { commentId: 'canonical-uuid', importedId: 'imported-5', commentText: 'Body' }, - ]); - const api = createCommentsAdapter(editor); - const info = api.get({ commentId: 'imported-5' }); - - expect(info.commentId).toBe('canonical-uuid'); - expect(info.target).toBeTruthy(); - }); - - it('invariant: move() passes canonical commentId to moveComment command for imported comments', () => { - const schema = createCommentSchema(); - const mark = schema.marks[CommentMarkName].create({ - commentId: 'canonical-uuid', - importedId: 'imported-5', - internal: false, - }); - const paragraph = schema.node('paragraph', { paraId: 'p1' }, [schema.text('Hello', [mark])]); - const doc = schema.node('doc', null, [paragraph]); - const moveComment = vi.fn(() => true); - const editor = createPmEditor(doc, { moveComment }, [ - { commentId: 'canonical-uuid', importedId: 'imported-5', commentText: 'Move me' }, - ]); - - const receipt = createCommentsAdapter(editor).move({ - commentId: 'imported-5', - target: { kind: 'text', blockId: 'p1', range: { start: 1, end: 4 } }, - }); - - expect(receipt.success).toBe(true); - expect(moveComment).toHaveBeenCalledWith(expect.objectContaining({ commentId: 'canonical-uuid' })); - }); -}); 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 deleted file mode 100644 index a1f2f28f48..0000000000 --- a/packages/super-editor/src/document-api-adapters/create-adapter.test.ts +++ /dev/null @@ -1,926 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import type { Editor } from '../core/Editor.js'; -import { createParagraphAdapter, createHeadingAdapter } from './create-adapter.js'; -import * as trackedChangeResolver from './helpers/tracked-change-resolver.js'; - -type MockNode = ProseMirrorNode & { - _children?: MockNode[]; - marks?: Array<{ type: { name: string }; attrs?: Record }>; -}; - -function createTextNode(text: string, marks: MockNode['marks'] = []): MockNode { - return { - type: { name: 'text' }, - text, - marks, - nodeSize: text.length, - isText: true, - isInline: true, - isBlock: false, - isLeaf: false, - inlineContent: false, - isTextblock: false, - childCount: 0, - child() { - throw new Error('text node has no children'); - }, - descendants() { - return undefined; - }, - } as unknown as MockNode; -} - -function createParagraphNode( - id: string, - text = '', - tracked = false, - extraAttrs: Record = {}, -): MockNode { - const marks = - tracked && text.length > 0 - ? [ - { - type: { name: 'trackInsert' }, - attrs: { id: `tc-${id}` }, - }, - ] - : []; - const children = text.length > 0 ? [createTextNode(text, marks)] : []; - const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); - - return { - type: { name: 'paragraph' }, - attrs: { sdBlockId: id, ...extraAttrs }, - _children: children, - nodeSize: contentSize + 2, - isText: false, - isInline: false, - isBlock: true, - isLeaf: false, - inlineContent: true, - isTextblock: true, - childCount: children.length, - child(index: number) { - return children[index] as unknown as ProseMirrorNode; - }, - descendants(callback: (node: ProseMirrorNode, pos: number) => void) { - let offset = 1; - for (const child of children) { - callback(child as unknown as ProseMirrorNode, offset); - offset += child.nodeSize; - } - return undefined; - }, - } as unknown as MockNode; -} - -function createDocNode(children: MockNode[]): MockNode { - const node = { - type: { name: 'doc' }, - _children: children, - isText: false, - isInline: false, - isBlock: false, - isLeaf: false, - inlineContent: false, - isTextblock: false, - childCount: children.length, - child(index: number) { - return children[index] as unknown as ProseMirrorNode; - }, - get nodeSize() { - return this.content.size + 2; - }, - get content() { - return { - size: children.reduce((sum, child) => sum + child.nodeSize, 0), - }; - }, - descendants(callback: (node: ProseMirrorNode, pos: number) => void) { - let pos = 0; - for (const child of children) { - callback(child as unknown as ProseMirrorNode, pos); - let offset = 1; - for (const grandChild of child._children ?? []) { - callback(grandChild as unknown as ProseMirrorNode, pos + offset); - offset += grandChild.nodeSize; - } - pos += child.nodeSize; - } - return undefined; - }, - nodesBetween(this: MockNode, from: number, to: number, callback: (node: ProseMirrorNode) => void) { - const size = this.content.size; - if (!Number.isFinite(size)) { - throw new Error('nodesBetween called without document context'); - } - let pos = 0; - for (const child of children) { - const childStart = pos; - const childEnd = pos + child.nodeSize; - if (childEnd < from || childStart > to) { - pos += child.nodeSize; - continue; - } - - callback(child as unknown as ProseMirrorNode); - for (const grandChild of child._children ?? []) { - callback(grandChild as unknown as ProseMirrorNode); - } - pos += child.nodeSize; - } - }, - } as unknown as MockNode; - - return node; -} - -function insertChildAtPos(doc: MockNode, child: MockNode, pos: number): boolean { - const children = doc._children ?? []; - let cursor = 0; - - for (let index = 0; index <= children.length; index += 1) { - if (cursor === pos) { - children.splice(index, 0, child); - doc.childCount = children.length; - return true; - } - - if (index < children.length) { - cursor += children[index]!.nodeSize; - } - } - - return false; -} - -function makeEditor({ - withTrackedCommand = true, - insertReturns = true, - insertedParagraphAttrs, - user, -}: { - withTrackedCommand?: boolean; - insertReturns?: boolean; - insertedParagraphAttrs?: Record; - user?: { name: string }; -} = {}): { - editor: Editor; - insertParagraphAt: ReturnType; -} { - const doc = createDocNode([createParagraphNode('p1', 'Hello')]); - - const insertParagraphAt = vi.fn( - (options: { pos: number; text?: string; sdBlockId?: string; paraId?: string; tracked?: boolean }) => { - if (!insertReturns) return false; - const nodeId = options.sdBlockId ?? 'new-paragraph'; - // Merge: command-supplied paraId overrides fixture attrs (matching real behavior). - const mergedAttrs = { ...insertedParagraphAttrs, ...(options.paraId ? { paraId: options.paraId } : {}) }; - const paragraph = createParagraphNode(nodeId, options.text ?? '', options.tracked === true, mergedAttrs); - return insertChildAtPos(doc, paragraph, options.pos); - }, - ); - - const editor = { - state: { - doc, - }, - commands: { - insertParagraphAt, - insertTrackedChange: withTrackedCommand ? vi.fn(() => true) : undefined, - }, - can: () => ({ - insertParagraphAt: () => insertReturns, - }), - options: { user }, - } as unknown as Editor; - - return { editor, insertParagraphAt }; -} - -describe('createParagraphAdapter', () => { - it('creates a paragraph at the document end by default', () => { - const { editor, insertParagraphAt } = makeEditor(); - - const result = createParagraphAdapter(editor, { text: 'New paragraph' }, { changeMode: 'direct' }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.paragraph.kind).toBe('block'); - expect(result.paragraph.nodeType).toBe('paragraph'); - expect(result.insertionPoint.kind).toBe('text'); - expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); - } - - expect(insertParagraphAt).toHaveBeenCalledTimes(1); - expect(insertParagraphAt.mock.calls[0]?.[0]).toMatchObject({ - text: 'New paragraph', - tracked: false, - }); - }); - - it('creates a paragraph before a target block', () => { - const { editor, insertParagraphAt } = makeEditor(); - - const result = createParagraphAdapter( - editor, - { - at: { - kind: 'before', - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, - }, - }, - { changeMode: 'direct' }, - ); - - expect(result.success).toBe(true); - expect(insertParagraphAt.mock.calls[0]?.[0]?.pos).toBe(0); - }); - - it('throws TARGET_NOT_FOUND when a before/after target cannot be resolved', () => { - const { editor } = makeEditor(); - - expect(() => - createParagraphAdapter( - editor, - { - at: { - kind: 'after', - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' }, - }, - }, - { changeMode: 'direct' }, - ), - ).toThrow('target block was not found'); - }); - - it('throws CAPABILITY_UNAVAILABLE when tracked create is requested without tracked capability', () => { - const { editor } = makeEditor({ withTrackedCommand: false }); - - expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( - 'requires the insertTrackedChange command', - ); - }); - - it('creates tracked paragraphs without losing nodesBetween context', () => { - const resolverSpy = vi.spyOn(trackedChangeResolver, 'buildTrackedChangeCanonicalIdMap').mockReturnValue(new Map()); - - const { editor } = makeEditor({ user: { name: 'Test' } }); - - const result = createParagraphAdapter(editor, { text: 'Tracked paragraph' }, { changeMode: 'tracked' }); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.trackedChangeRefs?.length).toBeGreaterThan(0); - expect(result.trackedChangeRefs?.[0]).toMatchObject({ - kind: 'entity', - entityType: 'trackedChange', - }); - expect(resolverSpy).toHaveBeenCalledTimes(1); - resolverSpy.mockRestore(); - }); - - it('returns INVALID_TARGET failure when command cannot apply the insertion', () => { - const { editor } = makeEditor({ insertReturns: false }); - - const result = createParagraphAdapter(editor, { text: 'No-op' }, { changeMode: 'direct' }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.failure.code).toBe('INVALID_TARGET'); - } - }); - - it('dry-run returns placeholder success without mutating the document', () => { - const { editor, insertParagraphAt } = makeEditor(); - - const result = createParagraphAdapter(editor, { text: 'Dry run text' }, { changeMode: 'direct', dryRun: true }); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.paragraph).toEqual({ kind: 'block', nodeType: 'paragraph', nodeId: '(dry-run)' }); - expect(result.insertionPoint).toEqual({ kind: 'text', blockId: '(dry-run)', range: { start: 0, end: 0 } }); - expect(insertParagraphAt).not.toHaveBeenCalled(); - }); - - it('dry-run returns INVALID_TARGET when insertion cannot be applied', () => { - const { editor } = makeEditor({ insertReturns: false }); - - const result = createParagraphAdapter(editor, { text: 'Dry run text' }, { changeMode: 'direct', dryRun: true }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('INVALID_TARGET'); - }); - - it('dry-run still throws TARGET_NOT_FOUND when target block does not exist', () => { - const { editor } = makeEditor(); - - expect(() => - createParagraphAdapter( - editor, - { - at: { - kind: 'before', - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' }, - }, - }, - { changeMode: 'direct', dryRun: true }, - ), - ).toThrow('target block was not found'); - }); - - it('dry-run still throws CAPABILITY_UNAVAILABLE when tracked capability is missing', () => { - const { editor } = makeEditor({ withTrackedCommand: false }); - - expect(() => - createParagraphAdapter(editor, { text: 'Tracked dry run' }, { changeMode: 'tracked', dryRun: true }), - ).toThrow('requires the insertTrackedChange command'); - }); - - it('returns a stable paraId that survives DOCX round-trips, not the ephemeral sdBlockId', () => { - const { editor } = makeEditor(); - - const result = createParagraphAdapter(editor, { text: 'Inserted paragraph' }, { changeMode: 'direct' }); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.paragraph.nodeType).toBe('paragraph'); - // The returned nodeId is an 8-char hex paraId, not a UUID sdBlockId. - expect(result.paragraph.nodeId).toMatch(/^[0-9A-F]{8}$/); - expect(result.insertionPoint.blockId).toBe(result.paragraph.nodeId); - }); - - it('returns success with generated ID when post-apply paragraph resolution fails', () => { - const { editor } = makeEditor({ - insertedParagraphAttrs: { - sdBlockId: undefined, - }, - }); - - const result = createParagraphAdapter(editor, { text: 'Inserted paragraph' }, { changeMode: 'direct' }); - - // Contract: success:false means no mutation was applied. - // The mutation DID apply, so we must return success with the generated ID. - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.paragraph.nodeType).toBe('paragraph'); - expect(typeof result.paragraph.nodeId).toBe('string'); - expect(result.paragraph.nodeId).not.toBe('(dry-run)'); - }); - - it('throws CAPABILITY_UNAVAILABLE for tracked dry-run without a configured user', () => { - const { editor } = makeEditor(); - - expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked', dryRun: true })).toThrow( - 'requires a user to be configured', - ); - }); - - it('throws same error for tracked non-dry-run without a configured user', () => { - const { editor } = makeEditor(); - - expect(() => createParagraphAdapter(editor, { text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( - '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); - }); -}); - -// --------------------------------------------------------------------------- -// createHeadingAdapter -// --------------------------------------------------------------------------- - -function createHeadingNode( - id: string, - level: number, - text = '', - tracked = false, - extraAttrs: Record = {}, -): MockNode { - const marks = - tracked && text.length > 0 - ? [ - { - type: { name: 'trackInsert' }, - attrs: { id: `tc-${id}` }, - }, - ] - : []; - const children = text.length > 0 ? [createTextNode(text, marks)] : []; - const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); - - return { - type: { name: 'paragraph' }, - attrs: { - sdBlockId: id, - paragraphProperties: { styleId: `Heading${level}` }, - ...extraAttrs, - }, - _children: children, - nodeSize: contentSize + 2, - isText: false, - isInline: false, - isBlock: true, - isLeaf: false, - inlineContent: true, - isTextblock: true, - childCount: children.length, - child(index: number) { - return children[index] as unknown as ProseMirrorNode; - }, - descendants(callback: (node: ProseMirrorNode, pos: number) => void) { - let offset = 1; - for (const child of children) { - callback(child as unknown as ProseMirrorNode, offset); - offset += child.nodeSize; - } - return undefined; - }, - } as unknown as MockNode; -} - -function makeHeadingEditor({ - withTrackedCommand = true, - insertReturns = true, - insertedHeadingAttrs, - user, -}: { - withTrackedCommand?: boolean; - insertReturns?: boolean; - insertedHeadingAttrs?: Record; - user?: { name: string }; -} = {}): { - editor: Editor; - insertHeadingAt: ReturnType; -} { - const doc = createDocNode([createParagraphNode('p1', 'Hello')]); - - const insertHeadingAt = vi.fn( - (options: { - pos: number; - level: number; - text?: string; - sdBlockId?: string; - paraId?: string; - tracked?: boolean; - }) => { - if (!insertReturns) return false; - const nodeId = options.sdBlockId ?? 'new-heading'; - const mergedAttrs = { ...insertedHeadingAttrs, ...(options.paraId ? { paraId: options.paraId } : {}) }; - const heading = createHeadingNode( - nodeId, - options.level, - options.text ?? '', - options.tracked === true, - mergedAttrs, - ); - return insertChildAtPos(doc, heading, options.pos); - }, - ); - - const editor = { - state: { - doc, - }, - commands: { - insertHeadingAt, - insertTrackedChange: withTrackedCommand ? vi.fn(() => true) : undefined, - }, - can: () => ({ - insertHeadingAt: (opts: { pos: number; level: number }) => { - if (!insertReturns) return false; - return opts.level >= 1 && opts.level <= 6; - }, - }), - options: { user }, - } as unknown as Editor; - - return { editor, insertHeadingAt }; -} - -describe('createHeadingAdapter', () => { - it('creates a heading at the document end by default', () => { - const { editor, insertHeadingAt } = makeHeadingEditor(); - - const result = createHeadingAdapter(editor, { level: 2, text: 'New heading' }, { changeMode: 'direct' }); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.heading.kind).toBe('block'); - expect(result.heading.nodeType).toBe('heading'); - expect(result.insertionPoint.kind).toBe('text'); - expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); - } - - expect(insertHeadingAt).toHaveBeenCalledTimes(1); - expect(insertHeadingAt.mock.calls[0]?.[0]).toMatchObject({ - level: 2, - text: 'New heading', - tracked: false, - }); - }); - - it('creates a heading before a target block', () => { - const { editor, insertHeadingAt } = makeHeadingEditor(); - - const result = createHeadingAdapter( - editor, - { - level: 1, - at: { - kind: 'before', - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, - }, - }, - { changeMode: 'direct' }, - ); - - expect(result.success).toBe(true); - expect(insertHeadingAt.mock.calls[0]?.[0]?.pos).toBe(0); - }); - - it('throws TARGET_NOT_FOUND when a before/after target cannot be resolved', () => { - const { editor } = makeHeadingEditor(); - - expect(() => - createHeadingAdapter( - editor, - { - level: 1, - at: { - kind: 'after', - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' }, - }, - }, - { changeMode: 'direct' }, - ), - ).toThrow('target block was not found'); - }); - - it('throws CAPABILITY_UNAVAILABLE when tracked create is requested without tracked capability', () => { - const { editor } = makeHeadingEditor({ withTrackedCommand: false }); - - expect(() => createHeadingAdapter(editor, { level: 1, text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( - 'requires the insertTrackedChange command', - ); - }); - - it('creates tracked headings and returns trackedChangeRefs', () => { - const resolverSpy = vi.spyOn(trackedChangeResolver, 'buildTrackedChangeCanonicalIdMap').mockReturnValue(new Map()); - - const { editor } = makeHeadingEditor({ user: { name: 'Test' } }); - - const result = createHeadingAdapter(editor, { level: 1, text: 'Tracked heading' }, { changeMode: 'tracked' }); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.trackedChangeRefs?.length).toBeGreaterThan(0); - expect(result.trackedChangeRefs?.[0]).toMatchObject({ - kind: 'entity', - entityType: 'trackedChange', - }); - expect(resolverSpy).toHaveBeenCalledTimes(1); - resolverSpy.mockRestore(); - }); - - it('returns INVALID_TARGET failure when command cannot apply the insertion', () => { - const { editor } = makeHeadingEditor({ insertReturns: false }); - - const result = createHeadingAdapter(editor, { level: 1, text: 'No-op' }, { changeMode: 'direct' }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.failure.code).toBe('INVALID_TARGET'); - } - }); - - it('dry-run returns placeholder success without mutating the document', () => { - const { editor, insertHeadingAt } = makeHeadingEditor(); - - const result = createHeadingAdapter( - editor, - { level: 1, text: 'Dry run text' }, - { changeMode: 'direct', dryRun: true }, - ); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.heading).toEqual({ kind: 'block', nodeType: 'heading', nodeId: '(dry-run)' }); - expect(result.insertionPoint).toEqual({ kind: 'text', blockId: '(dry-run)', range: { start: 0, end: 0 } }); - expect(insertHeadingAt).not.toHaveBeenCalled(); - }); - - it('dry-run returns INVALID_TARGET when insertion cannot be applied', () => { - const { editor } = makeHeadingEditor({ insertReturns: false }); - - const result = createHeadingAdapter( - editor, - { level: 1, text: 'Dry run text' }, - { changeMode: 'direct', dryRun: true }, - ); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('INVALID_TARGET'); - }); - - it('dry-run still throws TARGET_NOT_FOUND when target block does not exist', () => { - const { editor } = makeHeadingEditor(); - - expect(() => - createHeadingAdapter( - editor, - { - level: 1, - at: { - kind: 'before', - target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' }, - }, - }, - { changeMode: 'direct', dryRun: true }, - ), - ).toThrow('target block was not found'); - }); - - it('dry-run still throws CAPABILITY_UNAVAILABLE when tracked capability is missing', () => { - const { editor } = makeHeadingEditor({ withTrackedCommand: false }); - - expect(() => - createHeadingAdapter(editor, { level: 1, text: 'Tracked dry run' }, { changeMode: 'tracked', dryRun: true }), - ).toThrow('requires the insertTrackedChange command'); - }); - - it('returns a stable paraId that survives DOCX round-trips for headings', () => { - const { editor } = makeHeadingEditor(); - - const result = createHeadingAdapter(editor, { level: 1, text: 'Inserted heading' }, { changeMode: 'direct' }); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.heading.nodeType).toBe('heading'); - expect(result.heading.nodeId).toMatch(/^[0-9A-F]{8}$/); - expect(result.insertionPoint.blockId).toBe(result.heading.nodeId); - }); - - it('returns success with generated ID when post-apply heading resolution fails', () => { - const { editor } = makeHeadingEditor({ - insertedHeadingAttrs: { - sdBlockId: undefined, - paragraphProperties: {}, - }, - }); - - const result = createHeadingAdapter(editor, { level: 1, text: 'Inserted heading' }, { changeMode: 'direct' }); - - // Contract: success:false means no mutation was applied. - // The mutation DID apply, so we must return success with the generated ID. - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.heading.nodeType).toBe('heading'); - expect(typeof result.heading.nodeId).toBe('string'); - expect(result.heading.nodeId).not.toBe('(dry-run)'); - }); - - it('throws CAPABILITY_UNAVAILABLE for tracked dry-run without a configured user', () => { - const { editor } = makeHeadingEditor(); - - expect(() => - createHeadingAdapter(editor, { level: 1, text: 'Tracked' }, { changeMode: 'tracked', dryRun: true }), - ).toThrow('requires a user to be configured'); - }); - - it('throws same error for tracked non-dry-run without a configured user', () => { - const { editor } = makeHeadingEditor(); - - expect(() => createHeadingAdapter(editor, { level: 1, text: 'Tracked' }, { changeMode: 'tracked' })).toThrow( - 'requires a user to be configured', - ); - }); - - 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(); - - createHeadingAdapter(editor, { level: 3 }, { changeMode: 'direct' }); - - expect(insertHeadingAt.mock.calls[0]?.[0]).toMatchObject({ level: 3 }); - }); -}); - -// --------------------------------------------------------------------------- -// create → getNodeById composability -// --------------------------------------------------------------------------- -// These tests verify that the paraId returned by create operations is -// immediately resolvable by getNodeById, and survives sdBlockId regeneration -// (as happens during DOCX round-trip re-open). - -import { getNodeByIdAdapter } from './get-node-adapter.js'; -import { buildBlockIndex } from './helpers/node-address-resolver.js'; -import { clearIndexCache } from './helpers/index-cache.js'; - -describe('create → getNodeById composability', () => { - it('returned paraId from create.paragraph is immediately resolvable by getNodeById', () => { - const { editor } = makeEditor(); - const createResult = createParagraphAdapter(editor, { text: 'New content' }, { changeMode: 'direct' }); - - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const returnedId = createResult.paragraph.nodeId; - expect(returnedId).toMatch(/^[0-9A-F]{8}$/); - - const result = getNodeByIdAdapter(editor, { nodeId: returnedId, nodeType: 'paragraph' }); - expect(result.nodeType).toBe('paragraph'); - }); - - it('returned paraId survives sdBlockId regeneration (simulating DOCX round-trip re-open)', () => { - const { editor } = makeEditor(); - const createResult = createParagraphAdapter(editor, { text: 'First' }, { changeMode: 'direct' }); - - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const returnedId = createResult.paragraph.nodeId; - const blockId = createResult.insertionPoint.blockId; - expect(blockId).toBe(returnedId); - - // Simulate what happens on DOCX re-open: the block-node plugin assigns a - // new random sdBlockId, but paraId (written as w14:paraId) remains stable. - const doc = editor.state.doc as unknown as MockNode; - const createdNode = doc._children?.find((child) => (child.attrs as Record)?.paraId === returnedId); - expect(createdNode).toBeDefined(); - (createdNode!.attrs as Record).sdBlockId = 'regenerated-uuid'; - - clearIndexCache(editor); - - // The returned paraId should still resolve despite sdBlockId changing. - const result = getNodeByIdAdapter(editor, { nodeId: returnedId, nodeType: 'paragraph' }); - expect(result.nodeType).toBe('paragraph'); - - // The index should also have the paraId as a lookup key. - const index = buildBlockIndex(editor); - const match = index.byId.get(`paragraph:${returnedId}`); - expect(match).toBeDefined(); - }); - - it('returned paraId from create.heading is immediately resolvable by getNodeById', () => { - const { editor } = makeHeadingEditor(); - const createResult = createHeadingAdapter(editor, { level: 2, text: 'Heading content' }, { changeMode: 'direct' }); - - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const returnedId = createResult.heading.nodeId; - expect(returnedId).toMatch(/^[0-9A-F]{8}$/); - - const result = getNodeByIdAdapter(editor, { nodeId: returnedId, nodeType: 'heading' }); - expect(result.nodeType).toBe('heading'); - }); - - it('returned paraId from create.heading survives sdBlockId regeneration', () => { - const { editor } = makeHeadingEditor(); - const createResult = createHeadingAdapter(editor, { level: 3, text: 'Heading text' }, { changeMode: 'direct' }); - - expect(createResult.success).toBe(true); - if (!createResult.success) return; - - const returnedId = createResult.heading.nodeId; - expect(createResult.insertionPoint.blockId).toBe(returnedId); - - const doc = editor.state.doc as unknown as MockNode; - const createdNode = doc._children?.find((child) => (child.attrs as Record)?.paraId === returnedId); - expect(createdNode).toBeDefined(); - (createdNode!.attrs as Record).sdBlockId = 'regenerated-heading-uuid'; - - clearIndexCache(editor); - - const result = getNodeByIdAdapter(editor, { nodeId: returnedId, nodeType: 'heading' }); - expect(result.nodeType).toBe('heading'); - - const index = buildBlockIndex(editor); - const match = index.byId.get(`heading:${returnedId}`); - expect(match).toBeDefined(); - }); -}); diff --git a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts index b3e6bdf41d..1c962b0e9e 100644 --- a/packages/super-editor/src/document-api-adapters/find/text-strategy.ts +++ b/packages/super-editor/src/document-api-adapters/find/text-strategy.ts @@ -28,7 +28,14 @@ type SearchMatch = { ranges?: Array<{ from: number; to: number }>; }; +/** Maximum allowed pattern length to guard against ReDoS and excessive memory usage. */ +const MAX_PATTERN_LENGTH = 1024; + function compileRegex(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): RegExp | null { + if (selector.pattern.length > MAX_PATTERN_LENGTH) { + addDiagnostic(diagnostics, `Text query regex pattern exceeds ${MAX_PATTERN_LENGTH} characters.`); + return null; + } const flags = selector.caseSensitive ? 'g' : 'gi'; try { return new RegExp(selector.pattern, flags); @@ -44,6 +51,10 @@ function buildSearchPattern(selector: TextSelector, diagnostics: UnknownNodeDiag if (mode === 'regex') { return compileRegex(selector, diagnostics); } + if (selector.pattern.length > MAX_PATTERN_LENGTH) { + addDiagnostic(diagnostics, `Text query pattern exceeds ${MAX_PATTERN_LENGTH} characters.`); + return null; + } // Compile as an escaped RegExp to guarantee literal matching. Passing a raw // string can be reinterpreted by the search command (e.g. slash-delimited // strings like "/foo/" are parsed as regex syntax by some implementations). 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 7a04200955..e1facb1cf1 100644 --- a/packages/super-editor/src/document-api-adapters/format-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/format-adapter.ts @@ -14,6 +14,7 @@ import { requireSchemaMark, ensureTrackedCapability } from './helpers/mutation-h import { applyDirectMutationMeta, applyTrackedMutationMeta } from './helpers/transaction-meta.js'; import { resolveTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; +import { checkRevision } from './plan-engine/revision-tracker.js'; /** Maps each format operation to the display label used in failure messages. */ const FORMAT_OPERATION_LABEL = { @@ -88,6 +89,7 @@ function formatMarkAdapter( input: FormatOperationInput, options?: MutationOptions, ): TextMutationReceipt { + checkRevision(editor, options?.expectedRevision); const normalizedInput = normalizeFormatLocator(input); const range = resolveTextTarget(editor, normalizedInput.target!); if (!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 cbac17cab1..f99197ae58 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 @@ -78,7 +78,7 @@ export function getHeadingLevel(styleId?: string | null): number | undefined { return Number(match[1]); } -function mapBlockNodeType(node: ProseMirrorNode): BlockNodeType | undefined { +export function mapBlockNodeType(node: ProseMirrorNode): BlockNodeType | undefined { if (!node.isBlock) return undefined; switch (node.type.name) { case 'paragraph': { diff --git a/packages/super-editor/src/document-api-adapters/index.ts b/packages/super-editor/src/document-api-adapters/index.ts index 81ac053cd0..803829f6c4 100644 --- a/packages/super-editor/src/document-api-adapters/index.ts +++ b/packages/super-editor/src/document-api-adapters/index.ts @@ -5,45 +5,45 @@ import type { InfoInput, NodeAddress, Query, - TrackChangesAcceptAllInput, - TrackChangesAcceptInput, - TrackChangesGetInput, - TrackChangesRejectAllInput, - TrackChangesRejectInput, } from '@superdoc/document-api'; import type { Editor } from '../core/Editor.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; -import { createCommentsAdapter } from './comments-adapter.js'; -import { createParagraphAdapter, createHeadingAdapter } from './create-adapter.js'; +import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; +import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; import { findAdapter } from './find-adapter.js'; import { - formatBoldAdapter, - formatItalicAdapter, - formatUnderlineAdapter, - formatStrikethroughAdapter, -} from './format-adapter.js'; + writeWrapper, + formatBoldWrapper, + formatItalicWrapper, + formatUnderlineWrapper, + formatStrikethroughWrapper, +} from './plan-engine/plan-wrappers.js'; import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; import { infoAdapter } from './info-adapter.js'; import { - listsExitAdapter, - listsGetAdapter, - listsIndentAdapter, - listsInsertAdapter, - listsListAdapter, - listsOutdentAdapter, - listsRestartAdapter, - listsSetTypeAdapter, -} from './lists-adapter.js'; + listsExitWrapper, + listsGetWrapper, + listsIndentWrapper, + listsInsertWrapper, + listsListWrapper, + listsOutdentWrapper, + listsRestartWrapper, + listsSetTypeWrapper, +} from './plan-engine/lists-wrappers.js'; import { - trackChangesAcceptAdapter, - trackChangesAcceptAllAdapter, - trackChangesGetAdapter, - trackChangesListAdapter, - trackChangesRejectAdapter, - trackChangesRejectAllAdapter, -} from './track-changes-adapter.js'; -import { writeAdapter } from './write-adapter.js'; + trackChangesAcceptWrapper, + trackChangesAcceptAllWrapper, + trackChangesGetWrapper, + trackChangesListWrapper, + trackChangesRejectWrapper, + trackChangesRejectAllWrapper, +} from './plan-engine/track-changes-wrappers.js'; +import { executePlan } from './plan-engine/executor.js'; +import { previewPlan } from './plan-engine/preview.js'; +import { queryMatchAdapter } from './plan-engine/query-match-adapter.js'; +import { initRevision, trackRevisions } from './plan-engine/revision-tracker.js'; +import { registerBuiltInExecutors } from './plan-engine/register-executors.js'; /** * Creates the full set of Document API adapters backed by the given editor instance. @@ -52,6 +52,11 @@ import { writeAdapter } from './write-adapter.js'; * @returns Adapter implementations for document query/mutation APIs. */ export function getDocumentApiAdapters(editor: Editor): DocumentApiAdapters { + registerBuiltInExecutors(); + // Initialize revision tracking for this editor instance + initRevision(editor); + trackRevisions(editor); + return { find: { find: (query: Query) => findAdapter(editor, query), @@ -70,37 +75,44 @@ export function getDocumentApiAdapters(editor: Editor): DocumentApiAdapters { get: () => getDocumentApiCapabilities(editor), }, // Factory pattern — comments has 11 methods; inline lambdas would be unwieldy. - comments: createCommentsAdapter(editor), + comments: createCommentsWrapper(editor), write: { - write: (request, options) => writeAdapter(editor, request, options), + write: (request, options) => writeWrapper(editor, request, options), }, format: { - bold: (input, options) => formatBoldAdapter(editor, input, options), - italic: (input, options) => formatItalicAdapter(editor, input, options), - underline: (input, options) => formatUnderlineAdapter(editor, input, options), - strikethrough: (input, options) => formatStrikethroughAdapter(editor, input, options), + bold: (input, options) => formatBoldWrapper(editor, input, options), + italic: (input, options) => formatItalicWrapper(editor, input, options), + underline: (input, options) => formatUnderlineWrapper(editor, input, options), + strikethrough: (input, options) => formatStrikethroughWrapper(editor, input, options), }, trackChanges: { - list: (query) => trackChangesListAdapter(editor, query), - get: (input: TrackChangesGetInput) => trackChangesGetAdapter(editor, input), - accept: (input: TrackChangesAcceptInput) => trackChangesAcceptAdapter(editor, input), - reject: (input: TrackChangesRejectInput) => trackChangesRejectAdapter(editor, input), - acceptAll: (input: TrackChangesAcceptAllInput) => trackChangesAcceptAllAdapter(editor, input), - rejectAll: (input: TrackChangesRejectAllInput) => trackChangesRejectAllAdapter(editor, input), + list: (query) => trackChangesListWrapper(editor, query), + get: (input) => trackChangesGetWrapper(editor, input), + accept: (input, options) => trackChangesAcceptWrapper(editor, input, options), + reject: (input, options) => trackChangesRejectWrapper(editor, input, options), + acceptAll: (input, options) => trackChangesAcceptAllWrapper(editor, input, options), + rejectAll: (input, options) => trackChangesRejectAllWrapper(editor, input, options), }, create: { - paragraph: (input, options) => createParagraphAdapter(editor, input, options), - heading: (input, options) => createHeadingAdapter(editor, input, options), + paragraph: (input, options) => createParagraphWrapper(editor, input, options), + heading: (input, options) => createHeadingWrapper(editor, input, options), }, lists: { - list: (query) => listsListAdapter(editor, query), - get: (input) => listsGetAdapter(editor, input), - insert: (input, options) => listsInsertAdapter(editor, input, options), - setType: (input, options) => listsSetTypeAdapter(editor, input, options), - indent: (input, options) => listsIndentAdapter(editor, input, options), - outdent: (input, options) => listsOutdentAdapter(editor, input, options), - restart: (input, options) => listsRestartAdapter(editor, input, options), - exit: (input, options) => listsExitAdapter(editor, input, options), + list: (query) => listsListWrapper(editor, query), + get: (input) => listsGetWrapper(editor, input), + insert: (input, options) => listsInsertWrapper(editor, input, options), + setType: (input, options) => listsSetTypeWrapper(editor, input, options), + indent: (input, options) => listsIndentWrapper(editor, input, options), + outdent: (input, options) => listsOutdentWrapper(editor, input, options), + restart: (input, options) => listsRestartWrapper(editor, input, options), + exit: (input, options) => listsExitWrapper(editor, input, options), + }, + query: { + match: (input) => queryMatchAdapter(editor, input), + }, + mutations: { + preview: (input) => previewPlan(editor, input), + apply: (input) => executePlan(editor, input), }, }; } diff --git a/packages/super-editor/src/document-api-adapters/info-adapter.ts b/packages/super-editor/src/document-api-adapters/info-adapter.ts index d11ce2bbcd..45dadd45e7 100644 --- a/packages/super-editor/src/document-api-adapters/info-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/info-adapter.ts @@ -2,6 +2,7 @@ import type { DocumentInfo, InfoInput, NodeInfo, NodeType, QueryResult } from '@ import type { Editor } from '../core/Editor.js'; import { findAdapter } from './find-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; +import { getRevision } from './plan-engine/revision-tracker.js'; type HeadingNodeInfo = Extract; type CommentNodeInfo = Extract; @@ -104,5 +105,6 @@ export function infoAdapter(editor: Editor, _input: InfoInput): DocumentInfo { canComment: true, canReplace: true, }, + revision: getRevision(editor), }; } 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 deleted file mode 100644 index caf097bcff..0000000000 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import type { Editor } from '../core/Editor.js'; -import { - listsExitAdapter, - listsIndentAdapter, - listsInsertAdapter, - listsListAdapter, - listsOutdentAdapter, - listsRestartAdapter, - listsSetTypeAdapter, -} from './lists-adapter.js'; -import { ListHelpers } from '../core/helpers/list-numbering-helpers.js'; - -type MockTextNode = { - type: { name: 'text' }; - marks?: Array<{ type: { name: string }; attrs?: Record }>; -}; - -type MockParagraphNode = { - type: { name: 'paragraph' }; - attrs: Record; - nodeSize: number; - isBlock: true; - textContent: string; - _textNode?: MockTextNode; -}; - -function makeListParagraph(options: { - id: string; - text?: string; - numId?: number; - ilvl?: number; - markerText?: string; - path?: number[]; - numberingType?: string; - sdBlockId?: string; - trackedMarkId?: string; -}): MockParagraphNode { - const text = options.text ?? ''; - const numberingProperties = - options.numId != null - ? { - numId: options.numId, - ilvl: options.ilvl ?? 0, - } - : undefined; - - return { - type: { name: 'paragraph' }, - attrs: { - paraId: options.id, - sdBlockId: options.sdBlockId ?? options.id, - paragraphProperties: numberingProperties ? { numberingProperties } : {}, - listRendering: - options.numId != null - ? { - markerText: options.markerText ?? '', - path: options.path ?? [], - numberingType: options.numberingType ?? 'decimal', - } - : null, - }, - nodeSize: Math.max(2, text.length + 2), - isBlock: true, - textContent: text, - _textNode: - options.trackedMarkId != null - ? { - type: { name: 'text' }, - marks: [{ type: { name: 'trackInsert' }, attrs: { id: options.trackedMarkId } }], - } - : undefined, - }; -} - -function makeDoc(children: MockParagraphNode[]) { - return { - get content() { - return { - size: children.reduce((sum, child) => sum + child.nodeSize, 0), - }; - }, - nodeAt(pos: number) { - let cursor = 0; - for (const child of children) { - if (cursor === pos) return child; - cursor += child.nodeSize; - } - return null; - }, - descendants(callback: (node: MockParagraphNode, pos: number) => void) { - let pos = 0; - for (const child of children) { - callback(child, pos); - pos += child.nodeSize; - } - return undefined; - }, - nodesBetween(from: number, to: number, callback: (node: unknown) => void) { - let pos = 0; - for (const child of children) { - const end = pos + child.nodeSize; - if (end < from || pos > to) { - pos = end; - continue; - } - callback(child); - if (child._textNode) callback(child._textNode); - pos = end; - } - return undefined; - }, - }; -} - -function makeEditor( - children: MockParagraphNode[], - commandOverrides: Record = {}, - editorOptions: { user?: { name: string } } = {}, -): Editor { - const doc = makeDoc(children); - const baseCommands = { - insertListItemAt: vi.fn( - (options: { - pos: number; - position: 'before' | 'after'; - sdBlockId?: string; - text?: string; - tracked?: boolean; - }) => { - const insertionId = options.sdBlockId ?? `inserted-${Date.now()}`; - let targetIndex = -1; - let cursor = 0; - for (let i = 0; i < children.length; i += 1) { - if (cursor === options.pos) { - targetIndex = i; - break; - } - cursor += children[i]!.nodeSize; - } - if (targetIndex < 0) return false; - - const target = children[targetIndex]!; - const numbering = ( - target.attrs.paragraphProperties as { numberingProperties?: { numId?: number; ilvl?: number } } - )?.numberingProperties; - if (!numbering) return false; - - const inserted = makeListParagraph({ - id: insertionId, - sdBlockId: insertionId, - text: options.text ?? '', - numId: numbering.numId, - ilvl: numbering.ilvl, - markerText: '', - path: [1], - numberingType: target.attrs?.listRendering?.numberingType as string | undefined, - trackedMarkId: options.tracked ? `tc-${insertionId}` : undefined, - }); - const at = options.position === 'before' ? targetIndex : targetIndex + 1; - children.splice(at, 0, inserted); - return true; - }, - ), - setListTypeAt: vi.fn(() => true), - setTextSelection: vi.fn(() => true), - increaseListIndent: vi.fn(() => true), - decreaseListIndent: vi.fn(() => true), - restartNumbering: vi.fn(() => true), - exitListItemAt: vi.fn(() => true), - insertTrackedChange: vi.fn(() => true), - }; - - return { - state: { - doc, - }, - commands: { - ...baseCommands, - ...commandOverrides, - }, - options: { user: editorOptions.user }, - converter: { - numbering: { definitions: {}, abstracts: {} }, - }, - } as unknown as Editor; -} - -describe('lists adapter', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('lists projected list items', () => { - const editor = makeEditor([ - makeListParagraph({ - id: 'li-1', - text: 'One', - numId: 1, - ilvl: 0, - markerText: '1.', - path: [1], - numberingType: 'decimal', - }), - makeListParagraph({ - id: 'li-2', - text: 'Two', - numId: 1, - ilvl: 0, - markerText: '2.', - path: [2], - numberingType: 'decimal', - }), - ]); - - const result = listsListAdapter(editor); - expect(result.total).toBe(2); - expect(result.matches.map((match) => match.nodeId)).toEqual(['li-1', 'li-2']); - }); - - it('inserts a list item with deterministic insertionPoint at offset 0', () => { - const editor = makeEditor([ - makeListParagraph({ - id: 'li-1', - text: 'One', - numId: 1, - ilvl: 0, - markerText: '1.', - path: [1], - numberingType: 'decimal', - }), - ]); - - const result = listsInsertAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - position: 'after', - text: 'Inserted', - }, - { changeMode: 'direct' }, - ); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.insertionPoint.range).toEqual({ start: 0, end: 0 }); - }); - - it('throws CAPABILITY_UNAVAILABLE for direct-only tracked requests', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), - ]); - - expect(() => - listsSetTypeAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - kind: 'bullet', - }, - { changeMode: 'tracked' }, - ), - ).toThrow('does not support tracked mode'); - }); - - it('returns NO_OP when setType already matches requested kind', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'bullet' }), - ]); - - const result = listsSetTypeAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - kind: 'bullet', - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('NO_OP'); - }); - - it('returns NO_OP for outdent at level 0', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), - ]); - - const result = listsOutdentAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('NO_OP'); - }); - - it('returns NO_OP for indent when list definition does not support next level', () => { - const hasDefinitionSpy = vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(false); - const editor = makeEditor([ - makeListParagraph({ - id: 'li-1', - numId: 1, - ilvl: 2, - markerText: 'iii.', - path: [1, 1, 3], - numberingType: 'lowerRoman', - }), - ]); - - const result = listsIndentAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('NO_OP'); - expect(hasDefinitionSpy).toHaveBeenCalled(); - }); - - it('returns NO_OP for restart when target is already effective start at 1 and run start', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), - ]); - - const result = listsRestartAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('NO_OP'); - }); - - it('returns NO_OP for restart when a level-1 item starts after a level-0 item with same numId', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), - makeListParagraph({ - id: 'li-2', - numId: 1, - ilvl: 1, - markerText: 'a.', - path: [1, 1], - numberingType: 'lowerLetter', - }), - ]); - const restartNumbering = editor.commands!.restartNumbering as ReturnType; - - const result = listsRestartAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('NO_OP'); - 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' }), - ]); - - expect(() => - listsExitAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'missing' }, - }), - ).toThrow('List item target was not found'); - }); - - it('maps explicit non-applied exit command to INVALID_TARGET', () => { - const editor = makeEditor( - [makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' })], - { exitListItemAt: vi.fn(() => false) }, - ); - - const result = listsExitAdapter(editor, { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.failure.code).toBe('INVALID_TARGET'); - }); - - describe('dryRun', () => { - function makeListEditor() { - return makeEditor([ - makeListParagraph({ - id: 'li-1', - text: 'One', - numId: 1, - ilvl: 1, - markerText: '1.', - path: [1], - numberingType: 'decimal', - }), - makeListParagraph({ - id: 'li-2', - text: 'Two', - numId: 1, - ilvl: 1, - markerText: '2.', - path: [2], - numberingType: 'decimal', - }), - ]); - } - - it('insert: returns placeholder success without mutating the document', () => { - const editor = makeListEditor(); - const insertListItemAt = editor.commands!.insertListItemAt as ReturnType; - - const result = listsInsertAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - position: 'after', - }, - { dryRun: true }, - ); - - expect(result.success).toBe(true); - expect(insertListItemAt).not.toHaveBeenCalled(); - }); - - it('setType: returns success without dispatching command', () => { - const editor = makeListEditor(); - const setListTypeAt = editor.commands!.setListTypeAt as ReturnType; - - const result = listsSetTypeAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - kind: 'bullet', - }, - { dryRun: true }, - ); - - expect(result.success).toBe(true); - expect(setListTypeAt).not.toHaveBeenCalled(); - }); - - it('indent: returns success without dispatching command', () => { - vi.spyOn(ListHelpers, 'hasListDefinition').mockReturnValue(true); - const editor = makeListEditor(); - const increaseListIndent = editor.commands!.increaseListIndent as ReturnType; - - const result = listsIndentAdapter( - editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, - { dryRun: true }, - ); - - expect(result.success).toBe(true); - expect(increaseListIndent).not.toHaveBeenCalled(); - }); - - it('outdent: returns success without dispatching command', () => { - const editor = makeListEditor(); - const decreaseListIndent = editor.commands!.decreaseListIndent as ReturnType; - - const result = listsOutdentAdapter( - editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, - { dryRun: true }, - ); - - expect(result.success).toBe(true); - expect(decreaseListIndent).not.toHaveBeenCalled(); - }); - - it('restart: returns success without dispatching command', () => { - const editor = makeListEditor(); - const restartNumbering = editor.commands!.restartNumbering as ReturnType; - - const result = listsRestartAdapter( - editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-2' } }, - { dryRun: true }, - ); - - expect(result.success).toBe(true); - expect(restartNumbering).not.toHaveBeenCalled(); - }); - - it('exit: returns placeholder success without dispatching command', () => { - const editor = makeListEditor(); - const exitListItemAt = editor.commands!.exitListItemAt as ReturnType; - - const result = listsExitAdapter( - editor, - { target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' } }, - { dryRun: true }, - ); - - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.paragraph.nodeId).toBe('(dry-run)'); - expect(exitListItemAt).not.toHaveBeenCalled(); - }); - }); - - it('throws CAPABILITY_UNAVAILABLE for tracked insert dry-run without a configured user', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), - ]); - - expect(() => - listsInsertAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - position: 'after', - }, - { changeMode: 'tracked', dryRun: true }, - ), - ).toThrow('requires a user to be configured'); - }); - - it('returns TARGET_NOT_FOUND failure when post-apply list item resolution fails', () => { - const children = [ - makeListParagraph({ - id: 'li-1', - text: 'One', - numId: 1, - ilvl: 0, - markerText: '1.', - path: [1], - numberingType: 'decimal', - }), - ]; - - // Custom insertListItemAt that returns true but inserts a node with a - // different sdBlockId/paraId than what was requested, making it - // unresolvable by resolveInsertedListItem. - const insertListItemAt = vi.fn((options: { pos: number; position: 'before' | 'after'; sdBlockId?: string }) => { - const inserted = makeListParagraph({ - id: 'unrelated-id', - sdBlockId: 'unrelated-sdBlockId', - numId: 1, - ilvl: 0, - markerText: '', - path: [1], - numberingType: 'decimal', - }); - const at = options.position === 'before' ? 0 : 1; - children.splice(at, 0, inserted); - return true; - }); - - const editor = makeEditor(children, { insertListItemAt }); - - const result = listsInsertAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - position: 'after', - }, - { changeMode: 'direct' }, - ); - - // Contract: success:false means no mutation was applied. - // The mutation DID apply, so we must return success with the generated ID. - expect(result.success).toBe(true); - if (!result.success) return; - expect(result.item.nodeType).toBe('listItem'); - expect(typeof result.item.nodeId).toBe('string'); - expect(result.item.nodeId).not.toBe('(dry-run)'); - }); - - it('throws same error for tracked insert non-dry-run without a configured user', () => { - const editor = makeEditor([ - makeListParagraph({ id: 'li-1', numId: 1, ilvl: 0, markerText: '1.', path: [1], numberingType: 'decimal' }), - ]); - - expect(() => - listsInsertAdapter( - editor, - { - target: { kind: 'block', nodeType: 'listItem', nodeId: 'li-1' }, - position: 'after', - }, - { changeMode: 'tracked' }, - ), - ).toThrow('requires a user to be configured'); - }); -}); diff --git a/packages/super-editor/src/document-api-adapters/comments-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/comments-wrappers.ts similarity index 58% rename from packages/super-editor/src/document-api-adapters/comments-adapter.ts rename to packages/super-editor/src/document-api-adapters/plan-engine/comments-wrappers.ts index a9f376da4c..26e6f74001 100644 --- a/packages/super-editor/src/document-api-adapters/comments-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/comments-wrappers.ts @@ -1,4 +1,13 @@ -import type { Editor } from '../core/Editor.js'; +/** + * Comments convenience wrappers — bridge comments operations to the plan + * engine's revision management and execution path. + * + * Read operations (list, get, goTo) are pure queries or non-mutating navigation. + * Mutating operations (add, edit, reply, move, resolve, remove, setInternal, setActive) + * delegate to editor commands with plan-engine revision tracking. + */ + +import type { Editor } from '../../core/Editor.js'; import type { AddCommentInput, CommentInfo, @@ -13,15 +22,17 @@ import type { RemoveCommentInput, ReplyToCommentInput, ResolveCommentInput, + RevisionGuardOptions, SetCommentActiveInput, SetCommentInternalInput, } from '@superdoc/document-api'; import { TextSelection } from 'prosemirror-state'; import { v4 as uuidv4 } from 'uuid'; -import { DocumentApiAdapterError } from './errors.js'; -import { requireEditorCommand } from './helpers/mutation-helpers.js'; -import { clearIndexCache } from './helpers/index-cache.js'; -import { resolveTextTarget } from './helpers/adapter-utils.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { requireEditorCommand } from '../helpers/mutation-helpers.js'; +import { clearIndexCache } from '../helpers/index-cache.js'; +import { resolveTextTarget } from '../helpers/adapter-utils.js'; +import { executeDomainCommand } from './plan-wrappers.js'; import { buildCommentJsonFromText, extractCommentText, @@ -31,9 +42,13 @@ import { removeCommentEntityTree, toCommentInfo, upsertCommentEntity, -} from './helpers/comment-entity-store.js'; -import { listCommentAnchors, resolveCommentAnchorsById } from './helpers/comment-target-resolver.js'; -import { toNonEmptyString } from './helpers/value-utils.js'; +} from '../helpers/comment-entity-store.js'; +import { listCommentAnchors, resolveCommentAnchorsById } from '../helpers/comment-target-resolver.js'; +import { toNonEmptyString } from '../helpers/value-utils.js'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- type EditorUserIdentity = { name?: string; @@ -62,16 +77,6 @@ function isSameTarget( return left.blockId === right.blockId && left.range.start === right.range.start && left.range.end === right.range.end; } -/** - * Attempts to list comment anchors, returning an empty array on failure. - * - * listCommentAnchors walks the ProseMirror document tree and can throw when - * the document is in a transient or inconsistent state (e.g. mid-transaction, - * partially-loaded). Since this is only used by read-path aggregation - * (buildCommentInfos), returning an empty array is a safe degradation — - * callers will simply see fewer anchors rather than crashing the entire - * list/get flow. - */ function listCommentAnchorsSafe(editor: Editor): ReturnType { try { return listCommentAnchors(editor); @@ -149,17 +154,6 @@ function resolveCommentIdentity( }; } -/** - * Merges document anchor data into a partially-built CommentInfo map. - * - * Grouping by anchor.commentId is safe because prepareCommentsForImport always - * sets the canonical commentId on marks (comments-helpers.js:650) and rewrites - * w:id on resolved range nodes (comments-helpers.js:621,639). - * resolveCommentIdFromAttrs returns canonical commentId first, so - * anchor.commentId matches the entity store key. If a non-import path ever - * creates marks without a canonical commentId attr, this grouping would need - * alias-merging by importedId. - */ function mergeAnchorData(infosById: Map, anchors: ReturnType): void { const grouped = new Map(); for (const anchor of anchors) { @@ -228,14 +222,11 @@ function buildCommentInfos(editor: Editor): CommentInfo[] { return infos; } -/** - * Adds a comment to the document at the specified text range. - * - * @param editor - The editor instance. - * @param input - The comment target and text. - * @returns A receipt indicating success and the created entity address. - */ -function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { +// --------------------------------------------------------------------------- +// Mutation handlers +// --------------------------------------------------------------------------- + +function addCommentHandler(editor: Editor, input: AddCommentInput, options?: RevisionGuardOptions): Receipt { requireEditorCommand(editor.commands?.addComment, 'comments.add (addComment)'); if (input.target.range.start === input.target.range.end) { @@ -264,8 +255,6 @@ function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { }; } - const commentId = uuidv4(); - if (!applyTextSelection(editor, resolved.from, resolved.to)) { return { success: false, @@ -277,53 +266,49 @@ function addCommentHandler(editor: Editor, input: AddCommentInput): Receipt { }; } - // Re-read after selection so the command closure captures the updated selection snapshot. - const addComment = requireEditorCommand(editor.commands?.addComment, 'comments.add (addComment)'); + const commentId = uuidv4(); - const didInsert = - addComment({ - content: input.text, - isInternal: false, - commentId, - }) === true; + const receipt = executeDomainCommand( + editor, + () => { + const addComment = requireEditorCommand(editor.commands?.addComment, 'comments.add (addComment)'); + const didInsert = addComment({ content: input.text, isInternal: false, commentId }) === true; + if (didInsert) { + clearIndexCache(editor); + const store = getCommentEntityStore(editor); + const now = Date.now(); + const user = (editor.options?.user ?? {}) as EditorUserIdentity; + upsertCommentEntity(store, commentId, { + commentId, + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + parentCommentId: undefined, + createdTime: now, + creatorName: user.name, + creatorEmail: user.email, + creatorImage: user.image, + isDone: false, + isInternal: false, + fileId: editor.options?.documentId, + documentId: editor.options?.documentId, + }); + } + return didInsert; + }, + { expectedRevision: options?.expectedRevision }, + ); - if (!didInsert) { + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment insertion produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment insertion produced no change.' }, }; } - clearIndexCache(editor); - - const store = getCommentEntityStore(editor); - const now = Date.now(); - const user = (editor.options?.user ?? {}) as EditorUserIdentity; - upsertCommentEntity(store, commentId, { - commentId, - commentText: input.text, - commentJSON: buildCommentJsonFromText(input.text), - parentCommentId: undefined, - createdTime: now, - creatorName: user.name, - creatorEmail: user.email, - creatorImage: user.image, - isDone: false, - isInternal: false, - fileId: editor.options?.documentId, - documentId: editor.options?.documentId, - }); - - return { - success: true, - inserted: [toCommentAddress(commentId)], - }; + return { success: true, inserted: [toCommentAddress(commentId)] }; } -function editCommentHandler(editor: Editor, input: EditCommentInput): Receipt { +function editCommentHandler(editor: Editor, input: EditCommentInput, options?: RevisionGuardOptions): Receipt { const editComment = requireEditorCommand(editor.commands?.editComment, 'comments.edit (editComment)'); const store = getCommentEntityStore(editor); @@ -333,41 +318,41 @@ function editCommentHandler(editor: Editor, input: EditCommentInput): Receipt { if (existingText === input.text) { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment edit produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment edit produced no change.' }, }; } - const didEdit = editComment({ - commentId: identity.commentId, - importedId: identity.importedId, - content: input.text, - }); - if (!didEdit) { + const receipt = executeDomainCommand( + editor, + () => { + const didEdit = editComment({ + commentId: identity.commentId, + importedId: identity.importedId, + content: input.text, + }); + if (didEdit) { + upsertCommentEntity(store, identity.commentId, { + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + importedId: identity.importedId, + }); + } + return Boolean(didEdit); + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment edit produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment edit produced no change.' }, }; } - upsertCommentEntity(store, identity.commentId, { - commentText: input.text, - commentJSON: buildCommentJsonFromText(input.text), - importedId: identity.importedId, - }); - - return { - success: true, - updated: [toCommentAddress(identity.commentId)], - }; + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } -function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput): Receipt { +function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput, options?: RevisionGuardOptions): Receipt { const addCommentReply = requireEditorCommand(editor.commands?.addCommentReply, 'comments.reply (addCommentReply)'); if (!input.parentCommentId) { @@ -382,55 +367,56 @@ function replyToCommentHandler(editor: Editor, input: ReplyToCommentInput): Rece const parentIdentity = resolveCommentIdentity(editor, input.parentCommentId); const replyId = uuidv4(); - const didReply = addCommentReply({ - parentId: parentIdentity.commentId, - content: input.text, - commentId: replyId, - }); - if (!didReply) { + + const receipt = executeDomainCommand( + editor, + () => { + const didReply = addCommentReply({ + parentId: parentIdentity.commentId, + content: input.text, + commentId: replyId, + }); + if (didReply) { + const now = Date.now(); + const user = (editor.options?.user ?? {}) as EditorUserIdentity; + const store = getCommentEntityStore(editor); + upsertCommentEntity(store, replyId, { + commentId: replyId, + parentCommentId: parentIdentity.commentId, + commentText: input.text, + commentJSON: buildCommentJsonFromText(input.text), + createdTime: now, + creatorName: user.name, + creatorEmail: user.email, + creatorImage: user.image, + isDone: false, + isInternal: false, + fileId: editor.options?.documentId, + documentId: editor.options?.documentId, + }); + } + return Boolean(didReply); + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment reply could not be applied.', - }, + failure: { code: 'INVALID_TARGET', message: 'Comment reply could not be applied.' }, }; } - const now = Date.now(); - const user = (editor.options?.user ?? {}) as EditorUserIdentity; - const store = getCommentEntityStore(editor); - upsertCommentEntity(store, replyId, { - commentId: replyId, - parentCommentId: parentIdentity.commentId, - commentText: input.text, - commentJSON: buildCommentJsonFromText(input.text), - createdTime: now, - creatorName: user.name, - creatorEmail: user.email, - creatorImage: user.image, - isDone: false, - isInternal: false, - fileId: editor.options?.documentId, - documentId: editor.options?.documentId, - }); - - return { - success: true, - inserted: [toCommentAddress(replyId)], - }; + return { success: true, inserted: [toCommentAddress(replyId)] }; } -function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { +function moveCommentHandler(editor: Editor, input: MoveCommentInput, options?: RevisionGuardOptions): Receipt { const moveComment = requireEditorCommand(editor.commands?.moveComment, 'comments.move (moveComment)'); if (input.target.range.start === input.target.range.end) { return { success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment target range must be non-collapsed.', - }, + failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.' }, }; } @@ -441,10 +427,7 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { if (resolved.from === resolved.to) { return { success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment target range must be non-collapsed.', - }, + failure: { code: 'INVALID_TARGET', message: 'Comment target range must be non-collapsed.' }, }; } @@ -452,10 +435,7 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { if (!identity.anchors.length) { return { success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment cannot be moved because it has no resolvable anchor.', - }, + failure: { code: 'INVALID_TARGET', message: 'Comment cannot be moved because it has no resolvable anchor.' }, }; } @@ -473,41 +453,27 @@ function moveCommentHandler(editor: Editor, input: MoveCommentInput): Receipt { if (currentTarget && isSameTarget(currentTarget, input.target)) { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment move produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment move produced no change.' }, }; } - // NOTE: Passing canonical commentId is sufficient because findRangeById checks - // marks by commentId || importedId (comments-plugin.js:1058) and resolved range - // nodes have w:id rewritten to canonical id during import (comments-helpers.js:621,639). - // If a non-import path ever creates anchors keyed only by importedId, this would - // need to fall back to identity.importedId. - const didMove = moveComment({ - commentId: identity.commentId, - from: resolved.from, - to: resolved.to, - }); + const receipt = executeDomainCommand( + editor, + () => Boolean(moveComment({ commentId: identity.commentId, from: resolved.from, to: resolved.to })), + { expectedRevision: options?.expectedRevision }, + ); - if (!didMove) { + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment move produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment move produced no change.' }, }; } - return { - success: true, - updated: [toCommentAddress(identity.commentId)], - }; + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } -function resolveCommentHandler(editor: Editor, input: ResolveCommentInput): Receipt { +function resolveCommentHandler(editor: Editor, input: ResolveCommentInput, options?: RevisionGuardOptions): Receipt { const resolveComment = requireEditorCommand(editor.commands?.resolveComment, 'comments.resolve (resolveComment)'); const store = getCommentEntityStore(editor); @@ -519,59 +485,62 @@ function resolveCommentHandler(editor: Editor, input: ResolveCommentInput): Rece if (alreadyResolved) { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment is already resolved.', - }, + failure: { code: 'NO_OP', message: 'Comment is already resolved.' }, }; } - const didResolve = resolveComment({ - commentId: identity.commentId, - importedId: identity.importedId, - }); - if (!didResolve) { + const receipt = executeDomainCommand( + editor, + () => { + const didResolve = resolveComment({ + commentId: identity.commentId, + importedId: identity.importedId, + }); + if (didResolve) { + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + isDone: true, + resolvedTime: Date.now(), + }); + } + return Boolean(didResolve); + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment resolve produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment resolve produced no change.' }, }; } - upsertCommentEntity(store, identity.commentId, { - importedId: identity.importedId, - isDone: true, - resolvedTime: Date.now(), - }); - - return { - success: true, - updated: [toCommentAddress(identity.commentId)], - }; + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } -function removeCommentHandler(editor: Editor, input: RemoveCommentInput): Receipt { +function removeCommentHandler(editor: Editor, input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt { const removeComment = requireEditorCommand(editor.commands?.removeComment, 'comments.remove (removeComment)'); const store = getCommentEntityStore(editor); const identity = resolveCommentIdentity(editor, input.commentId); - const didRemove = - removeComment({ - commentId: identity.commentId, - importedId: identity.importedId, - }) === true; + let didRemove = false; + let removedRecords: ReturnType = []; + + const receipt = executeDomainCommand( + editor, + () => { + didRemove = removeComment({ commentId: identity.commentId, importedId: identity.importedId }) === true; + removedRecords = removeCommentEntityTree(store, identity.commentId); + return didRemove || removedRecords.length > 0; + }, + { expectedRevision: options?.expectedRevision }, + ); - const removedRecords = removeCommentEntityTree(store, identity.commentId); - if (!didRemove && removedRecords.length === 0) { + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment remove produced no change.', - }, + failure: { code: 'NO_OP', message: 'Comment remove produced no change.' }, }; } @@ -592,7 +561,11 @@ function removeCommentHandler(editor: Editor, input: RemoveCommentInput): Receip }; } -function setCommentInternalHandler(editor: Editor, input: SetCommentInternalInput): Receipt { +function setCommentInternalHandler( + editor: Editor, + input: SetCommentInternalInput, + options?: RevisionGuardOptions, +): Receipt { const setCommentInternal = requireEditorCommand( editor.commands?.setCommentInternal, 'comments.setInternal (setCommentInternal)', @@ -607,43 +580,50 @@ function setCommentInternalHandler(editor: Editor, input: SetCommentInternalInpu if (typeof currentInternal === 'boolean' && currentInternal === input.isInternal) { return { success: false, - failure: { - code: 'NO_OP', - message: 'Comment internal state is already set to the requested value.', - }, + failure: { code: 'NO_OP', message: 'Comment internal state is already set to the requested value.' }, }; } const hasOpenAnchor = identity.anchors.some((anchor) => anchor.status === 'open'); - if (hasOpenAnchor) { - const didApply = setCommentInternal({ - commentId: identity.commentId, - importedId: identity.importedId, - isInternal: input.isInternal, - }); - if (!didApply) { - return { - success: false, - failure: { - code: 'INVALID_TARGET', - message: 'Comment internal state could not be updated on the current anchor.', - }, - }; - } - } - upsertCommentEntity(store, identity.commentId, { - importedId: identity.importedId, - isInternal: input.isInternal, - }); + const receipt = executeDomainCommand( + editor, + () => { + if (hasOpenAnchor) { + const didApply = setCommentInternal({ + commentId: identity.commentId, + importedId: identity.importedId, + isInternal: input.isInternal, + }); + if (!didApply) return false; + } + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + isInternal: input.isInternal, + }); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); - return { - success: true, - updated: [toCommentAddress(identity.commentId)], - }; + if (receipt.steps[0]?.effect !== 'changed') { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment internal state could not be updated on the current anchor.', + }, + }; + } + + return { success: true, updated: [toCommentAddress(identity.commentId)] }; } -function setCommentActiveHandler(editor: Editor, input: SetCommentActiveInput): Receipt { +function setCommentActiveHandler( + editor: Editor, + input: SetCommentActiveInput, + options?: RevisionGuardOptions, +): Receipt { const setActiveComment = requireEditorCommand( editor.commands?.setActiveComment, 'comments.setActive (setActiveComment)', @@ -654,8 +634,11 @@ function setCommentActiveHandler(editor: Editor, input: SetCommentActiveInput): resolvedCommentId = resolveCommentIdentity(editor, input.commentId).commentId; } - const didSet = setActiveComment({ commentId: resolvedCommentId }); - if (!didSet) { + const receipt = executeDomainCommand(editor, () => Boolean(setActiveComment({ commentId: resolvedCommentId })), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, failure: { @@ -671,6 +654,10 @@ function setCommentActiveHandler(editor: Editor, input: SetCommentActiveInput): }; } +// --------------------------------------------------------------------------- +// Read handlers +// --------------------------------------------------------------------------- + function goToCommentHandler(editor: Editor, input: GoToCommentInput): Receipt { const setCursorById = requireEditorCommand(editor.commands?.setCursorById, 'comments.goTo (setCursorById)'); @@ -711,22 +698,24 @@ function listCommentsHandler(editor: Editor, query?: CommentsListQuery): Comment }; } -/** - * Creates the comments adapter namespace for the Document API. - * - * @param editor - The editor instance to bind comment operations to. - * @returns A {@link CommentsAdapter} that delegates to editor commands. - */ -export function createCommentsAdapter(editor: Editor): CommentsAdapter { +// --------------------------------------------------------------------------- +// Adapter factory +// --------------------------------------------------------------------------- + +export function createCommentsWrapper(editor: Editor): CommentsAdapter { return { - add: (input: AddCommentInput) => addCommentHandler(editor, input), - edit: (input: EditCommentInput) => editCommentHandler(editor, input), - reply: (input: ReplyToCommentInput) => replyToCommentHandler(editor, input), - move: (input: MoveCommentInput) => moveCommentHandler(editor, input), - resolve: (input: ResolveCommentInput) => resolveCommentHandler(editor, input), - remove: (input: RemoveCommentInput) => removeCommentHandler(editor, input), - setInternal: (input: SetCommentInternalInput) => setCommentInternalHandler(editor, input), - setActive: (input: SetCommentActiveInput) => setCommentActiveHandler(editor, input), + add: (input: AddCommentInput, options?: RevisionGuardOptions) => addCommentHandler(editor, input, options), + edit: (input: EditCommentInput, options?: RevisionGuardOptions) => editCommentHandler(editor, input, options), + reply: (input: ReplyToCommentInput, options?: RevisionGuardOptions) => + replyToCommentHandler(editor, input, options), + move: (input: MoveCommentInput, options?: RevisionGuardOptions) => moveCommentHandler(editor, input, options), + resolve: (input: ResolveCommentInput, options?: RevisionGuardOptions) => + resolveCommentHandler(editor, input, options), + remove: (input: RemoveCommentInput, options?: RevisionGuardOptions) => removeCommentHandler(editor, input, options), + setInternal: (input: SetCommentInternalInput, options?: RevisionGuardOptions) => + setCommentInternalHandler(editor, input, options), + setActive: (input: SetCommentActiveInput, options?: RevisionGuardOptions) => + setCommentActiveHandler(editor, input, options), goTo: (input: GoToCommentInput) => goToCommentHandler(editor, input), get: (input: GetCommentInput) => getCommentHandler(editor, input), list: (query?: CommentsListQuery) => listCommentsHandler(editor, query), diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.test.ts new file mode 100644 index 0000000000..f45995d34c --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.test.ts @@ -0,0 +1,203 @@ +import type { TextAddress } from '@superdoc/document-api'; +import { normalizeMatchRanges } from './compiler.js'; +import { PlanError } from './errors.js'; + +// --------------------------------------------------------------------------- +// Helper to build TextAddress values concisely +// --------------------------------------------------------------------------- + +function addr(blockId: string, start: number, end: number): TextAddress { + return { kind: 'text', blockId, range: { start, end } }; +} + +// --------------------------------------------------------------------------- +// normalizeMatchRanges — unit tests +// --------------------------------------------------------------------------- + +describe('normalizeMatchRanges', () => { + const stepId = 'step-1'; + + // --- Single range (passthrough) --- + + it('returns a single range unchanged', () => { + const result = normalizeMatchRanges(stepId, [addr('p1', 5, 10)]); + expect(result).toEqual({ blockId: 'p1', from: 5, to: 10 }); + }); + + // --- Contiguous multi-range coalescing --- + + it('coalesces two adjacent ranges in the same block', () => { + const result = normalizeMatchRanges(stepId, [addr('p1', 0, 5), addr('p1', 5, 11)]); + expect(result).toEqual({ blockId: 'p1', from: 0, to: 11 }); + }); + + it('coalesces three contiguous ranges (split-run phrase)', () => { + // Simulates "hello world!" split across bold, plain, italic runs + const result = normalizeMatchRanges(stepId, [addr('p1', 0, 5), addr('p1', 5, 11), addr('p1', 11, 12)]); + expect(result).toEqual({ blockId: 'p1', from: 0, to: 12 }); + }); + + it('coalesces unsorted ranges by sorting first', () => { + const result = normalizeMatchRanges(stepId, [addr('p1', 10, 15), addr('p1', 0, 5), addr('p1', 5, 10)]); + expect(result).toEqual({ blockId: 'p1', from: 0, to: 15 }); + }); + + it('handles overlapping sub-ranges within a single block', () => { + // Edge case: ranges overlap slightly (shouldn't happen normally, but the + // normalizer should handle it gracefully by extending) + const result = normalizeMatchRanges(stepId, [addr('p1', 0, 7), addr('p1', 5, 12)]); + expect(result).toEqual({ blockId: 'p1', from: 0, to: 12 }); + }); + + // --- Cross-block → CROSS_BLOCK_MATCH --- + + it('throws CROSS_BLOCK_MATCH when ranges span multiple blocks', () => { + expect(() => normalizeMatchRanges(stepId, [addr('p1', 0, 5), addr('p2', 0, 5)])).toThrow(PlanError); + + try { + normalizeMatchRanges(stepId, [addr('p1', 0, 5), addr('p2', 0, 5)]); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe('CROSS_BLOCK_MATCH'); + expect((e as PlanError).stepId).toBe(stepId); + } + }); + + it('throws CROSS_BLOCK_MATCH with all distinct blockIds in details', () => { + try { + normalizeMatchRanges(stepId, [addr('p1', 0, 3), addr('p2', 0, 3), addr('p3', 0, 3)]); + } catch (e) { + const details = (e as PlanError).details as { blockIds: string[] }; + expect(details.blockIds).toEqual(expect.arrayContaining(['p1', 'p2', 'p3'])); + expect(details.blockIds).toHaveLength(3); + } + }); + + // --- Discontiguous same-block → INVALID_INPUT --- + + it('throws INVALID_INPUT for discontiguous ranges in the same block', () => { + expect(() => normalizeMatchRanges(stepId, [addr('p1', 0, 5), addr('p1', 10, 15)])).toThrow(PlanError); + + try { + normalizeMatchRanges(stepId, [addr('p1', 0, 5), addr('p1', 10, 15)]); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe('INVALID_INPUT'); + expect((e as PlanError).message).toContain('discontiguous'); + } + }); + + // --- Empty ranges → INVALID_INPUT --- + + it('throws INVALID_INPUT for empty range array', () => { + expect(() => normalizeMatchRanges(stepId, [])).toThrow(PlanError); + + try { + normalizeMatchRanges(stepId, []); + } catch (e) { + expect((e as PlanError).code).toBe('INVALID_INPUT'); + } + }); + + // --- Malformed range bounds → INVALID_INPUT --- + + it('throws INVALID_INPUT for negative start offset', () => { + try { + normalizeMatchRanges(stepId, [addr('p1', -3, 5)]); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe('INVALID_INPUT'); + expect((e as PlanError).message).toContain('invalid range bounds'); + return; + } + throw new Error('expected PlanError'); + }); + + it('throws INVALID_INPUT for inverted range (end < start)', () => { + try { + normalizeMatchRanges(stepId, [addr('p1', 10, 5)]); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe('INVALID_INPUT'); + expect((e as PlanError).message).toContain('invalid range bounds'); + return; + } + throw new Error('expected PlanError'); + }); + + it('throws INVALID_INPUT when any range in a multi-range set has bad bounds', () => { + try { + normalizeMatchRanges(stepId, [ + addr('p1', 0, 5), + addr('p1', 5, 3), // inverted + ]); + } catch (e) { + expect((e as PlanError).code).toBe('INVALID_INPUT'); + return; + } + throw new Error('expected PlanError'); + }); + + it('accepts zero-width range (start === end)', () => { + const result = normalizeMatchRanges(stepId, [addr('p1', 5, 5)]); + expect(result).toEqual({ blockId: 'p1', from: 5, to: 5 }); + }); + + // --- Cardinality semantics --- + + it('produces one result per logical match regardless of range fragment count', () => { + // This verifies the fundamental semantic: 3 fragments from one search hit = 1 target + const result = normalizeMatchRanges(stepId, [addr('p1', 0, 4), addr('p1', 4, 8), addr('p1', 8, 12)]); + // One object, not three + expect(result).toEqual({ blockId: 'p1', from: 0, to: 12 }); + }); +}); + +// --------------------------------------------------------------------------- +// TextRewriteStep type — style is optional +// --------------------------------------------------------------------------- + +describe('TextRewriteStep type contract', () => { + it('accepts text.rewrite step without style (compile-time + runtime check)', () => { + // This verifies the type change: style is now optional on TextRewriteStep. + // When omitted, the executor defaults to preserve mode. + const step: import('@superdoc/document-api').TextRewriteStep = { + id: 'rewrite-1', + op: 'text.rewrite', + where: { + by: 'select', + select: { type: 'text', pattern: 'hello' }, + require: 'exactlyOne', + }, + args: { + replacement: { text: 'world' }, + // style intentionally omitted — should compile and be valid + }, + }; + + expect(step.args.style).toBeUndefined(); + expect(step.op).toBe('text.rewrite'); + }); + + it('still accepts text.rewrite step with explicit style', () => { + const step: import('@superdoc/document-api').TextRewriteStep = { + id: 'rewrite-2', + op: 'text.rewrite', + where: { + by: 'select', + select: { type: 'text', pattern: 'hello' }, + require: 'exactlyOne', + }, + args: { + replacement: { text: 'world' }, + style: { + inline: { mode: 'preserve', onNonUniform: 'majority' }, + paragraph: { mode: 'preserve' }, + }, + }, + }; + + expect(step.args.style).toBeDefined(); + expect(step.args.style!.inline.mode).toBe('preserve'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts new file mode 100644 index 0000000000..8a8d3a62b9 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts @@ -0,0 +1,441 @@ +/** + * Plan compiler — resolves step selectors against pre-mutation document state. + * + * Phase 1 (compile): resolve all mutation step selectors, capture style data, + * detect overlapping targets. + */ + +import type { + MutationStep, + AssertStep, + TextSelector, + NodeSelector, + SelectWhere, + RefWhere, + TextAddress, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { CompiledTarget } from './executor-registry.types.js'; +import { planError } from './errors.js'; +import { hasStepExecutor } from './executor-registry.js'; +import { captureRunsInRange } from './style-resolver.js'; +import { getBlockIndex } from '../helpers/index-cache.js'; +import { getRevision } from './revision-tracker.js'; +import { executeTextSelector } from '../find/text-strategy.js'; +import { executeBlockSelector } from '../find/block-strategy.js'; +import { isTextBlockCandidate, type BlockIndex } from '../helpers/node-address-resolver.js'; + +export interface CompiledStep { + step: MutationStep; + targets: CompiledTarget[]; +} + +export interface CompiledPlan { + mutationSteps: CompiledStep[]; + assertSteps: AssertStep[]; +} + +function isAssertStep(step: MutationStep): step is AssertStep { + return step.op === 'assert'; +} + +function isSelectWhere(where: MutationStep['where']): where is SelectWhere { + return where.by === 'select'; +} + +/** Resolved address with captured style data from the matched range. */ +interface ResolvedAddress { + blockId: string; + from: number; + to: number; + text: string; + marks: readonly unknown[]; + /** Block position in PM doc coordinates (needed for style capture). */ + blockPos: number; +} + +// --------------------------------------------------------------------------- +// Logical-match range normalizer +// --------------------------------------------------------------------------- + +/** + * Coalesces an array of text ranges (from a single logical match) into one + * contiguous range. All ranges must belong to the same block and be + * contiguous/adjacent — otherwise an appropriate error is thrown. + */ +export function normalizeMatchRanges( + stepId: string, + ranges: TextAddress[], +): { blockId: string; from: number; to: number } { + if (ranges.length === 0) { + throw planError('INVALID_INPUT', 'logical match produced zero ranges', stepId); + } + + // Validate per-range bounds (guards against malformed ref payloads) + for (const r of ranges) { + if (r.range.start < 0 || r.range.end < r.range.start) { + throw planError( + 'INVALID_INPUT', + `invalid range bounds [${r.range.start}, ${r.range.end}) in block "${r.blockId}"`, + stepId, + ); + } + } + + const blockId = ranges[0].blockId; + + // Cross-block check + if (ranges.some((r) => r.blockId !== blockId)) { + throw planError('CROSS_BLOCK_MATCH', `mutation target spans multiple blocks`, stepId, { + blockIds: [...new Set(ranges.map((r) => r.blockId))], + }); + } + + if (ranges.length === 1) { + return { blockId, from: ranges[0].range.start, to: ranges[0].range.end }; + } + + // Sort by start position + const sorted = [...ranges].sort((a, b) => a.range.start - b.range.start); + + // Walk ranges and verify contiguity + const from = sorted[0].range.start; + let to = sorted[0].range.end; + for (let i = 1; i < sorted.length; i++) { + const r = sorted[i]; + if (r.range.start > to) { + throw planError( + 'INVALID_INPUT', + `match ranges are discontiguous within block "${blockId}" (gap between offset ${to} and ${r.range.start})`, + stepId, + ); + } + // Extend (handles adjacent and overlapping sub-ranges) + if (r.range.end > to) to = r.range.end; + } + + return { blockId, from, to }; +} + +function resolveTextSelector( + editor: Editor, + index: BlockIndex, + selector: TextSelector | NodeSelector, + within: import('@superdoc/document-api').NodeAddress | undefined, + stepId: string, +): { addresses: ResolvedAddress[] } { + if (selector.type === 'text') { + const query = { + select: selector, + within: within as import('@superdoc/document-api').NodeAddress | undefined, + includeNodes: false, + }; + const result = executeTextSelector(editor, index, query, []); + + const addresses: ResolvedAddress[] = []; + + if (result.context) { + for (const ctx of result.context) { + if (!ctx.textRanges?.length) continue; + + // One context entry = one logical match. + // Coalesce its range fragments into a single contiguous range. + const coalesced = normalizeMatchRanges(stepId, ctx.textRanges); + const candidate = index.candidates.find((c) => c.nodeId === coalesced.blockId); + if (!candidate) continue; + + // Build text from actual document bounds, not snippet + const blockText = getBlockText(editor, candidate); + const matchText = blockText.slice(coalesced.from, coalesced.to); + + // Capture inline mark runs from the coalesced range + const captured = captureRunsInRange(editor, candidate.pos, coalesced.from, coalesced.to); + + addresses.push({ + blockId: coalesced.blockId, + from: coalesced.from, + to: coalesced.to, + text: matchText, + marks: captured.runs.length > 0 ? captured.runs[0].marks : [], + blockPos: candidate.pos, + }); + } + } + + return { addresses }; + } + + // Node selector — resolve to block positions + const query = { + select: selector, + within: within as import('@superdoc/document-api').NodeAddress | undefined, + includeNodes: false, + }; + const result = executeBlockSelector(index, query, []); + const textBlocks = index.candidates.filter(isTextBlockCandidate); + + const addresses: ResolvedAddress[] = []; + for (const match of result.matches) { + if (match.kind !== 'block') continue; + const candidate = textBlocks.find((c) => c.nodeId === match.nodeId); + if (!candidate) continue; + const blockText = getBlockText(editor, candidate); + addresses.push({ + blockId: match.nodeId, + from: 0, + to: blockText.length, + text: blockText, + marks: [], + blockPos: candidate.pos, + }); + } + + return { addresses }; +} + +function getBlockText(editor: Editor, candidate: { pos: number; end: number }): string { + // Use the same separator/leaf arguments as toTextAddress in common.ts so that + // block-relative offsets computed by the selector engine align with this text. + const blockStart = candidate.pos + 1; + const blockEnd = candidate.end - 1; + return editor.state.doc.textBetween(blockStart, blockEnd, '\n', '\ufffc'); +} + +function applyCardinalityCheck(step: MutationStep, targets: CompiledTarget[]): void { + const where = step.where; + if (!('require' in where)) return; + + const require = where.require; + + if (require === 'first') { + if (targets.length === 0) { + throw planError('MATCH_NOT_FOUND', `selector matched zero ranges`, step.id); + } + } else if (require === 'exactlyOne') { + if (targets.length === 0) { + throw planError('MATCH_NOT_FOUND', `selector matched zero ranges`, step.id); + } + if (targets.length > 1) { + throw planError('AMBIGUOUS_MATCH', `selector matched ${targets.length} ranges, expected exactly one`, step.id, { + matchCount: targets.length, + }); + } + } else if (require === 'all') { + if (targets.length === 0) { + throw planError('MATCH_NOT_FOUND', `selector matched zero ranges`, step.id); + } + } +} + +function isRefWhere(where: MutationStep['where']): where is RefWhere { + return where.by === 'ref'; +} + +// --------------------------------------------------------------------------- +// Ref resolution — text refs and block refs +// --------------------------------------------------------------------------- + +function resolveTextRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] { + const encoded = ref.slice(5); // strip 'text:' prefix + let refData: { rev: string; addr: unknown; ranges?: TextAddress[] }; + try { + refData = JSON.parse(atob(encoded)); + } catch { + throw planError('INVALID_INPUT', `invalid text ref encoding`, step.id); + } + + const currentRevision = getRevision(editor); + if (refData.rev !== currentRevision) { + throw planError( + 'REVISION_MISMATCH', + `text ref was created at revision "${refData.rev}" but document is at "${currentRevision}"`, + step.id, + { refRevision: refData.rev, currentRevision }, + ); + } + + if (!refData.ranges?.length) return []; + + // All ranges in a text ref represent one logical match — coalesce them. + const coalesced = normalizeMatchRanges(step.id, refData.ranges); + const candidate = index.candidates.find((c) => c.nodeId === coalesced.blockId); + if (!candidate) return []; + + const blockText = getBlockText(editor, candidate); + const matchText = blockText.slice(coalesced.from, coalesced.to); + + const capturedStyle = + step.op === 'text.rewrite' ? captureRunsInRange(editor, candidate.pos, coalesced.from, coalesced.to) : undefined; + + return [ + { + stepId: step.id, + op: step.op, + blockId: coalesced.blockId, + from: coalesced.from, + to: coalesced.to, + text: matchText, + marks: [], + capturedStyle, + }, + ]; +} + +function resolveBlockRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] { + const candidate = index.candidates.find((c) => c.nodeId === ref); + if (!candidate) return []; + + const blockText = getBlockText(editor, candidate); + const capturedStyle = + step.op === 'text.rewrite' ? captureRunsInRange(editor, candidate.pos, 0, blockText.length) : undefined; + + return [ + { + stepId: step.id, + op: step.op, + blockId: candidate.nodeId, + from: 0, + to: blockText.length, + text: blockText, + marks: [], + capturedStyle, + }, + ]; +} + +function resolveRefTargets(editor: Editor, index: BlockIndex, step: MutationStep, where: RefWhere): CompiledTarget[] { + const ref = where.ref; + if (ref.startsWith('text:')) { + return resolveTextRef(editor, index, step, ref); + } + return resolveBlockRef(editor, index, step, ref); +} + +// --------------------------------------------------------------------------- + +function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationStep): CompiledTarget[] { + const where = step.where; + + let targets: CompiledTarget[]; + + if (isRefWhere(where)) { + targets = resolveRefTargets(editor, index, step, where); + } else if (isSelectWhere(where)) { + const resolved = resolveTextSelector(editor, index, where.select, where.within, step.id); + targets = resolved.addresses.map((addr) => { + const capturedStyle = + step.op === 'text.rewrite' ? captureRunsInRange(editor, addr.blockPos, addr.from, addr.to) : undefined; + + return { + stepId: step.id, + op: step.op, + blockId: addr.blockId, + from: addr.from, + to: addr.to, + text: addr.text, + marks: addr.marks, + capturedStyle, + }; + }); + } else { + throw planError('INVALID_INPUT', `unsupported where.by value`, step.id); + } + + // Sort by document position (ascending) + targets.sort((a, b) => { + if (a.blockId === b.blockId) return a.from - b.from; + const posA = index.candidates.find((c) => c.nodeId === a.blockId)?.pos ?? 0; + const posB = index.candidates.find((c) => c.nodeId === b.blockId)?.pos ?? 0; + return posA - posB; + }); + + // Deduplicate identical ranges + targets = targets.filter( + (t, i) => + i === 0 || t.blockId !== targets[i - 1].blockId || t.from !== targets[i - 1].from || t.to !== targets[i - 1].to, + ); + + // Apply cardinality rules + applyCardinalityCheck(step, targets); + + // Apply cardinality truncation + const require = 'require' in where ? where.require : undefined; + if (require === 'first' && targets.length > 1) { + targets = [targets[0]]; + } + + return targets; +} + +export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan { + const index = getBlockIndex(editor); + const mutationSteps: CompiledStep[] = []; + const assertSteps: AssertStep[] = []; + + // Validate step IDs are unique + const seenIds = new Set(); + for (const step of steps) { + if (!step.id) { + throw planError('INVALID_INPUT', 'step.id is required'); + } + if (seenIds.has(step.id)) { + throw planError('INVALID_INPUT', `duplicate step id "${step.id}"`, step.id); + } + seenIds.add(step.id); + } + + // Separate assert steps from mutation steps + for (const step of steps) { + if (isAssertStep(step)) { + assertSteps.push(step); + continue; + } + + // Validate known op via executor registry + if (!hasStepExecutor(step.op)) { + throw planError('INVALID_INPUT', `unknown step op "${step.op}"`, step.id); + } + + const targets = resolveStepTargets(editor, index, step); + mutationSteps.push({ step, targets }); + } + + // Overlap detection across mutation steps + detectOverlaps(mutationSteps); + + return { mutationSteps, assertSteps }; +} + +function detectOverlaps(steps: CompiledStep[]): void { + // Collect all target ranges grouped by blockId + const rangesByBlock = new Map>(); + + for (const compiled of steps) { + for (const target of compiled.targets) { + let blockRanges = rangesByBlock.get(target.blockId); + if (!blockRanges) { + blockRanges = []; + rangesByBlock.set(target.blockId, blockRanges); + } + blockRanges.push({ stepId: target.stepId, from: target.from, to: target.to }); + } + } + + // Check for overlaps within each block + for (const [blockId, ranges] of rangesByBlock) { + ranges.sort((a, b) => a.from - b.from); + for (let i = 1; i < ranges.length; i++) { + const prev = ranges[i - 1]; + const curr = ranges[i]; + // Different steps overlapping + if (prev.stepId !== curr.stepId && prev.to > curr.from) { + throw planError( + 'PLAN_CONFLICT_OVERLAP', + `steps "${prev.stepId}" and "${curr.stepId}" target overlapping ranges in block "${blockId}"`, + curr.stepId, + { blockId, rangeA: { from: prev.from, to: prev.to }, rangeB: { from: curr.from, to: curr.to } }, + ); + } + } + } +} diff --git a/packages/super-editor/src/document-api-adapters/create-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/create-wrappers.ts similarity index 50% rename from packages/super-editor/src/document-api-adapters/create-adapter.ts rename to packages/super-editor/src/document-api-adapters/plan-engine/create-wrappers.ts index e2f1566b89..cbf36e42f4 100644 --- a/packages/super-editor/src/document-api-adapters/create-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/create-wrappers.ts @@ -1,6 +1,13 @@ +/** + * Create convenience wrappers — bridge create.paragraph and create.heading + * to the plan engine's execution path. + * + * Each wrapper resolves the insertion position, calls the editor command, + * and manages revision tracking through the plan engine's revision system. + */ + import { v4 as uuidv4 } from 'uuid'; -import { generateDocxRandomId } from '../core/helpers/generateDocxRandomId.js'; -import type { Editor } from '../core/Editor.js'; +import type { Editor } from '../../core/Editor.js'; import type { CreateParagraphInput, CreateParagraphResult, @@ -10,17 +17,21 @@ import type { CreateHeadingSuccessResult, MutationOptions, } from '@superdoc/document-api'; -import { clearIndexCache, getBlockIndex } from './helpers/index-cache.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'; +import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.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'; +import { executeDomainCommand } from './plan-wrappers.js'; + +// --------------------------------------------------------------------------- +// Command types (internal to the wrapper) +// --------------------------------------------------------------------------- type InsertParagraphAtCommandOptions = { pos: number; text?: string; sdBlockId?: string; - paraId?: string; tracked?: boolean; }; @@ -31,14 +42,21 @@ type InsertHeadingAtCommandOptions = { level: number; text?: string; sdBlockId?: string; - paraId?: string; tracked?: boolean; }; type InsertHeadingAtCommand = (options: InsertHeadingAtCommandOptions) => boolean; -function resolveParagraphInsertPosition(editor: Editor, input: CreateParagraphInput): number { - const location = input.at ?? { kind: 'documentEnd' }; +// --------------------------------------------------------------------------- +// Position resolution helpers +// --------------------------------------------------------------------------- + +function resolveCreateInsertPosition( + editor: Editor, + at: CreateParagraphInput['at'] | CreateHeadingInput['at'], + operationLabel: string, +): number { + const location = at ?? { kind: 'documentEnd' }; if (location.kind === 'documentStart') return 0; if (location.kind === 'documentEnd') return editor.state.doc.content.size; @@ -49,7 +67,7 @@ function resolveParagraphInsertPosition(editor: Editor, input: CreateParagraphIn ? 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.', { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Create ${operationLabel} target block was not found.`, { target: hasTarget ? location.target : (location as { nodeId: string }).nodeId, }); } @@ -57,26 +75,36 @@ function resolveParagraphInsertPosition(editor: Editor, input: CreateParagraphIn return location.kind === 'before' ? target.pos : target.end; } -function resolveCreatedParagraph(editor: Editor, paraId: string): BlockCandidate { +// --------------------------------------------------------------------------- +// Post-execution block resolution helpers +// --------------------------------------------------------------------------- + +function resolveCreatedBlock(editor: Editor, nodeType: string, blockId: string): BlockCandidate { const index = getBlockIndex(editor); - // paraId is the primary key in the index, so this is a direct hit. - const resolved = index.byId.get(`paragraph:${paraId}`); + const resolved = index.byId.get(`${nodeType}:${blockId}`); if (resolved) return resolved; - // Fallback: scan by paraId attr in case the index was built before the node - // was fully materialised. - const byAttr = index.candidates.find((candidate) => { - if (candidate.nodeType !== 'paragraph') return false; - const attrs = (candidate.node as { attrs?: { paraId?: unknown } }).attrs; - return typeof attrs?.paraId === 'string' && attrs.paraId === paraId; + const bySdBlockId = index.candidates.find((candidate) => { + if (candidate.nodeType !== nodeType) return false; + const attrs = (candidate.node as { attrs?: { sdBlockId?: unknown } }).attrs; + return typeof attrs?.sdBlockId === 'string' && attrs.sdBlockId === blockId; }); - if (byAttr) return byAttr; + if (bySdBlockId) return bySdBlockId; + + const fallback = index.candidates.find( + (candidate) => candidate.nodeType === nodeType && candidate.nodeId === blockId, + ); + if (fallback) return fallback; - throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Created paragraph could not be resolved after insertion.', { - paraId, + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Created ${nodeType} could not be resolved after insertion.`, { + [`${nodeType}Id`]: blockId, }); } +// --------------------------------------------------------------------------- +// Result builders +// --------------------------------------------------------------------------- + function buildParagraphCreateSuccess( paragraphNodeId: string, trackedChangeRefs?: CreateParagraphSuccessResult['trackedChangeRefs'], @@ -97,7 +125,31 @@ function buildParagraphCreateSuccess( }; } -export function createParagraphAdapter( +function buildHeadingCreateSuccess( + headingNodeId: string, + trackedChangeRefs?: CreateHeadingSuccessResult['trackedChangeRefs'], +): CreateHeadingSuccessResult { + return { + success: true, + heading: { + kind: 'block', + nodeType: 'heading', + nodeId: headingNodeId, + }, + insertionPoint: { + kind: 'text', + blockId: headingNodeId, + range: { start: 0, end: 0 }, + }, + trackedChangeRefs, + }; +} + +// --------------------------------------------------------------------------- +// create.paragraph wrapper +// --------------------------------------------------------------------------- + +export function createParagraphWrapper( editor: Editor, input: CreateParagraphInput, options?: MutationOptions, @@ -112,7 +164,7 @@ export function createParagraphAdapter( ensureTrackedCapability(editor, { operation: 'create.paragraph' }); } - const insertAt = resolveParagraphInsertPosition(editor, input); + const insertAt = resolveCreateInsertPosition(editor, input.at, 'paragraph'); if (options?.dryRun) { const canInsert = editor.can().insertParagraphAt?.({ @@ -146,18 +198,35 @@ export function createParagraphAdapter( }; } - const sdBlockId = uuidv4(); - const paraId = generateDocxRandomId(); - - const didApply = insertParagraphAt({ - pos: insertAt, - text: input.text, - sdBlockId, - paraId, - tracked: mode === 'tracked', - }); + const paragraphId = uuidv4(); + let trackedChangeRefs: CreateParagraphSuccessResult['trackedChangeRefs'] | undefined; + + const receipt = executeDomainCommand( + editor, + () => { + const didApply = insertParagraphAt({ + pos: insertAt, + text: input.text, + sdBlockId: paragraphId, + tracked: mode === 'tracked', + }); + if (didApply) { + clearIndexCache(editor); + try { + const paragraph = resolveCreatedBlock(editor, 'paragraph', paragraphId); + if (mode === 'tracked') { + trackedChangeRefs = collectTrackInsertRefsInRange(editor, paragraph.pos, paragraph.end); + } + } catch { + /* will use fallback */ + } + } + return didApply; + }, + { expectedRevision: options?.expectedRevision }, + ); - if (!didApply) { + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, failure: { @@ -167,88 +236,14 @@ export function createParagraphAdapter( }; } - clearIndexCache(editor); - try { - const paragraph = resolveCreatedParagraph(editor, paraId); - const trackedChangeRefs = - mode === 'tracked' ? collectTrackInsertRefsInRange(editor, paragraph.pos, paragraph.end) : undefined; - - // Return the paraId — it's written as w14:paraId during DOCX export and - // survives round-trips, giving callers a stable identity for subsequent - // operations even across separate CLI invocations. - return buildParagraphCreateSuccess(paraId, trackedChangeRefs); - } catch { - // Mutation already applied — contract requires success: true. - // Fall back to the paraId we generated. - return buildParagraphCreateSuccess(paraId); - } + return buildParagraphCreateSuccess(paragraphId, trackedChangeRefs); } // --------------------------------------------------------------------------- -// create.heading +// create.heading wrapper // --------------------------------------------------------------------------- -function resolveHeadingInsertPosition(editor: Editor, input: CreateHeadingInput): number { - const location = input.at ?? { kind: 'documentEnd' }; - - if (location.kind === 'documentStart') return 0; - if (location.kind === 'documentEnd') return editor.state.doc.content.size; - - const index = getBlockIndex(editor); - 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: hasTarget ? location.target : (location as { nodeId: string }).nodeId, - }); - } - - return location.kind === 'before' ? target.pos : target.end; -} - -function resolveCreatedHeading(editor: Editor, paraId: string): BlockCandidate { - const index = getBlockIndex(editor); - // paraId is the primary key in the index, so this is a direct hit. - const resolved = index.byId.get(`heading:${paraId}`); - if (resolved) return resolved; - - // Fallback: scan by paraId attr in case the index was built before the node - // was fully materialised. - const byAttr = index.candidates.find((candidate) => { - if (candidate.nodeType !== 'heading') return false; - const attrs = (candidate.node as { attrs?: { paraId?: unknown } }).attrs; - return typeof attrs?.paraId === 'string' && attrs.paraId === paraId; - }); - if (byAttr) return byAttr; - - throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Created heading could not be resolved after insertion.', { - paraId, - }); -} - -function buildHeadingCreateSuccess( - headingNodeId: string, - trackedChangeRefs?: CreateHeadingSuccessResult['trackedChangeRefs'], -): CreateHeadingSuccessResult { - return { - success: true, - heading: { - kind: 'block', - nodeType: 'heading', - nodeId: headingNodeId, - }, - insertionPoint: { - kind: 'text', - blockId: headingNodeId, - range: { start: 0, end: 0 }, - }, - trackedChangeRefs, - }; -} - -export function createHeadingAdapter( +export function createHeadingWrapper( editor: Editor, input: CreateHeadingInput, options?: MutationOptions, @@ -263,7 +258,7 @@ export function createHeadingAdapter( ensureTrackedCapability(editor, { operation: 'create.heading' }); } - const insertAt = resolveHeadingInsertPosition(editor, input); + const insertAt = resolveCreateInsertPosition(editor, input.at, 'heading'); if (options?.dryRun) { const canInsert = editor.can().insertHeadingAt?.({ @@ -298,19 +293,36 @@ export function createHeadingAdapter( }; } - const sdBlockId = uuidv4(); - const paraId = generateDocxRandomId(); - - const didApply = insertHeadingAt({ - pos: insertAt, - level: input.level, - text: input.text, - sdBlockId, - paraId, - tracked: mode === 'tracked', - }); + const headingId = uuidv4(); + let trackedChangeRefs: CreateHeadingSuccessResult['trackedChangeRefs'] | undefined; + + const receipt = executeDomainCommand( + editor, + () => { + const didApply = insertHeadingAt({ + pos: insertAt, + level: input.level, + text: input.text, + sdBlockId: headingId, + tracked: mode === 'tracked', + }); + if (didApply) { + clearIndexCache(editor); + try { + const heading = resolveCreatedBlock(editor, 'heading', headingId); + if (mode === 'tracked') { + trackedChangeRefs = collectTrackInsertRefsInRange(editor, heading.pos, heading.end); + } + } catch { + /* will use fallback */ + } + } + return didApply; + }, + { expectedRevision: options?.expectedRevision }, + ); - if (!didApply) { + if (receipt.steps[0]?.effect !== 'changed') { return { success: false, failure: { @@ -320,16 +332,5 @@ export function createHeadingAdapter( }; } - clearIndexCache(editor); - try { - const heading = resolveCreatedHeading(editor, paraId); - const trackedChangeRefs = - mode === 'tracked' ? collectTrackInsertRefsInRange(editor, heading.pos, heading.end) : undefined; - - return buildHeadingCreateSuccess(paraId, trackedChangeRefs); - } catch { - // Mutation already applied — contract requires success: true. - // Fall back to the paraId we generated. - return buildHeadingCreateSuccess(paraId); - } + return buildHeadingCreateSuccess(headingId, trackedChangeRefs); } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/errors.ts b/packages/super-editor/src/document-api-adapters/plan-engine/errors.ts new file mode 100644 index 0000000000..cf7a9e9ed7 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/errors.ts @@ -0,0 +1,23 @@ +/** + * Plan engine error types. + * + * All pre-mutation failures throw typed errors with machine-readable codes. + */ + +export class PlanError extends Error { + readonly code: string; + readonly stepId?: string; + readonly details?: unknown; + + constructor(code: string, message: string, stepId?: string, details?: unknown) { + super(message); + this.name = 'PlanError'; + this.code = code; + this.stepId = stepId; + this.details = details; + } +} + +export function planError(code: string, message: string, stepId?: string, details?: unknown): PlanError { + return new PlanError(code, `${code} — ${message}`, stepId, details); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.ts new file mode 100644 index 0000000000..acd71bf104 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.ts @@ -0,0 +1,32 @@ +/** + * Step executor registry — runtime dispatch by op prefix. + * + * Domain executors register here. The plan engine looks up executors by + * matching the step's `op` field against registered prefixes. + */ + +import type { StepExecutor } from './executor-registry.types.js'; + +const registry = new Map(); + +export function registerStepExecutor(opPrefix: string, executor: StepExecutor): void { + if (registry.has(opPrefix)) { + throw new Error(`Step executor already registered for op prefix "${opPrefix}"`); + } + registry.set(opPrefix, executor); +} + +export function getStepExecutor(op: string): StepExecutor | undefined { + // Exact match first, then prefix match + if (registry.has(op)) return registry.get(op); + const prefix = op.split('.')[0]; + return registry.get(prefix); +} + +export function hasStepExecutor(op: string): boolean { + return getStepExecutor(op) !== undefined; +} + +export function clearExecutorRegistry(): void { + registry.clear(); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts new file mode 100644 index 0000000000..6ad06b787e --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor-registry.types.ts @@ -0,0 +1,54 @@ +/** + * Internal executor registry types — PM-aware, lives only in super-editor. + * + * These types define the interface that domain step executors must implement. + * They are NOT exported by document-api. + */ + +import type { Transaction } from 'prosemirror-state'; +import type { Mapping } from 'prosemirror-transform'; +import type { StepOutcome, StepOutcomeData, MutationStep, TextStepResolution } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { CapturedStyle } from './style-resolver.js'; + +export interface CompiledTarget { + stepId: string; + op: string; + blockId: string; + from: number; + to: number; + text: string; + marks: readonly unknown[]; + /** Captured inline style data for the matched range (populated during compile). */ + capturedStyle?: CapturedStyle; +} + +export interface CompileContext { + editor: Editor; + step: MutationStep; +} + +export interface ExecuteContext { + editor: Editor; + tr: Transaction; + mapping: Mapping; + changeMode: 'direct' | 'tracked'; + planGroupId: string; + commandDispatched: boolean; +} + +export interface StepExecutor { + /** Resolve step targets against pre-mutation document state. */ + compile?(ctx: CompileContext): CompiledTarget[]; + /** Validate compiled targets (e.g., overlap detection). */ + validate?(targets: CompiledTarget[], allTargets: CompiledTarget[]): void; + /** Execute the step against the shared transaction. */ + execute(ctx: ExecuteContext, targets: CompiledTarget[], step: MutationStep): StepOutcome; + /** Produce domain-specific outcome data for the receipt. */ + serializeOutcome?(targets: CompiledTarget[], step: MutationStep): StepOutcomeData; +} + +export interface ExecutorRegistration { + opPrefix: string; + executor: StepExecutor; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts new file mode 100644 index 0000000000..9195fd231e --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts @@ -0,0 +1,980 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { TextRewriteStep, StyleApplyStep, AssertStep } from '@superdoc/document-api'; +import type { CompiledTarget } from './executor-registry.types.js'; +import type { CompiledPlan } from './compiler.js'; +import { executeCompiledPlan, runMutationsOnTransaction } from './executor.js'; +import { registerBuiltInExecutors } from './register-executors.js'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const mockedDeps = vi.hoisted(() => ({ + getBlockIndex: vi.fn(), + resolveTextRangeInBlock: vi.fn(), + getRevision: vi.fn(() => '0'), + checkRevision: vi.fn(), + incrementRevision: vi.fn(() => '1'), + captureRunsInRange: vi.fn(), + resolveInlineStyle: vi.fn(() => []), + applyDirectMutationMeta: vi.fn(), + applyTrackedMutationMeta: vi.fn(), + mapBlockNodeType: vi.fn(), +})); + +vi.mock('../helpers/index-cache.js', () => ({ + getBlockIndex: mockedDeps.getBlockIndex, +})); + +vi.mock('../helpers/text-offset-resolver.js', () => ({ + resolveTextRangeInBlock: mockedDeps.resolveTextRangeInBlock, +})); + +vi.mock('./revision-tracker.js', () => ({ + getRevision: mockedDeps.getRevision, + checkRevision: mockedDeps.checkRevision, + incrementRevision: mockedDeps.incrementRevision, +})); + +vi.mock('./style-resolver.js', () => ({ + captureRunsInRange: mockedDeps.captureRunsInRange, + resolveInlineStyle: mockedDeps.resolveInlineStyle, +})); + +vi.mock('../helpers/transaction-meta.js', () => ({ + applyDirectMutationMeta: mockedDeps.applyDirectMutationMeta, + applyTrackedMutationMeta: mockedDeps.applyTrackedMutationMeta, +})); + +vi.mock('../helpers/node-address-resolver.js', () => ({ + mapBlockNodeType: mockedDeps.mapBlockNodeType, + findBlockById: (index: any, address: { nodeType: string; nodeId: string }) => + index.byId.get(`${address.nodeType}:${address.nodeId}`), + isTextBlockCandidate: (candidate: { nodeType: string }) => + candidate.nodeType === 'paragraph' || + candidate.nodeType === 'heading' || + candidate.nodeType === 'listItem' || + candidate.nodeType === 'tableCell', +})); + +// Register built-in executors once +beforeAll(() => { + registerBuiltInExecutors(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('0'); + mockedDeps.incrementRevision.mockReturnValue('1'); + mockedDeps.mapBlockNodeType.mockReturnValue(undefined); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockMark(name: string) { + return { + type: { name, create: () => mockMark(name) }, + attrs: {}, + eq: (other: any) => other.type.name === name, + }; +} + +function makeEditor(text = 'Hello'): { + editor: Editor; + tr: { + replaceWith: ReturnType; + delete: ReturnType; + insert: ReturnType; + addMark: ReturnType; + removeMark: ReturnType; + setMeta: ReturnType; + }; + dispatch: ReturnType; +} { + const tr = { + replaceWith: vi.fn(), + delete: vi.fn(), + insert: vi.fn(), + addMark: vi.fn(), + removeMark: vi.fn(), + setMeta: vi.fn(), + mapping: { map: (pos: number) => pos }, + docChanged: true, + doc: { + resolve: () => ({ marks: () => [] }), + textContent: text, + }, + }; + tr.replaceWith.mockReturnValue(tr); + tr.delete.mockReturnValue(tr); + tr.insert.mockReturnValue(tr); + tr.addMark.mockReturnValue(tr); + tr.removeMark.mockReturnValue(tr); + tr.setMeta.mockReturnValue(tr); + + const boldMark = mockMark('bold'); + const italicMark = mockMark('italic'); + + const dispatch = vi.fn(); + + const editor = { + state: { + doc: { + textContent: text, + textBetween: vi.fn((from: number, to: number) => { + const start = Math.max(0, from - 1); + const end = Math.max(start, to - 1); + return text.slice(start, end); + }), + nodesBetween: vi.fn(), + }, + tr, + schema: { + marks: { + bold: { create: vi.fn(() => boldMark) }, + italic: { create: vi.fn(() => italicMark) }, + underline: { create: vi.fn(() => mockMark('underline')) }, + strike: { create: vi.fn(() => mockMark('strike')) }, + }, + text: vi.fn((t: string, m?: unknown[]) => ({ + type: { name: 'text' }, + text: t, + marks: m ?? [], + })), + }, + }, + dispatch, + } as unknown as Editor; + + return { editor, tr, dispatch }; +} + +function makeTarget(overrides: Partial = {}): CompiledTarget { + return { + stepId: 'step-1', + op: 'text.rewrite', + blockId: 'p1', + from: 0, + to: 5, + text: 'Hello', + marks: [], + ...overrides, + }; +} + +function setupBlockIndex(candidates: Array<{ nodeId: string; pos: number; node: any }>) { + mockedDeps.getBlockIndex.mockReturnValue({ candidates }); +} + +function setupResolveTextRange(from: number, to: number) { + mockedDeps.resolveTextRangeInBlock.mockReturnValue({ from, to }); +} + +// --------------------------------------------------------------------------- +// text.rewrite — style preservation behavioral tests +// --------------------------------------------------------------------------- + +describe('executeCompiledPlan: text.rewrite style behavior', () => { + it('uses capturedStyle from compilation when style is omitted (preserve + majority default)', () => { + const { editor, tr } = makeEditor(); + const boldMark = mockMark('bold'); + const resolvedMarks = [boldMark]; + + // Setup: block index knows about p1 + setupBlockIndex([{ nodeId: 'p1', pos: 0, node: {} }]); + // The resolver maps block-relative [0,5) to absolute PM positions [1,6) + setupResolveTextRange(1, 6); + + // resolveInlineStyle should be called with the capturedStyle and DEFAULT policy + mockedDeps.resolveInlineStyle.mockReturnValue(resolvedMarks); + + const capturedStyle = { + runs: [{ from: 0, to: 5, charCount: 5, marks: [boldMark] }], + isUniform: true, + }; + + const step: TextRewriteStep = { + id: 'step-1', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { replacement: { text: 'World' } }, + // style intentionally omitted + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + targets: [makeTarget({ capturedStyle })], + }, + ], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled); + + // resolveInlineStyle should have been called with captured style + default preserve policy + expect(mockedDeps.resolveInlineStyle).toHaveBeenCalledWith( + editor, + capturedStyle, + { mode: 'preserve', onNonUniform: 'majority' }, + 'step-1', + ); + + // The resolved marks should be passed to schema.text() for the replacement + expect(editor.state.schema.text).toHaveBeenCalledWith('World', resolvedMarks); + + // tr.replaceWith should be called with the text node + expect(tr.replaceWith).toHaveBeenCalled(); + + expect(receipt.success).toBe(true); + expect(receipt.steps[0].effect).toBe('changed'); + }); + + it('uses explicit style policy when provided on text.rewrite', () => { + const { editor, tr } = makeEditor(); + const italicMark = mockMark('italic'); + + setupBlockIndex([{ nodeId: 'p1', pos: 0, node: {} }]); + setupResolveTextRange(1, 6); + mockedDeps.resolveInlineStyle.mockReturnValue([italicMark]); + + const step: TextRewriteStep = { + id: 'step-2', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { + replacement: { text: 'World' }, + style: { + inline: { mode: 'set', setMarks: { italic: true } }, + paragraph: { mode: 'preserve' }, + }, + }, + }; + + const capturedStyle = { + runs: [{ from: 0, to: 5, charCount: 5, marks: [mockMark('bold')] }], + isUniform: true, + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + targets: [makeTarget({ capturedStyle })], + }, + ], + assertSteps: [], + }; + + executeCompiledPlan(editor, compiled); + + // resolveInlineStyle should receive the explicit policy, not the default + expect(mockedDeps.resolveInlineStyle).toHaveBeenCalledWith( + editor, + capturedStyle, + { mode: 'set', setMarks: { italic: true } }, + 'step-2', + ); + }); + + it('falls back to runtime capture when capturedStyle is absent', () => { + const { editor } = makeEditor(); + const boldMark = mockMark('bold'); + + setupBlockIndex([{ nodeId: 'p1', pos: 0, node: {} }]); + setupResolveTextRange(1, 6); + + // captureRunsInRange is the runtime fallback + mockedDeps.captureRunsInRange.mockReturnValue({ + runs: [{ from: 0, to: 5, charCount: 5, marks: [boldMark] }], + isUniform: true, + }); + mockedDeps.resolveInlineStyle.mockReturnValue([boldMark]); + + const step: TextRewriteStep = { + id: 'step-3', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { replacement: { text: 'World' } }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + // No capturedStyle on target — executor must capture at runtime + targets: [makeTarget({ capturedStyle: undefined })], + }, + ], + assertSteps: [], + }; + + executeCompiledPlan(editor, compiled); + + // captureRunsInRange should be called as fallback + expect(mockedDeps.captureRunsInRange).toHaveBeenCalledWith(editor, 0, 0, 5); + }); + + it('produces noop effect when replacement text equals original', () => { + const { editor } = makeEditor(); + + setupBlockIndex([{ nodeId: 'p1', pos: 0, node: {} }]); + setupResolveTextRange(1, 6); + mockedDeps.resolveInlineStyle.mockReturnValue([]); + + const step: TextRewriteStep = { + id: 'step-4', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { replacement: { text: 'Hello' } }, // same text + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + targets: [makeTarget({ text: 'Hello' })], + }, + ], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled); + + // Effect should be noop since text didn't change + expect(receipt.steps[0].effect).toBe('noop'); + }); +}); + +// --------------------------------------------------------------------------- +// text.rewrite — multi-target execution +// --------------------------------------------------------------------------- + +describe('executeCompiledPlan: multi-target rewrite', () => { + it('applies rewrite to multiple targets with independent styles', () => { + const { editor, tr } = makeEditor('Hello World'); + const boldMark = mockMark('bold'); + const italicMark = mockMark('italic'); + + setupBlockIndex([ + { nodeId: 'p1', pos: 0, node: {} }, + { nodeId: 'p2', pos: 10, node: {} }, + ]); + // Resolve targets at different positions + mockedDeps.resolveTextRangeInBlock + .mockReturnValueOnce({ from: 1, to: 6 }) // p1: [0,5) → abs [1,6) + .mockReturnValueOnce({ from: 11, to: 16 }); // p2: [0,5) → abs [11,16) + + mockedDeps.resolveInlineStyle + .mockReturnValueOnce([boldMark]) // first target: bold + .mockReturnValueOnce([italicMark]); // second target: italic + + const step: TextRewriteStep = { + id: 'step-multi', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'all' }, + args: { replacement: { text: 'World' } }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + targets: [ + makeTarget({ + blockId: 'p1', + from: 0, + to: 5, + text: 'Hello', + capturedStyle: { + runs: [{ from: 0, to: 5, charCount: 5, marks: [boldMark] }], + isUniform: true, + }, + }), + makeTarget({ + stepId: 'step-multi', + blockId: 'p2', + from: 0, + to: 5, + text: 'Hello', + capturedStyle: { + runs: [{ from: 0, to: 5, charCount: 5, marks: [italicMark] }], + isUniform: true, + }, + }), + ], + }, + ], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled); + + // Two calls to schema.text — one per target + expect(editor.state.schema.text).toHaveBeenCalledTimes(2); + // Two calls to tr.replaceWith + expect(tr.replaceWith).toHaveBeenCalledTimes(2); + expect(receipt.steps[0].matchCount).toBe(2); + expect(receipt.steps[0].effect).toBe('changed'); + }); +}); + +// --------------------------------------------------------------------------- +// Assert steps — node selector uses Document API type mapping +// --------------------------------------------------------------------------- + +describe('executeAssertStep: node selector uses mapBlockNodeType', () => { + /** Each entry has a node and a position, matching PM descendants(cb(node, pos)). */ + interface PositionedNode { + node: { type: { name: string }; isBlock: boolean; nodeSize: number; attrs?: Record }; + pos: number; + } + + function makeAssertTr(entries: PositionedNode[]) { + return { + mapping: { map: (pos: number) => pos }, + docChanged: false, + setMeta: vi.fn().mockReturnThis(), + doc: { + resolve: () => ({ marks: () => [] }), + textContent: '', + descendants: (fn: (node: any, pos: number) => boolean | void) => { + for (const entry of entries) { + const result = fn(entry.node, entry.pos); + if (result === false) break; + } + }, + }, + }; + } + + /** Shorthand: nodes at sequential positions (nodeSize=10 each, no scoping concern). */ + function makeSimpleAssertTr( + nodes: Array<{ type: { name: string }; isBlock: boolean; attrs?: Record }>, + ) { + return makeAssertTr( + nodes.map((n, i) => ({ + node: { + ...n, + nodeSize: 10, + attrs: { + nodeId: `node-${i}`, + ...(n.attrs ?? {}), + }, + }, + pos: i * 10, + })), + ); + } + + it('counts headings via mapBlockNodeType instead of raw PM type name', () => { + const headingNode = { + type: { name: 'paragraph' }, + isBlock: true, + attrs: { paragraphProperties: { styleId: 'Heading1' } }, + }; + const paragraphNode = { + type: { name: 'paragraph' }, + isBlock: true, + attrs: {}, + }; + + mockedDeps.mapBlockNodeType.mockImplementation((node: any) => { + if (node.attrs?.paragraphProperties?.styleId === 'Heading1') return 'heading'; + return 'paragraph'; + }); + + const { editor } = makeEditor(); + const tr = makeSimpleAssertTr([headingNode, paragraphNode]); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-heading', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'heading' }, + }, + args: { expectCount: 1 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-heading'); + + expect(assertOutcome).toBeDefined(); + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(1); + }); + + it('counts paragraphs excluding heading and listItem nodes', () => { + const headingNode = { type: { name: 'paragraph' }, isBlock: true, attrs: {} }; + const listItemNode = { type: { name: 'paragraph' }, isBlock: true, attrs: {} }; + const plainParagraph = { type: { name: 'paragraph' }, isBlock: true, attrs: {} }; + + mockedDeps.mapBlockNodeType + .mockReturnValueOnce('heading') + .mockReturnValueOnce('listItem') + .mockReturnValueOnce('paragraph'); + + const { editor } = makeEditor(); + const tr = makeSimpleAssertTr([headingNode, listItemNode, plainParagraph]); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-para', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + }, + args: { expectCount: 1 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-para'); + + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(1); + }); + + it('fails assert when heading count does not match expectation', () => { + const node1 = { type: { name: 'paragraph' }, isBlock: true, attrs: {} }; + const node2 = { type: { name: 'paragraph' }, isBlock: true, attrs: {} }; + + mockedDeps.mapBlockNodeType.mockReturnValueOnce('heading').mockReturnValueOnce('heading'); + + const { editor } = makeEditor(); + const tr = makeSimpleAssertTr([node1, node2]); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-one-heading', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'heading' }, + }, + args: { expectCount: 1 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes, assertFailures } = runMutationsOnTransaction(editor, tr, compiled, { + throwOnAssertFailure: false, + }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-one-heading'); + + expect(assertOutcome!.effect).toBe('assert_failed'); + expect((assertOutcome!.data as any).actualCount).toBe(2); + expect(assertFailures).toHaveLength(1); + }); + + it('counts listItem nodes correctly via mapBlockNodeType', () => { + const nodes = [ + { type: { name: 'paragraph' }, isBlock: true, attrs: {} }, + { type: { name: 'paragraph' }, isBlock: true, attrs: {} }, + { type: { name: 'paragraph' }, isBlock: true, attrs: {} }, + ]; + + mockedDeps.mapBlockNodeType + .mockReturnValueOnce('listItem') + .mockReturnValueOnce('listItem') + .mockReturnValueOnce('paragraph'); + + const { editor } = makeEditor(); + const tr = makeSimpleAssertTr(nodes); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-list', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'listItem' }, + }, + args: { expectCount: 2 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-list'); + + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(2); + }); + + // --- within scoping tests --- + + it('scopes node count to descendants of the within block only', () => { + // Layout: table at pos 0 (nodeSize 50) contains 2 paragraphs, + // then another paragraph at pos 50 outside the table. + // + // table (pos=0, size=50, id="tbl-1") + // paragraph (pos=5, size=10) + // paragraph (pos=20, size=10) + // paragraph (pos=50, size=10) ← outside scope + const entries: PositionedNode[] = [ + { node: { type: { name: 'table' }, isBlock: true, nodeSize: 50, attrs: { nodeId: 'tbl-1' } }, pos: 0 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p1' } }, pos: 5 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p2' } }, pos: 20 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p3' } }, pos: 50 }, + ]; + + mockedDeps.mapBlockNodeType.mockImplementation((node: any) => { + if (node.type.name === 'table') return 'table'; + return 'paragraph'; + }); + + const { editor } = makeEditor(); + const tr = makeAssertTr(entries); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-scoped', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'tbl-1' }, + }, + args: { expectCount: 2 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-scoped'); + + // Only p1 and p2 are inside the table (pos 0..50), p3 is outside + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(2); + }); + + it('does not count nodes after the scoped block boundary', () => { + // Same layout but assert expects 3 — should fail because p3 is outside scope + const entries: PositionedNode[] = [ + { node: { type: { name: 'table' }, isBlock: true, nodeSize: 50, attrs: { nodeId: 'tbl-1' } }, pos: 0 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p1' } }, pos: 5 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p2' } }, pos: 20 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p3' } }, pos: 50 }, + ]; + + mockedDeps.mapBlockNodeType.mockImplementation((node: any) => { + if (node.type.name === 'table') return 'table'; + return 'paragraph'; + }); + + const { editor } = makeEditor(); + const tr = makeAssertTr(entries); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-leak', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'tbl-1' }, + }, + args: { expectCount: 3 }, // wrong — only 2 are inside + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-leak'); + + expect(assertOutcome!.effect).toBe('assert_failed'); + expect((assertOutcome!.data as any).actualCount).toBe(2); + }); + + it('returns zero when scoped node is not found in document', () => { + const entries: PositionedNode[] = [ + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p1' } }, pos: 0 }, + ]; + + mockedDeps.mapBlockNodeType.mockReturnValue('paragraph'); + + const { editor } = makeEditor(); + const tr = makeAssertTr(entries); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-missing-scope', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'nonexistent' }, + }, + args: { expectCount: 0 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-missing-scope'); + + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(0); + }); + + // --- Ancestor exclusion --- + + it('includes the scoped container itself when it matches the selector', () => { + // Scoping within a table: the table itself is inside [start, end] and + // therefore included by scopeByRange semantics. + // + // table (pos=0, size=50, id="tbl-1") + // tableRow (pos=1, size=48) ← child block + const entries: PositionedNode[] = [ + { node: { type: { name: 'table' }, isBlock: true, nodeSize: 50, attrs: { nodeId: 'tbl-1' } }, pos: 0 }, + { node: { type: { name: 'tableRow' }, isBlock: true, nodeSize: 48, attrs: { nodeId: 'row-1' } }, pos: 1 }, + ]; + + mockedDeps.mapBlockNodeType.mockImplementation((node: any) => { + if (node.type.name === 'table') return 'table'; + if (node.type.name === 'tableRow') return 'tableRow'; + return 'paragraph'; + }); + + const { editor } = makeEditor(); + const tr = makeAssertTr(entries); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-no-self', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'table' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'tbl-1' }, + }, + args: { expectCount: 1 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-no-self'); + + // The scoped table itself is included. + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(1); + }); + + it('excludes ancestor blocks that overlap the scope range', () => { + // A document-level container wrapping both the scoped block and its siblings. + // + // section (pos=0, size=100) ← ancestor, overlaps scope + // table (pos=5, size=50, id="tbl-1") ← scope target + // paragraph (pos=10, size=10) + // paragraph (pos=60, size=10) ← outside scope + const entries: PositionedNode[] = [ + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 100, attrs: { nodeId: 'section-1' } }, pos: 0 }, + { node: { type: { name: 'table' }, isBlock: true, nodeSize: 50, attrs: { nodeId: 'tbl-1' } }, pos: 5 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p-inside' } }, pos: 10 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'p-outside' } }, pos: 60 }, + ]; + + mockedDeps.mapBlockNodeType.mockImplementation((node: any) => { + if (node.type.name === 'table') return 'table'; + return 'paragraph'; + }); + + const { editor } = makeEditor(); + const tr = makeAssertTr(entries); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-ancestor-excl', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + within: { kind: 'block', nodeType: 'table', nodeId: 'tbl-1' }, + }, + args: { expectCount: 1 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-ancestor-excl'); + + // section-1 at pos=0 is an ancestor (pos < scopeFrom=5), excluded + // p-inside at pos=10 is inside scope [5, 55), counted + // p-outside at pos=60 is outside scope, excluded + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(1); + }); + + // --- Inline within support --- + + it('uses inline within offsets as the scope range', () => { + // Inline within is resolved to an absolute text range in the target block. + // Block candidates must be fully contained in that range. + const entries: PositionedNode[] = [ + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 20, attrs: { paraId: 'p1' } }, pos: 0 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'child-1' } }, pos: 1 }, + { node: { type: { name: 'paragraph' }, isBlock: true, nodeSize: 10, attrs: { paraId: 'outside' } }, pos: 25 }, + ]; + + mockedDeps.mapBlockNodeType.mockReturnValue('paragraph'); + + const { editor } = makeEditor(); + const tr = makeAssertTr(entries); + (editor as any).state.tr = tr; + + const assertStep: AssertStep = { + id: 'assert-inline-within', + op: 'assert', + where: { + by: 'select', + select: { type: 'node', nodeType: 'paragraph' }, + within: { + kind: 'inline', + nodeType: 'commentMark', + anchor: { + start: { blockId: 'p1', offset: 0 }, + end: { blockId: 'p1', offset: 5 }, + }, + } as any, + }, + args: { expectCount: 0 }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [], + assertSteps: [assertStep], + }; + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: false }); + const assertOutcome = stepOutcomes.find((o) => o.stepId === 'assert-inline-within'); + + // Inline range resolves to [1, 6). None of these block nodes are fully + // contained within that range, so count is zero. + expect(assertOutcome!.effect).toBe('assert_passed'); + expect((assertOutcome!.data as any).actualCount).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Revision tracking — reads revision after dispatch (no manual increment) +// --------------------------------------------------------------------------- + +describe('executeCompiledPlan: revision tracking', () => { + it('reads revision after dispatch instead of manually incrementing', () => { + const { editor } = makeEditor(); + + setupBlockIndex([{ nodeId: 'p1', pos: 0, node: {} }]); + setupResolveTextRange(1, 6); + mockedDeps.resolveInlineStyle.mockReturnValue([]); + + // Simulate: getRevision returns '0' initially, then '1' after dispatch + mockedDeps.getRevision + .mockReturnValueOnce('0') // revisionBefore + .mockReturnValueOnce('1'); // revisionAfter (post-dispatch) + + const step: TextRewriteStep = { + id: 'step-rev', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { replacement: { text: 'World' } }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + targets: [makeTarget()], + }, + ], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled); + + // incrementRevision should NOT be called (tracked by transaction listener) + expect(mockedDeps.incrementRevision).not.toHaveBeenCalled(); + + // getRevision should be called twice: once for before, once for after + expect(mockedDeps.getRevision).toHaveBeenCalledTimes(2); + + expect(receipt.revision.before).toBe('0'); + expect(receipt.revision.after).toBe('1'); + }); + + it('returns same revision when no doc changes occur', () => { + const { editor, tr } = makeEditor(); + // No doc changes + (tr as any).docChanged = false; + + setupBlockIndex([{ nodeId: 'p1', pos: 0, node: {} }]); + setupResolveTextRange(1, 6); + mockedDeps.resolveInlineStyle.mockReturnValue([]); + + mockedDeps.getRevision.mockReturnValue('5'); + + const step: TextRewriteStep = { + id: 'step-noop', + op: 'text.rewrite', + where: { by: 'select', select: { type: 'text', pattern: 'Hello' }, require: 'exactlyOne' }, + args: { replacement: { text: 'Hello' } }, + }; + + const compiled: CompiledPlan = { + mutationSteps: [ + { + step, + targets: [makeTarget({ text: 'Hello' })], + }, + ], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled); + + // No dispatch should have occurred + expect(editor.dispatch).not.toHaveBeenCalled(); + // Revision unchanged + expect(receipt.revision.before).toBe('5'); + expect(receipt.revision.after).toBe('5'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts new file mode 100644 index 0000000000..e996a05060 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -0,0 +1,630 @@ +/** + * Atomic execution engine — single-transaction execution with rollback semantics. + * + * Phase 2 (execute): apply compiled mutation steps sequentially in one PM + * transaction, remap positions, evaluate assert steps post-mutation. + */ + +import type { + MutationStep, + AssertStep, + TextRewriteStep, + TextInsertStep, + TextDeleteStep, + StyleApplyStep, + PlanReceipt, + StepOutcome, + StepEffect, + TextStepData, + TextStepResolution, + AssertStepData, + MutationsApplyInput, + SetMarks, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { CompiledStep, CompiledPlan } from './compiler.js'; +import type { CompiledTarget, ExecuteContext } from './executor-registry.types.js'; +import { getStepExecutor } from './executor-registry.js'; +import { planError } from './errors.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; +import { compilePlan } from './compiler.js'; +import { getBlockIndex } from '../helpers/index-cache.js'; +import { resolveTextRangeInBlock } from '../helpers/text-offset-resolver.js'; +import { applyDirectMutationMeta, applyTrackedMutationMeta } from '../helpers/transaction-meta.js'; +import { captureRunsInRange, resolveInlineStyle } from './style-resolver.js'; +import { mapBlockNodeType } from '../helpers/node-address-resolver.js'; +import { resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js'; + +// --------------------------------------------------------------------------- +// Style resolution helpers +// --------------------------------------------------------------------------- + +/** Default inline policy when style is omitted from text.rewrite. */ +const DEFAULT_INLINE_POLICY: import('@superdoc/document-api').InlineStylePolicy = { + mode: 'preserve', + onNonUniform: 'majority', +}; + +function resolveMarks(editor: Editor, target: CompiledTarget, step: MutationStep): readonly unknown[] { + if (step.op !== 'text.rewrite') return []; + const rewriteStep = step as TextRewriteStep; + const policy = rewriteStep.args.style?.inline ?? DEFAULT_INLINE_POLICY; + + // Use captured style data from compilation if available, otherwise capture now + const captured = + target.capturedStyle ?? + captureRunsInRange(editor, toAbsoluteBlockPos(editor, target.blockId), target.from, target.to); + + return resolveInlineStyle(editor, captured, policy, step.id); +} + +function toAbsoluteBlockPos(editor: Editor, blockId: string): number { + const index = getBlockIndex(editor); + const candidate = index.candidates.find((c) => c.nodeId === blockId); + if (!candidate) throw planError('TARGET_NOT_FOUND', `block "${blockId}" not found in style capture fallback`); + return candidate.pos; +} + +function buildMarksFromSetMarks(editor: Editor, setMarks?: SetMarks): readonly unknown[] { + if (!setMarks) return []; + const { schema } = editor.state; + const marks: unknown[] = []; + if (setMarks.bold && schema.marks.bold) marks.push(schema.marks.bold.create()); + if (setMarks.italic && schema.marks.italic) marks.push(schema.marks.italic.create()); + if (setMarks.underline && schema.marks.underline) marks.push(schema.marks.underline.create()); + if (setMarks.strike && schema.marks.strike) marks.push(schema.marks.strike.create()); + return marks; +} + +// --------------------------------------------------------------------------- +// Step executors +// --------------------------------------------------------------------------- + +/** + * Resolves block-relative text offsets to absolute PM positions using the + * node-walking resolver. This accounts for inline run boundaries (marks, + * leaf atoms) so that offsets from the flattened text model map to the + * correct PM positions. + */ +function resolveTextRange( + editor: Editor, + blockId: string, + from: number, + to: number, + stepId?: string, +): { absFrom: number; absTo: number } { + const index = getBlockIndex(editor); + const candidate = index.candidates.find((c) => c.nodeId === blockId); + if (!candidate) throw planError('TARGET_NOT_FOUND', `block "${blockId}" not found`, stepId); + + const resolved = resolveTextRangeInBlock(candidate.node, candidate.pos, { start: from, end: to }); + if (!resolved) { + throw planError('INVALID_INPUT', `text offset [${from}, ${to}) out of range in block "${blockId}"`, stepId); + } + return { absFrom: resolved.from, absTo: resolved.to }; +} + +/** + * Resolves a single block-relative text offset to an absolute PM position. + * Used for insertion points where only one position is needed. + */ +function resolveTextOffset(editor: Editor, blockId: string, offset: number, stepId?: string): number { + return resolveTextRange(editor, blockId, offset, offset, stepId).absFrom; +} + +/** + * Returns the absolute PM position of a block node (not a text offset). + * Used by create steps that insert relative to block boundaries. + */ +function toAbsoluteBlockInsertPos(editor: Editor, blockId: string, offset: number, stepId?: string): number { + const index = getBlockIndex(editor); + const candidate = index.candidates.find((c) => c.nodeId === blockId); + if (!candidate) throw planError('TARGET_NOT_FOUND', `block "${blockId}" not found`, stepId); + return candidate.pos + offset; +} + +export function executeTextRewrite( + editor: Editor, + tr: any, + target: CompiledTarget, + step: TextRewriteStep, + mapping: any, +): { changed: boolean } { + const range = resolveTextRange(editor, target.blockId, target.from, target.to, step.id); + const absFrom = mapping.map(range.absFrom); + const absTo = mapping.map(range.absTo); + + const replacementText = step.args.replacement.text; + const marks = resolveMarks(editor, target, step); + + const textNode = editor.state.schema.text(replacementText, marks as any); + tr.replaceWith(absFrom, absTo, textNode); + + return { changed: replacementText !== target.text }; +} + +export function executeTextInsert( + editor: Editor, + tr: any, + target: CompiledTarget, + step: TextInsertStep, + mapping: any, +): { changed: boolean } { + const position = step.args.position; + const offset = position === 'before' ? target.from : target.to; + const absPos = mapping.map(resolveTextOffset(editor, target.blockId, offset, step.id)); + + const text = step.args.content.text; + if (!text) return { changed: false }; + + // Resolve insert style + let marks: readonly unknown[] = []; + const stylePolicy = step.args.style?.inline; + if (stylePolicy) { + if (stylePolicy.mode === 'set') { + marks = buildMarksFromSetMarks(editor, stylePolicy.setMarks); + } else if (stylePolicy.mode === 'clear') { + marks = []; + } else { + // 'inherit' — use marks at insertion point + const resolvedPos = tr.doc.resolve(absPos); + marks = resolvedPos.marks(); + } + } else { + // Default: inherit + const resolvedPos = tr.doc.resolve(absPos); + marks = resolvedPos.marks(); + } + + const textNode = editor.state.schema.text(text, marks as any); + tr.insert(absPos, textNode); + + return { changed: true }; +} + +export function executeTextDelete( + editor: Editor, + tr: any, + target: CompiledTarget, + _step: TextDeleteStep, + mapping: any, +): { changed: boolean } { + const range = resolveTextRange(editor, target.blockId, target.from, target.to, _step.id); + const absFrom = mapping.map(range.absFrom); + const absTo = mapping.map(range.absTo); + + if (absFrom === absTo) return { changed: false }; + + tr.delete(absFrom, absTo); + return { changed: true }; +} + +export function executeStyleApply( + editor: Editor, + tr: any, + target: CompiledTarget, + step: StyleApplyStep, + mapping: any, +): { changed: boolean } { + const range = resolveTextRange(editor, target.blockId, target.from, target.to, step.id); + const absFrom = mapping.map(range.absFrom); + const absTo = mapping.map(range.absTo); + const { schema } = editor.state; + let changed = false; + + const markEntries: Array<[string, boolean | undefined, any]> = [ + ['bold', step.args.marks.bold, schema.marks.bold], + ['italic', step.args.marks.italic, schema.marks.italic], + ['underline', step.args.marks.underline, schema.marks.underline], + ['strike', step.args.marks.strike, schema.marks.strike], + ]; + + for (const [, value, markType] of markEntries) { + if (value === undefined || !markType) continue; + if (value) { + tr.addMark(absFrom, absTo, markType.create()); + } else { + tr.removeMark(absFrom, absTo, markType); + } + changed = true; + } + + return { changed }; +} + +/** + * Counts text pattern matches within a given text string. + */ +function countTextMatches(text: string, pattern: string, mode: string, caseSensitive: boolean): number { + if (mode === 'regex') { + if (pattern.length > 1024) return 0; + const flags = caseSensitive ? 'g' : 'gi'; + try { + const regex = new RegExp(pattern, flags); + const matches = text.match(regex); + return matches ? matches.length : 0; + } catch { + return 0; + } + } + + const searchText = caseSensitive ? text : text.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + let count = 0; + let pos = 0; + while (true) { + const idx = searchText.indexOf(searchPattern, pos); + if (idx === -1) break; + count++; + pos = idx + 1; + } + return count; +} + +type AssertIndexCandidate = { + node: any; + pos: number; + end: number; + nodeType: string; + nodeId: string; +}; + +type AssertIndex = { + candidates: AssertIndexCandidate[]; + byId: Map; +}; + +function asId(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function resolveAssertNodeId(node: any, mappedType: string): string | undefined { + const attrs = node.attrs ?? {}; + // Paragraph-like blocks use paraId as canonical identity, with sdBlockId fallback. + if (mappedType === 'paragraph' || mappedType === 'heading' || mappedType === 'listItem') { + return asId(attrs.paraId) ?? asId(attrs.sdBlockId) ?? asId(attrs.nodeId); + } + return ( + asId(attrs.blockId) ?? + asId(attrs.id) ?? + asId(attrs.paraId) ?? + asId(attrs.uuid) ?? + asId(attrs.sdBlockId) ?? + asId(attrs.nodeId) + ); +} + +function buildAssertIndex(doc: any): AssertIndex { + const candidates: AssertIndexCandidate[] = []; + const byId = new Map(); + const ambiguous = new Set(); + + function registerKey(key: string, candidate: AssertIndexCandidate): void { + if (byId.has(key)) { + ambiguous.add(key); + byId.delete(key); + return; + } + if (!ambiguous.has(key)) { + byId.set(key, candidate); + } + } + + doc.descendants((node: any, pos: number) => { + const nodeType = mapBlockNodeType(node); + if (!nodeType) return true; + const nodeId = resolveAssertNodeId(node, nodeType); + if (!nodeId) return true; + + const candidate: AssertIndexCandidate = { + node, + pos, + end: pos + node.nodeSize, + nodeType, + nodeId, + }; + + candidates.push(candidate); + registerKey(`${nodeType}:${nodeId}`, candidate); + + // Preserve alias resolution for paragraph-like blocks. + if (nodeType === 'paragraph' || nodeType === 'heading' || nodeType === 'listItem') { + const aliasId = asId(node.attrs?.sdBlockId); + if (aliasId && aliasId !== nodeId) { + registerKey(`${nodeType}:${aliasId}`, candidate); + } + } + + return true; + }); + + return { candidates, byId }; +} + +function resolveAssertScope( + index: AssertIndex, + select: AssertStep['where']['select'], + within: AssertStep['where']['within'], +): { ok: true; range: { start: number; end: number } | undefined } | { ok: false } { + if (!within) return { ok: true, range: undefined }; + const scope = resolveWithinScope(index as any, { select, within } as any, []); + if (!scope.ok) return { ok: false }; + return { ok: true, range: scope.range }; +} + +/** + * Count block nodes matching `nodeType` in the document, optionally scoped + * to descendants of a specific block node. + * + * Uses the same scope resolution and range semantics as the query engine + * (`resolveWithinScope` + `scopeByRange`) so assert counts match query counts. + */ +function countNodeMatchesInDoc( + doc: any, + selector: Exclude, + within: AssertStep['where']['within'], +): number { + const index = buildAssertIndex(doc); + const scope = resolveAssertScope(index, selector, within); + if (!scope.ok) return 0; + + // Node assert currently operates on block selectors only. + if (selector.kind && selector.kind !== 'block') return 0; + + const scoped = scopeByRange(index.candidates, scope.range); + let count = 0; + for (const candidate of scoped) { + if (selector.nodeType && candidate.nodeType !== selector.nodeType) continue; + count++; + } + return count; +} + +function resolveScopedTextForAssert( + doc: any, + selector: Extract, + within: AssertStep['where']['within'], +): string { + const index = buildAssertIndex(doc); + const scope = resolveAssertScope(index, selector, within); + if (!scope.ok) return ''; + if (!scope.range) return doc.textContent; + + return doc.textBetween(scope.range.start, scope.range.end, '\n', '\ufffc'); +} + +function executeAssertStep(_editor: Editor, tr: any, step: AssertStep): { passed: boolean; actualCount: number } { + // Evaluate against post-mutation state (the transaction's doc) + const where = step.where; + if (where.by !== 'select') { + throw planError('INVALID_INPUT', `assert steps only support by: 'select'`, step.id); + } + + const selector = where.select; + if (selector.type !== 'text') { + // For node selectors, use Document API node type mapping (e.g., headings + // and listItems are PM paragraph nodes with specific attributes). + const count = countNodeMatchesInDoc(tr.doc, selector, where.within); + return { passed: count === step.args.expectCount, actualCount: count }; + } + + const text = resolveScopedTextForAssert(tr.doc, selector, where.within); + + const pattern = selector.pattern; + const mode = selector.mode ?? 'contains'; + const caseSensitive = selector.caseSensitive ?? false; + + const count = countTextMatches(text, pattern, mode, caseSensitive); + return { passed: count === step.args.expectCount, actualCount: count }; +} + +// --------------------------------------------------------------------------- +// Domain step executors — create operations +// --------------------------------------------------------------------------- + +export function executeCreateStep( + editor: Editor, + tr: any, + step: MutationStep, + targets: CompiledTarget[], + mapping: any, +): StepOutcome { + const target = targets[0]; + if (!target) { + throw planError('INVALID_INPUT', `${step.op} step requires exactly one target`, step.id); + } + + const args = step.args as Record; + const pos = mapping.map(toAbsoluteBlockInsertPos(editor, target.blockId, target.from, step.id)); + const paragraphType = editor.state.schema?.nodes?.paragraph; + + if (!paragraphType) { + throw planError('INVALID_INPUT', 'paragraph node type not in schema', step.id); + } + + const sdBlockId = args.sdBlockId as string | undefined; + const text = (args.text as string) ?? ''; + const textNode = text.length > 0 ? editor.state.schema.text(text) : null; + + let attrs: Record | undefined; + if (step.op === 'create.heading') { + const level = (args.level as number) ?? 1; + attrs = { + ...(sdBlockId ? { sdBlockId } : undefined), + paragraphProperties: { styleId: `Heading${level}` }, + }; + } else { + attrs = sdBlockId ? { sdBlockId } : undefined; + } + + const node = + paragraphType.createAndFill(attrs, textNode ?? undefined) ?? + paragraphType.create(attrs, textNode ? [textNode] : undefined); + + if (!node) { + throw planError('INVALID_INPUT', `could not create ${step.op} node`, step.id); + } + + tr.insert(pos, node); + + return { + stepId: step.id, + op: step.op, + effect: 'changed', + matchCount: 1, + data: { domain: 'text', resolutions: [] } as TextStepData, + }; +} + +// --------------------------------------------------------------------------- +// Shared execution core — used by both executePlan and previewPlan +// --------------------------------------------------------------------------- + +/** + * Execute compiled mutation steps on a transaction and evaluate asserts. + * Does NOT dispatch the transaction — the caller decides whether to dispatch. + * + * @returns Step outcomes for each mutation and assert step. + * @throws PlanError if an assert step fails (PRECONDITION_FAILED). + */ +export function runMutationsOnTransaction( + editor: Editor, + tr: any, + compiled: CompiledPlan, + options: { throwOnAssertFailure: boolean }, +): { + stepOutcomes: StepOutcome[]; + assertFailures: Array<{ stepId: string; expectedCount: number; actualCount: number }>; + commandDispatched: boolean; +} { + const mapping = tr.mapping; + const stepOutcomes: StepOutcome[] = []; + const assertFailures: Array<{ stepId: string; expectedCount: number; actualCount: number }> = []; + + const ctx: ExecuteContext = { + editor, + tr, + mapping, + changeMode: 'direct', + planGroupId: '', + commandDispatched: false, + }; + + // Execute mutation steps sequentially via registry dispatch + for (const compiledStep of compiled.mutationSteps) { + const { step, targets } = compiledStep; + const executor = getStepExecutor(step.op); + if (!executor) { + throw planError('INVALID_INPUT', `unsupported step op "${step.op}"`, step.id); + } + const outcome = executor.execute(ctx, targets, step); + stepOutcomes.push(outcome); + } + + // Evaluate assert steps against post-mutation state + for (const assertStep of compiled.assertSteps) { + const { passed, actualCount } = executeAssertStep(editor, tr, assertStep); + + if (!passed) { + if (options.throwOnAssertFailure) { + throw planError( + 'PRECONDITION_FAILED', + `assert "${assertStep.id}" expected ${assertStep.args.expectCount} matches but found ${actualCount}`, + assertStep.id, + { expectedCount: assertStep.args.expectCount, actualCount }, + ); + } + assertFailures.push({ stepId: assertStep.id, expectedCount: assertStep.args.expectCount, actualCount }); + } + + const data: AssertStepData = { + domain: 'assert', + expectedCount: assertStep.args.expectCount, + actualCount, + }; + + stepOutcomes.push({ + stepId: assertStep.id, + op: 'assert', + effect: passed ? 'assert_passed' : 'assert_failed', + matchCount: actualCount, + data, + }); + } + + return { stepOutcomes, assertFailures, commandDispatched: ctx.commandDispatched }; +} + +// --------------------------------------------------------------------------- +// Shared post-compilation execution — used by executePlan and convenience wrappers +// --------------------------------------------------------------------------- + +export interface ExecuteCompiledOptions { + changeMode?: 'direct' | 'tracked'; + expectedRevision?: string; +} + +/** + * Execute a pre-compiled plan: build transaction, run mutations, dispatch. + * + * This is the single execution path for all document mutations. Both + * `executePlan` (selector-compiled plans) and convenience wrappers + * (pre-resolved targets) converge here. + */ +export function executeCompiledPlan( + editor: Editor, + compiled: CompiledPlan, + options: ExecuteCompiledOptions = {}, +): PlanReceipt { + const startTime = performance.now(); + const revisionBefore = getRevision(editor); + + checkRevision(editor, options.expectedRevision); + + const tr = editor.state.tr; + const changeMode = options.changeMode ?? 'direct'; + + if (changeMode === 'tracked') { + applyTrackedMutationMeta(tr); + } else { + applyDirectMutationMeta(tr); + } + + const { stepOutcomes } = runMutationsOnTransaction(editor, tr, compiled, { throwOnAssertFailure: true }); + + if (tr.docChanged) { + editor.dispatch(tr); + } + + // Revision is advanced by the transaction listener (trackRevisions), + // so we read the current value after dispatch completes. + const revisionAfter = getRevision(editor); + const totalMs = performance.now() - startTime; + + return { + success: true, + revision: { + before: revisionBefore, + after: revisionAfter, + }, + steps: stepOutcomes, + timing: { totalMs }, + }; +} + +// --------------------------------------------------------------------------- +// Main execution entry point (selector-based plans) +// --------------------------------------------------------------------------- + +export function executePlan(editor: Editor, input: MutationsApplyInput): PlanReceipt { + if (!input.steps?.length) { + throw planError('INVALID_INPUT', 'plan must contain at least one step'); + } + + const compiled = compilePlan(editor, input.steps); + + return executeCompiledPlan(editor, compiled, { + changeMode: input.changeMode ?? 'direct', + expectedRevision: input.expectedRevision, + }); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/index.ts b/packages/super-editor/src/document-api-adapters/plan-engine/index.ts new file mode 100644 index 0000000000..ae9968b7e0 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/index.ts @@ -0,0 +1,21 @@ +/** + * Plan engine — barrel export for all plan-engine modules. + */ + +export { executePlan, executeCompiledPlan } from './executor.js'; +export type { ExecuteCompiledOptions } from './executor.js'; +export { previewPlan } from './preview.js'; +export { queryMatchAdapter } from './query-match-adapter.js'; +export { getRevision, initRevision, incrementRevision, checkRevision, trackRevisions } from './revision-tracker.js'; +export { registerStepExecutor, getStepExecutor, hasStepExecutor, clearExecutorRegistry } from './executor-registry.js'; +export { planError, PlanError } from './errors.js'; +export { captureRunsInRange, resolveInlineStyle } from './style-resolver.js'; +export type { CapturedRun, CapturedStyle } from './style-resolver.js'; +export type { CompiledTarget, StepExecutor, CompileContext, ExecuteContext } from './executor-registry.types.js'; +export { + writeWrapper, + formatBoldWrapper, + formatItalicWrapper, + formatUnderlineWrapper, + formatStrikethroughWrapper, +} from './plan-wrappers.js'; diff --git a/packages/super-editor/src/document-api-adapters/lists-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts similarity index 74% rename from packages/super-editor/src/document-api-adapters/lists-adapter.ts rename to packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts index 5dffc7e641..1a408c7ceb 100644 --- a/packages/super-editor/src/document-api-adapters/lists-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/lists-wrappers.ts @@ -1,5 +1,14 @@ +/** + * Lists convenience wrappers — bridge lists operations to the plan engine's + * revision management and execution path. + * + * Read operations (list, get) are pure queries. + * Mutating operations (insert, setType, indent, outdent, restart, exit) + * delegate to editor commands with plan-engine revision tracking. + */ + import { v4 as uuidv4 } from 'uuid'; -import type { Editor } from '../core/Editor.js'; +import type { Editor } from '../../core/Editor.js'; import type { ListInsertInput, ListItemInfo, @@ -13,17 +22,22 @@ import type { ListTargetInput, MutationOptions, } from '@superdoc/document-api'; -import { DocumentApiAdapterError } from './errors.js'; -import { requireEditorCommand, ensureTrackedCapability, rejectTrackedMode } from './helpers/mutation-helpers.js'; -import { clearIndexCache, getBlockIndex } from './helpers/index-cache.js'; -import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { requireEditorCommand, ensureTrackedCapability, rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { executeDomainCommand } from './plan-wrappers.js'; +import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js'; +import { collectTrackInsertRefsInRange } from '../helpers/tracked-change-refs.js'; import { listItemProjectionToInfo, listListItems, resolveListItem, type ListItemProjection, -} from './helpers/list-item-resolver.js'; -import { ListHelpers } from '../core/helpers/list-numbering-helpers.js'; +} from '../helpers/list-item-resolver.js'; +import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; + +// --------------------------------------------------------------------------- +// Command types +// --------------------------------------------------------------------------- type InsertListItemAtCommand = (options: { pos: number; @@ -37,6 +51,10 @@ type SetListTypeAtCommand = (options: { pos: number; kind: 'ordered' | 'bullet' type ExitListItemAtCommand = (options: { pos: number }) => boolean; type SetTextSelectionCommand = (options: { from: number; to?: number }) => boolean; +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + function toListsFailure(code: 'NO_OP' | 'INVALID_TARGET', message: string, details?: unknown) { return { success: false as const, failure: { code, message, details } }; } @@ -119,14 +137,11 @@ function withListTarget(editor: Editor, input: ListTargetInput): ListItemProject 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.`, { @@ -138,16 +153,24 @@ function withListTarget(editor: Editor, input: ListTargetInput): ListItemProject throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'List item target was not found.', { nodeId }); } -export function listsListAdapter(editor: Editor, query?: ListsListQuery): ListsListResult { +// --------------------------------------------------------------------------- +// Read operations (queries) +// --------------------------------------------------------------------------- + +export function listsListWrapper(editor: Editor, query?: ListsListQuery): ListsListResult { return listListItems(editor, query); } -export function listsGetAdapter(editor: Editor, input: ListsGetInput): ListItemInfo { +export function listsGetWrapper(editor: Editor, input: ListsGetInput): ListItemInfo { const item = resolveListItem(editor, input.address); return listItemProjectionToInfo(item); } -export function listsInsertAdapter( +// --------------------------------------------------------------------------- +// Mutating operations (wrappers) +// --------------------------------------------------------------------------- + +export function listsInsertWrapper( editor: Editor, input: ListInsertInput, options?: MutationOptions, @@ -175,29 +198,39 @@ export function listsInsertAdapter( } const createdId = uuidv4(); - const didApply = insertListItemAt({ - pos: target.candidate.pos, - position: input.position, - text: input.text ?? '', - sdBlockId: createdId, - tracked: mode === 'tracked', - }); + let created: ListItemProjection | null = null; + + const receipt = executeDomainCommand( + editor, + () => { + const didApply = insertListItemAt({ + pos: target.candidate.pos, + position: input.position, + text: input.text ?? '', + sdBlockId: createdId, + tracked: mode === 'tracked', + }); + if (didApply) { + clearIndexCache(editor); + try { + created = resolveInsertedListItem(editor, createdId); + } catch { + /* fallback below */ + } + } + return didApply; + }, + { expectedRevision: options?.expectedRevision }, + ); - if (!didApply) { + if (receipt.steps[0]?.effect !== 'changed') { return toListsFailure('INVALID_TARGET', 'List item insertion could not be applied at the requested target.', { target: input.target, position: input.position, }); } - clearIndexCache(editor); - - let created: ListItemProjection; - try { - created = resolveInsertedListItem(editor, createdId); - } catch { - // Mutation already applied — contract requires success: true. - // Fall back to the generated ID we assigned to the command. + if (!created) { return { success: true, item: { kind: 'block', nodeType: 'listItem', nodeId: createdId }, @@ -224,7 +257,7 @@ export function listsInsertAdapter( }; } -export function listsSetTypeAdapter( +export function listsSetTypeWrapper( editor: Editor, input: ListSetTypeInput, options?: MutationOptions, @@ -247,25 +280,21 @@ export function listsSetTypeAdapter( return { success: true, item: target.address }; } - const didApply = setListTypeAt({ - pos: target.candidate.pos, - kind: input.kind, + const receipt = executeDomainCommand(editor, () => setListTypeAt({ pos: target.candidate.pos, kind: input.kind }), { + expectedRevision: options?.expectedRevision, }); - if (!didApply) { + if (receipt.steps[0]?.effect !== 'changed') { return toListsFailure('INVALID_TARGET', 'List type conversion could not be applied.', { target: input.target, kind: input.kind, }); } - return { - success: true, - item: target.address, - }; + return { success: true, item: target.address }; } -export function listsIndentAdapter( +export function listsIndentWrapper( editor: Editor, input: ListTargetInput, options?: MutationOptions, @@ -291,18 +320,18 @@ export function listsIndentAdapter( }); } - const didApply = increaseListIndent(); - if (!didApply) { + const receipt = executeDomainCommand(editor, () => increaseListIndent(), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { return toListsFailure('INVALID_TARGET', 'List indentation could not be applied.', { target: input.target }); } - return { - success: true, - item: target.address, - }; + return { success: true, item: target.address }; } -export function listsOutdentAdapter( +export function listsOutdentWrapper( editor: Editor, input: ListTargetInput, options?: MutationOptions, @@ -328,18 +357,18 @@ export function listsOutdentAdapter( }); } - const didApply = decreaseListIndent(); - if (!didApply) { + const receipt = executeDomainCommand(editor, () => decreaseListIndent(), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { return toListsFailure('INVALID_TARGET', 'List outdent could not be applied.', { target: input.target }); } - return { - success: true, - item: target.address, - }; + return { success: true, item: target.address }; } -export function listsRestartAdapter( +export function listsRestartWrapper( editor: Editor, input: ListTargetInput, options?: MutationOptions, @@ -372,18 +401,18 @@ export function listsRestartAdapter( }); } - const didApply = restartNumbering(); - if (!didApply) { + const receipt = executeDomainCommand(editor, () => restartNumbering(), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { return toListsFailure('INVALID_TARGET', 'List restart could not be applied.', { target: input.target }); } - return { - success: true, - item: target.address, - }; + return { success: true, item: target.address }; } -export function listsExitAdapter(editor: Editor, input: ListTargetInput, options?: MutationOptions): ListsExitResult { +export function listsExitWrapper(editor: Editor, input: ListTargetInput, options?: MutationOptions): ListsExitResult { rejectTrackedMode('lists.exit', options); const target = withListTarget(editor, input); @@ -403,8 +432,11 @@ export function listsExitAdapter(editor: Editor, input: ListTargetInput, options }; } - const didApply = exitListItemAt({ pos: target.candidate.pos }); - if (!didApply) { + const receipt = executeDomainCommand(editor, () => exitListItemAt({ pos: target.candidate.pos }), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { return toListsFailure('INVALID_TARGET', 'List exit could not be applied.', { target: input.target }); } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts new file mode 100644 index 0000000000..d87184f6a0 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -0,0 +1,475 @@ +/** + * Convenience wrappers — bridge the positional TextAddress-based API to + * the plan engine's single execution path. + * + * Each wrapper builds a pre-resolved CompiledPlan and delegates to + * executeCompiledPlan, so all mutations flow through the same execution core. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { + MutationOptions, + MutationStep, + TextAddress, + TextMutationReceipt, + TextMutationResolution, + WriteRequest, + FormatBoldInput, + FormatItalicInput, + FormatUnderlineInput, + FormatStrikethroughInput, + SetMarks, + PlanReceipt, + ReceiptFailure, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import type { CompiledPlan, CompiledStep } from './compiler.js'; +import type { CompiledTarget } from './executor-registry.types.js'; +import { executeCompiledPlan } from './executor.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from '../helpers/adapter-utils.js'; +import { buildTextMutationResolution, readTextAtResolvedRange } from '../helpers/text-mutation-resolution.js'; +import { ensureTrackedCapability, requireSchemaMark } from '../helpers/mutation-helpers.js'; +import { TrackFormatMarkName } from '../../extensions/track-changes/constants.js'; + +// --------------------------------------------------------------------------- +// Locator normalization (same validation as the old adapters) +// --------------------------------------------------------------------------- + +function normalizeWriteLocator(request: WriteRequest): WriteRequest { + if (request.kind === 'insert') { + const hasBlockId = request.blockId !== undefined; + const hasOffset = request.offset !== undefined; + + if (hasOffset && request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with offset on insert request.', { + fields: ['target', 'offset'], + }); + } + if (hasOffset && !hasBlockId) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'offset requires blockId on insert request.', { + fields: ['offset', 'blockId'], + }); + } + if (!hasBlockId) return request; + if (request.target) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'Cannot combine target with blockId on insert request.', { + fields: ['target', 'blockId'], + }); + } + + const effectiveOffset = request.offset ?? 0; + const target: TextAddress = { + kind: 'text', + blockId: request.blockId!, + range: { start: effectiveOffset, end: effectiveOffset }, + }; + return { kind: 'insert', target, text: request.text }; + } + + if (request.kind === 'replace' || request.kind === 'delete') { + const hasBlockId = request.blockId !== undefined; + const hasStart = request.start !== undefined; + const hasEnd = request.end !== undefined; + + 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'] }, + ); + } + 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; + 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! }, + }; + if (request.kind === 'replace') return { kind: 'replace', target, text: request.text }; + return { kind: 'delete', target, text: '' }; + } + + return request; +} + +type FormatOperationInput = { target?: TextAddress; blockId?: string; start?: number; end?: number }; + +function normalizeFormatLocator(input: FormatOperationInput): FormatOperationInput { + const hasBlockId = input.blockId !== undefined; + const hasStart = input.start !== undefined; + const hasEnd = input.end !== undefined; + + 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'] }, + ); + } + if (!hasBlockId && (hasStart || hasEnd)) { + throw new DocumentApiAdapterError('INVALID_TARGET', 'start/end require blockId on format request.', { + fields: ['blockId', 'start', 'end'], + }); + } + if (!hasBlockId) return input; + 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 }; +} + +// --------------------------------------------------------------------------- +// Resolution helpers +// --------------------------------------------------------------------------- + +interface ResolvedWrite { + requestedTarget?: TextAddress; + effectiveTarget: TextAddress; + range: ResolvedTextTarget; + resolution: TextMutationResolution; +} + +function resolveWriteTarget(editor: Editor, request: WriteRequest): ResolvedWrite | null { + const requestedTarget = request.target; + + if (request.kind === 'insert' && !request.target) { + const fallback = resolveDefaultInsertTarget(editor); + if (!fallback) return null; + const text = readTextAtResolvedRange(editor, fallback.range); + return { + requestedTarget, + effectiveTarget: fallback.target, + range: fallback.range, + resolution: buildTextMutationResolution({ + requestedTarget, + target: fallback.target, + range: fallback.range, + text, + }), + }; + } + + const target = request.target; + if (!target) return null; + + const range = resolveTextTarget(editor, target); + if (!range) return null; + + const text = readTextAtResolvedRange(editor, range); + return { + requestedTarget, + effectiveTarget: target, + range, + resolution: buildTextMutationResolution({ requestedTarget, target, range, text }), + }; +} + +// --------------------------------------------------------------------------- +// Receipt mapping: PlanReceipt → TextMutationReceipt +// --------------------------------------------------------------------------- + +function mapPlanReceiptToTextReceipt(_receipt: PlanReceipt, resolution: TextMutationResolution): TextMutationReceipt { + return { success: true, resolution }; +} + +// --------------------------------------------------------------------------- +// Stub step builder — wrapper steps bypass compilation, so the `where` clause +// is never evaluated. We build a structurally-valid MutationStep for the type +// system; only `id`, `op`, and `args` matter at execution time. +// --------------------------------------------------------------------------- + +export const STUB_WHERE = { + by: 'select' as const, + select: { type: 'text' as const, pattern: '', mode: 'exact' as const }, + require: 'exactlyOne' as const, +}; + +// --------------------------------------------------------------------------- +// Target → CompiledTarget +// --------------------------------------------------------------------------- + +function toCompiledTarget(stepId: string, op: string, resolved: ResolvedWrite): CompiledTarget { + return { + stepId, + op, + blockId: resolved.effectiveTarget.blockId, + from: resolved.effectiveTarget.range.start, + to: resolved.effectiveTarget.range.end, + text: resolved.resolution.text, + marks: [], + }; +} + +// --------------------------------------------------------------------------- +// Domain command execution helper +// --------------------------------------------------------------------------- + +/** + * Execute a domain command through the plan engine. Builds a single-step + * CompiledPlan with a `domain.command` executor that delegates to the + * provided handler closure. + * + * This is the bridge for all domain wrappers (create, lists, comments, + * trackChanges) to route their mutations through executeCompiledPlan. + */ +export function executeDomainCommand( + editor: Editor, + handler: () => boolean, + options?: { expectedRevision?: string }, +): PlanReceipt { + const stepId = uuidv4(); + const step = { + id: stepId, + op: 'domain.command', + where: STUB_WHERE, + args: {}, + _handler: handler, + } as unknown as MutationStep; + const compiled: CompiledPlan = { mutationSteps: [{ step, targets: [] }], assertSteps: [] }; + return executeCompiledPlan(editor, compiled, { expectedRevision: options?.expectedRevision }); +} + +// --------------------------------------------------------------------------- +// Write wrappers (insert / replace / delete) +// --------------------------------------------------------------------------- + +const FORMAT_OPERATION_LABEL = { + 'format.bold': 'Bold', + 'format.italic': 'Italic', + 'format.underline': 'Underline', + 'format.strikethrough': 'Strikethrough', +} as const; + +function validateWriteRequest(request: WriteRequest, resolved: ResolvedWrite): ReceiptFailure | null { + if (request.kind === 'insert') { + if (!request.text) return { code: 'INVALID_TARGET', message: 'Insert operations require non-empty text.' }; + if (resolved.range.from !== resolved.range.to) { + return { code: 'INVALID_TARGET', message: 'Insert operations require a collapsed target range.' }; + } + return null; + } + if (request.kind === 'replace') { + if (request.text == null || request.text.length === 0) { + return { code: 'INVALID_TARGET', message: 'Replace operations require non-empty text. Use delete for removals.' }; + } + if (resolved.resolution.text === request.text) { + return { code: 'NO_OP', message: 'Replace operation produced no change.' }; + } + return null; + } + // delete + if (resolved.range.from === resolved.range.to) { + return { code: 'NO_OP', message: 'Delete operation produced no change for a collapsed range.' }; + } + return null; +} + +export function writeWrapper(editor: Editor, request: WriteRequest, options?: MutationOptions): TextMutationReceipt { + const normalizedRequest = normalizeWriteLocator(request); + + const resolved = resolveWriteTarget(editor, normalizedRequest); + if (!resolved) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', 'Mutation target could not be resolved.', { + target: normalizedRequest.target, + }); + } + + const validationFailure = validateWriteRequest(normalizedRequest, resolved); + if (validationFailure) { + return { success: false, resolution: resolved.resolution, failure: validationFailure }; + } + + const mode = options?.changeMode ?? 'direct'; + if (mode === 'tracked') ensureTrackedCapability(editor, { operation: 'write' }); + + if (options?.dryRun) { + return { success: true, resolution: resolved.resolution }; + } + + // Build single-step compiled plan with pre-resolved target. + // The step's `where` clause is a structural stub — it is never evaluated + // because targets are already resolved. + const stepId = uuidv4(); + let op: string; + let stepDef: { id: string; op: string; where: typeof STUB_WHERE; args: unknown }; + + if (normalizedRequest.kind === 'insert') { + op = 'text.insert'; + stepDef = { + id: stepId, + op, + where: STUB_WHERE, + args: { position: 'before', content: { text: normalizedRequest.text ?? '' } }, + }; + } else if (normalizedRequest.kind === 'replace') { + op = 'text.rewrite'; + stepDef = { + id: stepId, + op, + where: STUB_WHERE, + args: { replacement: { text: normalizedRequest.text ?? '' }, style: { inline: { mode: 'preserve' } } }, + }; + } else { + op = 'text.delete'; + stepDef = { + id: stepId, + op, + where: STUB_WHERE, + args: {}, + }; + } + + const step = stepDef as unknown as MutationStep; + const target = toCompiledTarget(stepId, op, resolved); + const compiled: CompiledPlan = { + mutationSteps: [{ step, targets: [target] }], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled, { + changeMode: mode, + expectedRevision: options?.expectedRevision, + }); + + return mapPlanReceiptToTextReceipt(receipt, resolved.resolution); +} + +// --------------------------------------------------------------------------- +// Format wrappers (bold / italic / underline / strikethrough) +// --------------------------------------------------------------------------- + +type FormatMarkName = 'bold' | 'italic' | 'underline' | 'strike'; +type FormatOperationId = keyof typeof FORMAT_OPERATION_LABEL; + +function formatMarkWrapper( + editor: Editor, + markName: FormatMarkName, + operationId: FormatOperationId, + input: FormatOperationInput, + options?: MutationOptions, +): TextMutationReceipt { + 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: normalizedInput.target, + }); + } + + const resolution = buildTextMutationResolution({ + requestedTarget: input.target, + target: normalizedInput.target!, + range, + text: readTextAtResolvedRange(editor, range), + }); + + if (range.from === range.to) { + const label = FORMAT_OPERATION_LABEL[operationId]; + return { + success: false, + resolution, + failure: { code: 'INVALID_TARGET', message: `${label} formatting requires a non-collapsed target range.` }, + }; + } + + requireSchemaMark(editor, markName, operationId); + + const mode = options?.changeMode ?? 'direct'; + if (mode === 'tracked') { + ensureTrackedCapability(editor, { operation: operationId, requireMarks: [TrackFormatMarkName] }); + } + + if (options?.dryRun) { + return { success: true, resolution }; + } + + // Build single-step compiled plan for style.apply + const stepId = uuidv4(); + const marks: SetMarks = {}; + if (markName === 'bold') marks.bold = true; + else if (markName === 'italic') marks.italic = true; + else if (markName === 'underline') marks.underline = true; + else if (markName === 'strike') marks.strike = true; + + const step = { + id: stepId, + op: 'style.apply', + where: STUB_WHERE, + args: { marks }, + } as unknown as MutationStep; + + const target: CompiledTarget = { + stepId, + op: 'style.apply', + blockId: normalizedInput.target!.blockId, + from: normalizedInput.target!.range.start, + to: normalizedInput.target!.range.end, + text: resolution.text, + marks: [], + }; + + const compiled: CompiledPlan = { + mutationSteps: [{ step, targets: [target] }], + assertSteps: [], + }; + + const receipt = executeCompiledPlan(editor, compiled, { + changeMode: mode, + expectedRevision: options?.expectedRevision, + }); + + return mapPlanReceiptToTextReceipt(receipt, resolution); +} + +export function formatBoldWrapper( + editor: Editor, + input: FormatBoldInput, + options?: MutationOptions, +): TextMutationReceipt { + return formatMarkWrapper(editor, 'bold', 'format.bold', input, options); +} + +export function formatItalicWrapper( + editor: Editor, + input: FormatItalicInput, + options?: MutationOptions, +): TextMutationReceipt { + return formatMarkWrapper(editor, 'italic', 'format.italic', input, options); +} + +export function formatUnderlineWrapper( + editor: Editor, + input: FormatUnderlineInput, + options?: MutationOptions, +): TextMutationReceipt { + return formatMarkWrapper(editor, 'underline', 'format.underline', input, options); +} + +export function formatStrikethroughWrapper( + editor: Editor, + input: FormatStrikethroughInput, + options?: MutationOptions, +): TextMutationReceipt { + return formatMarkWrapper(editor, 'strike', 'format.strikethrough', input, options); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts new file mode 100644 index 0000000000..0cabdcc5ec --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts @@ -0,0 +1,94 @@ +/** + * Preview engine — evaluates a mutation plan on an ephemeral transaction + * without dispatching it. Reports what would happen. + * + * Runs the full two-phase evaluation (compile + execute) on an ephemeral + * transaction that is never dispatched. + */ + +import type { + MutationsPreviewInput, + MutationsPreviewOutput, + StepPreview, + PreviewFailure, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { checkRevision, getRevision } from './revision-tracker.js'; +import { compilePlan } from './compiler.js'; +import { runMutationsOnTransaction } from './executor.js'; +import { planError, PlanError } from './errors.js'; + +export function previewPlan(editor: Editor, input: MutationsPreviewInput): MutationsPreviewOutput { + const evaluatedRevision = getRevision(editor); + + // Revision guard + checkRevision(editor, input.expectedRevision); + + if (!input.steps?.length) { + throw planError('INVALID_INPUT', 'plan must contain at least one step'); + } + + const failures: PreviewFailure[] = []; + const stepPreviews: StepPreview[] = []; + let currentPhase: 'compile' | 'execute' = 'compile'; + + try { + // Phase 1: Compile — resolve selectors against pre-mutation snapshot + const compiled = compilePlan(editor, input.steps); + currentPhase = 'execute'; + + // Phase 2: Execute on ephemeral transaction (never dispatched) + const tr = editor.state.tr; + + // Run mutations without throwing on assert failure — collect failures instead + const { stepOutcomes, assertFailures } = runMutationsOnTransaction(editor, tr, compiled, { + throwOnAssertFailure: false, + }); + + // Build step previews from outcomes + for (const outcome of stepOutcomes) { + const preview: StepPreview = { + stepId: outcome.stepId, + op: outcome.op, + }; + + if (outcome.data && 'resolutions' in outcome.data && outcome.data.domain === 'text') { + preview.resolutions = outcome.data.resolutions; + } + + stepPreviews.push(preview); + } + + // Report assert failures + for (const failure of assertFailures) { + failures.push({ + code: 'PRECONDITION_FAILED', + stepId: failure.stepId, + phase: 'assert', + message: `assert "${failure.stepId}" expected ${failure.expectedCount} matches but found ${failure.actualCount}`, + details: { expectedCount: failure.expectedCount, actualCount: failure.actualCount }, + }); + } + + // Transaction is discarded — never dispatched + } catch (error) { + if (error instanceof PlanError) { + failures.push({ + code: error.code, + stepId: error.stepId ?? '', + phase: currentPhase, + message: error.message, + details: error.details, + }); + } else { + throw error; + } + } + + return { + evaluatedRevision, + steps: stepPreviews, + valid: failures.length === 0, + failures: failures.length > 0 ? failures : undefined, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts new file mode 100644 index 0000000000..4ba94addf8 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CapturedStyle, CapturedRun } from './style-resolver.js'; +import { buildStyleSummary, queryMatchAdapter } from './query-match-adapter.js'; + +// --------------------------------------------------------------------------- +// Module mocks — intercept dependencies of queryMatchAdapter +// --------------------------------------------------------------------------- + +const mockedDeps = vi.hoisted(() => ({ + findAdapter: vi.fn(), + getBlockIndex: vi.fn(), + captureRunsInRange: vi.fn(), + getRevision: vi.fn(() => 'rev-1'), +})); + +vi.mock('../find-adapter.js', () => ({ + findAdapter: mockedDeps.findAdapter, +})); + +vi.mock('../helpers/index-cache.js', () => ({ + getBlockIndex: mockedDeps.getBlockIndex, +})); + +vi.mock('./style-resolver.js', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + captureRunsInRange: mockedDeps.captureRunsInRange, + }; +}); + +vi.mock('./revision-tracker.js', () => ({ + getRevision: mockedDeps.getRevision, +})); + +// --------------------------------------------------------------------------- +// Helpers to build mock captured styles +// --------------------------------------------------------------------------- + +function mockMark(name: string) { + return { + type: { name, create: () => mockMark(name) }, + attrs: {}, + eq: (other: any) => other.type.name === name, + }; +} + +function run(from: number, to: number, markNames: string[]): CapturedRun { + return { + from, + to, + charCount: to - from, + marks: markNames.map(mockMark) as any, + }; +} + +function captured(runs: CapturedRun[], isUniform: boolean): CapturedStyle { + return { runs, isUniform }; +} + +// --------------------------------------------------------------------------- +// buildStyleSummary — unit tests +// --------------------------------------------------------------------------- + +describe('buildStyleSummary', () => { + it('reports all marks from a uniform bold+italic range', () => { + const style = buildStyleSummary(captured([run(0, 10, ['bold', 'italic'])], true)); + expect(style.marks).toEqual({ bold: true, italic: true }); + expect(style.isUniform).toBe(true); + }); + + it('reports no marks for unstyled text', () => { + const style = buildStyleSummary(captured([run(0, 10, [])], true)); + expect(style.marks).toEqual({}); + expect(style.isUniform).toBe(true); + }); + + it('reports marks by majority rule for non-uniform ranges', () => { + // 8 chars bold, 2 chars not bold → bold wins (>50%) + const style = buildStyleSummary(captured([run(0, 8, ['bold']), run(8, 10, [])], false)); + expect(style.marks).toEqual({ bold: true }); + expect(style.isUniform).toBe(false); + }); + + it('reports mark as false when minority of text has it', () => { + // 2 chars bold, 8 chars not bold → bold loses + const style = buildStyleSummary(captured([run(0, 2, ['bold']), run(2, 10, [])], false)); + expect(style.marks).toEqual({ bold: false }); + expect(style.isUniform).toBe(false); + }); + + it('reports mark as false on exact 50/50 tie', () => { + const style = buildStyleSummary(captured([run(0, 5, ['bold']), run(5, 10, [])], false)); + expect(style.marks).toEqual({ bold: false }); + }); + + it('handles multiple marks independently', () => { + // bold: 8/10 chars → true, italic: 3/10 chars → false, underline: 10/10 → true + const style = buildStyleSummary( + captured( + [ + run(0, 8, ['bold', 'underline']), + run(8, 10, ['italic', 'underline']), + run(10, 10, []), // zero-width, doesn't affect counts + ], + false, + ), + ); + // Bold: 8/10 > 50% → true + // Italic: 2/10 < 50% → false + // Underline: 10/10 > 50% → true + expect(style.marks.bold).toBe(true); + expect(style.marks.italic).toBe(false); + expect(style.marks.underline).toBe(true); + }); + + it('returns empty marks for empty runs', () => { + const style = buildStyleSummary(captured([], true)); + expect(style.marks).toEqual({}); + expect(style.isUniform).toBe(true); + }); + + it('only reports core marks (ignores non-core like textStyle)', () => { + const style = buildStyleSummary(captured([run(0, 10, ['bold', 'textStyle'])], true)); + // textStyle is not in CORE_MARK_NAMES so it should not appear + expect(style.marks).toEqual({ bold: true }); + expect(style.marks).not.toHaveProperty('textStyle'); + }); +}); + +// --------------------------------------------------------------------------- +// queryMatchAdapter — behavioral integration tests +// --------------------------------------------------------------------------- + +describe('queryMatchAdapter (behavioral)', () => { + const dummyEditor = {} as any; + + beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('rev-1'); + }); + + function setupFindResult(options: { matches: any[]; context?: any[]; total: number }) { + mockedDeps.findAdapter.mockReturnValue({ + matches: options.matches, + context: options.context ?? [], + total: options.total, + }); + } + + function setupBlockIndex(candidates: Array<{ nodeId: string; pos: number }>) { + mockedDeps.getBlockIndex.mockReturnValue({ candidates }); + } + + it('includes style summary on matches when includeStyle is true', () => { + const boldRun = run(0, 5, ['bold']); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [ + { + textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }], + }, + ], + total: 1, + }); + setupBlockIndex([{ nodeId: 'p1', pos: 0 }]); + mockedDeps.captureRunsInRange.mockReturnValue(captured([boldRun], true)); + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'text', pattern: 'Hello' }, + includeStyle: true, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0].style).toBeDefined(); + expect(result.matches[0].style!.marks).toEqual({ bold: true }); + expect(result.matches[0].style!.isUniform).toBe(true); + }); + + it('omits style summary when includeStyle is false', () => { + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [ + { + textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }], + }, + ], + total: 1, + }); + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'text', pattern: 'Hello' }, + includeStyle: false, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0].style).toBeUndefined(); + // captureRunsInRange should not be called + expect(mockedDeps.captureRunsInRange).not.toHaveBeenCalled(); + }); + + it('omits style when includeStyle is true but match has no textRanges', () => { + setupFindResult({ + matches: [{ kind: 'block', nodeId: 'p1' }], + context: [{}], // no textRanges + total: 1, + }); + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'node', nodeType: 'paragraph' }, + includeStyle: true, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0].style).toBeUndefined(); + }); + + it('reports non-uniform style across multi-run match', () => { + const boldRun = run(0, 3, ['bold']); + const plainRun = run(3, 5, []); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [ + { + textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }], + }, + ], + total: 1, + }); + setupBlockIndex([{ nodeId: 'p1', pos: 0 }]); + mockedDeps.captureRunsInRange.mockReturnValue(captured([boldRun, plainRun], false)); + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'text', pattern: 'Hello' }, + includeStyle: true, + }); + + expect(result.matches[0].style).toBeDefined(); + // bold: 3/5 > 50% → true + expect(result.matches[0].style!.marks.bold).toBe(true); + expect(result.matches[0].style!.isUniform).toBe(false); + }); + + it('captures style across multiple textRanges in a single match', () => { + // Simulates a match split across two inline runs (same block) + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [ + { + textRanges: [ + { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }, + { kind: 'text', blockId: 'p1', range: { start: 3, end: 7 } }, + ], + }, + ], + total: 1, + }); + setupBlockIndex([{ nodeId: 'p1', pos: 0 }]); + + // First range: bold, second range: bold+italic + mockedDeps.captureRunsInRange + .mockReturnValueOnce(captured([run(0, 3, ['bold'])], true)) + .mockReturnValueOnce(captured([run(3, 7, ['bold', 'italic'])], true)); + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'text', pattern: 'Hello w' }, + includeStyle: true, + }); + + expect(result.matches[0].style).toBeDefined(); + // bold: 7/7 (all chars) → true + expect(result.matches[0].style!.marks.bold).toBe(true); + // italic: 4/7 > 50% → true + expect(result.matches[0].style!.marks.italic).toBe(true); + // Cross-range uniformity: runs have different marks → not uniform + expect(result.matches[0].style!.isUniform).toBe(false); + }); + + it('generates ephemeral text ref with revision', () => { + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [ + { + textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }], + }, + ], + total: 1, + }); + mockedDeps.getRevision.mockReturnValue('rev-42'); + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'text', pattern: 'Hello' }, + }); + + expect(result.evaluatedRevision).toBe('rev-42'); + expect(result.matches[0].ref).toBeDefined(); + expect(result.matches[0].ref!.startsWith('text:')).toBe(true); + expect(result.matches[0].refStability).toBe('ephemeral'); + + // Decode ref to verify it contains revision and ranges + const decoded = JSON.parse(atob(result.matches[0].ref!.slice(5))); + expect(decoded.rev).toBe('rev-42'); + expect(decoded.ranges).toHaveLength(1); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts new file mode 100644 index 0000000000..b64949dd3c --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts @@ -0,0 +1,176 @@ +/** + * query.match adapter — deterministic matching with cardinality contracts. + * + * Reuses the same search infrastructure as `find` but applies strict + * cardinality rules and returns mutation-ready refs. + */ + +import type { + QueryMatchInput, + QueryMatchOutput, + MatchResult, + MatchStyleSummary, + CardinalityRequirement, + TextAddress, +} from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { findAdapter } from '../find-adapter.js'; +import { getBlockIndex } from '../helpers/index-cache.js'; +import { captureRunsInRange, type CapturedStyle } from './style-resolver.js'; +import { getRevision } from './revision-tracker.js'; +import { planError } from './errors.js'; + +// --------------------------------------------------------------------------- +// Style introspection helpers +// --------------------------------------------------------------------------- + +const CORE_MARK_NAMES = ['bold', 'italic', 'underline', 'strike'] as const; + +/** + * Builds a MatchStyleSummary from captured style data. + * Reports each core mark as true if it appears on the majority of characters. + */ +export function buildStyleSummary(captured: CapturedStyle): MatchStyleSummary { + const totalChars = captured.runs.reduce((sum, r) => sum + r.charCount, 0); + const marks: Record = {}; + + for (const markName of CORE_MARK_NAMES) { + let activeChars = 0; + for (const run of captured.runs) { + if (run.marks.some((m) => m.type.name === markName)) { + activeChars += run.charCount; + } + } + // Only include mark if it's active on any character + if (activeChars > 0) { + marks[markName] = totalChars > 0 && activeChars > totalChars / 2; + } + } + + return { marks, isUniform: captured.isUniform }; +} + +/** + * Captures style for a match's text ranges and produces a summary. + */ +function captureMatchStyle(editor: Editor, textRanges: TextAddress[]): MatchStyleSummary | undefined { + if (!textRanges.length) return undefined; + + const index = getBlockIndex(editor); + + // Merge captured runs across all ranges in the match + const allRuns: CapturedStyle['runs'] = []; + let allUniform = true; + + for (const range of textRanges) { + const candidate = index.candidates.find((c) => c.nodeId === range.blockId); + if (!candidate) continue; + + const captured = captureRunsInRange(editor, candidate.pos, range.range.start, range.range.end); + allRuns.push(...captured.runs); + if (!captured.isUniform) allUniform = false; + } + + // Check cross-range uniformity using full mark equality (attrs + eq) + if (allUniform && allRuns.length > 1) { + const ref = allRuns[0].marks; + for (let i = 1; i < allRuns.length; i++) { + const cur = allRuns[i].marks; + if (ref.length !== cur.length || !ref.every((m, j) => cur[j] && m.eq(cur[j]))) { + allUniform = false; + break; + } + } + } + + return buildStyleSummary({ runs: allRuns, isUniform: allUniform }); +} + +// --------------------------------------------------------------------------- + +export function queryMatchAdapter(editor: Editor, input: QueryMatchInput): QueryMatchOutput { + const evaluatedRevision = getRevision(editor); + const require: CardinalityRequirement = input.require ?? 'any'; + + // Validate pagination + cardinality interaction + if ((require === 'first' || require === 'exactlyOne') && (input.limit !== undefined || input.offset !== undefined)) { + throw planError('INVALID_INPUT', `limit/offset are not valid when require is "${require}"`); + } + + // Execute search using the find adapter infrastructure + const query = { + select: input.select, + within: input.within, + includeNodes: input.includeNodes, + limit: input.limit, + offset: input.offset, + }; + + const result = findAdapter(editor, query); + const totalMatches = result.total; + + // Apply cardinality checks + if (require === 'first') { + if (totalMatches === 0) { + throw planError('MATCH_NOT_FOUND', 'selector matched zero ranges'); + } + } else if (require === 'exactlyOne') { + if (totalMatches === 0) { + throw planError('MATCH_NOT_FOUND', 'selector matched zero ranges'); + } + if (totalMatches > 1) { + throw planError('AMBIGUOUS_MATCH', `selector matched ${totalMatches} ranges, expected exactly one`, undefined, { + matchCount: totalMatches, + }); + } + } else if (require === 'all') { + if (totalMatches === 0) { + throw planError('MATCH_NOT_FOUND', 'selector matched zero ranges'); + } + } + + // Build match results + const matchResults: MatchResult[] = result.matches.map((address, idx) => { + const matchResult: MatchResult = { address }; + + // Include text ranges from context + const ctx = result.context?.[idx]; + if (ctx?.textRanges?.length) { + matchResult.textRanges = ctx.textRanges; + } + + // Include style summary when requested + if (input.includeStyle && matchResult.textRanges?.length) { + matchResult.style = captureMatchStyle(editor, matchResult.textRanges); + } + + // Generate mutation-ready ref + if (input.select.type === 'text') { + // Text refs are ephemeral (revision-scoped) + const refData = { + rev: evaluatedRevision, + addr: address, + ranges: ctx?.textRanges, + }; + matchResult.ref = `text:${btoa(JSON.stringify(refData))}`; + matchResult.refStability = 'ephemeral'; + } else { + // Entity/structural refs are stable + if (address.kind === 'block') { + matchResult.ref = address.nodeId; + matchResult.refStability = 'stable'; + } + } + + return matchResult; + }); + + // Apply cardinality truncation for 'first' + const truncated = require === 'first' ? matchResults.slice(0, 1) : matchResults; + + return { + evaluatedRevision, + matches: truncated, + totalMatches, + }; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts new file mode 100644 index 0000000000..850346d363 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/register-executors.ts @@ -0,0 +1,143 @@ +/** + * Built-in step executor registration — registers all core step executors + * with the executor registry. + * + * Called once from adapter assembly to wire the dispatch table. + */ + +import type { + MutationStep, + StepOutcome, + StepEffect, + TextStepData, + TextStepResolution, + TextRewriteStep, + TextInsertStep, + TextDeleteStep, + StyleApplyStep, + DomainStepData, +} from '@superdoc/document-api'; +import type { CompiledTarget, ExecuteContext } from './executor-registry.types.js'; +import { registerStepExecutor } from './executor-registry.js'; +import { + executeTextRewrite, + executeTextInsert, + executeTextDelete, + executeStyleApply, + executeCreateStep, +} from './executor.js'; + +// --------------------------------------------------------------------------- +// Shared helpers for target iteration +// --------------------------------------------------------------------------- + +function sortTargetsByPosition(targets: CompiledTarget[]): CompiledTarget[] { + return [...targets].sort((a, b) => { + if (a.blockId === b.blockId) return a.from - b.from; + return a.blockId < b.blockId ? -1 : 1; + }); +} + +function buildTextResolution(target: CompiledTarget): TextStepResolution { + return { + target: { + kind: 'text', + blockId: target.blockId, + range: { start: target.from, end: target.to }, + }, + range: { from: target.from, to: target.to }, + text: target.text, + }; +} + +function executeWithTargetIteration( + ctx: ExecuteContext, + targets: CompiledTarget[], + step: MutationStep, + executeFn: (editor: any, tr: any, target: CompiledTarget, step: any, mapping: any) => { changed: boolean }, +): StepOutcome { + const sortedTargets = sortTargetsByPosition(targets); + let overallChanged = false; + const resolutions: TextStepResolution[] = []; + + for (const target of sortedTargets) { + resolutions.push(buildTextResolution(target)); + const { changed } = executeFn(ctx.editor, ctx.tr, target, step, ctx.mapping); + if (changed) overallChanged = true; + } + + const effect: StepEffect = overallChanged ? 'changed' : 'noop'; + const data: TextStepData = { domain: 'text', resolutions }; + return { stepId: step.id, op: step.op, effect, matchCount: targets.length, data }; +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +let registered = false; + +export function registerBuiltInExecutors(): void { + if (registered) return; + registered = true; + + registerStepExecutor('text.rewrite', { + execute: (ctx, targets, step) => + executeWithTargetIteration(ctx, targets, step, (e, tr, t, s, m) => + executeTextRewrite(e, tr, t, s as TextRewriteStep, m), + ), + }); + + registerStepExecutor('text.insert', { + execute: (ctx, targets, step) => + executeWithTargetIteration(ctx, targets, step, (e, tr, t, s, m) => + executeTextInsert(e, tr, t, s as TextInsertStep, m), + ), + }); + + registerStepExecutor('text.delete', { + execute: (ctx, targets, step) => + executeWithTargetIteration(ctx, targets, step, (e, tr, t, s, m) => + executeTextDelete(e, tr, t, s as TextDeleteStep, m), + ), + }); + + registerStepExecutor('style.apply', { + execute: (ctx, targets, step) => + executeWithTargetIteration(ctx, targets, step, (e, tr, t, s, m) => + executeStyleApply(e, tr, t, s as StyleApplyStep, m), + ), + }); + + registerStepExecutor('create.paragraph', { + execute: (ctx, targets, step) => executeCreateStep(ctx.editor, ctx.tr, step, targets, ctx.mapping), + }); + + registerStepExecutor('create.heading', { + execute: (ctx, targets, step) => executeCreateStep(ctx.editor, ctx.tr, step, targets, ctx.mapping), + }); + + registerStepExecutor('domain.command', { + execute(ctx, _targets, step) { + const handler = (step as any)._handler as (() => boolean) | undefined; + if (!handler) { + return { + stepId: step.id, + op: step.op, + effect: 'noop' as StepEffect, + matchCount: 0, + data: { domain: 'command', commandDispatched: false } as DomainStepData, + }; + } + const success = handler(); + if (success) ctx.commandDispatched = true; + return { + stepId: step.id, + op: step.op, + effect: (success ? 'changed' : 'noop') as StepEffect, + matchCount: success ? 1 : 0, + data: { domain: 'command', commandDispatched: success } as DomainStepData, + }; + }, + }); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/revision-tracker.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/revision-tracker.test.ts new file mode 100644 index 0000000000..c3336ec8a2 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/revision-tracker.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { getRevision, incrementRevision, initRevision, checkRevision, trackRevisions } from './revision-tracker.js'; +import { PlanError } from './errors.js'; +import type { Editor } from '../../core/Editor.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEditor(): Editor & { _listeners: Map } { + const listeners = new Map(); + return { + on(event: string, fn: Function) { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event)!.push(fn); + }, + _listeners: listeners, + } as any; +} + +function emitTransaction(editor: any, docChanged: boolean) { + const fns = editor._listeners.get('transaction') ?? []; + for (const fn of fns) { + fn({ transaction: { docChanged } }); + } +} + +// --------------------------------------------------------------------------- +// Core revision operations +// --------------------------------------------------------------------------- + +describe('revision-tracker: core operations', () => { + it('starts at 0 after init', () => { + const editor = makeEditor(); + initRevision(editor); + expect(getRevision(editor)).toBe('0'); + }); + + it('increments monotonically', () => { + const editor = makeEditor(); + initRevision(editor); + + expect(incrementRevision(editor)).toBe('1'); + expect(incrementRevision(editor)).toBe('2'); + expect(getRevision(editor)).toBe('2'); + }); + + it('checkRevision passes when revision matches', () => { + const editor = makeEditor(); + initRevision(editor); + + expect(() => checkRevision(editor, '0')).not.toThrow(); + }); + + it('checkRevision throws REVISION_MISMATCH when revision differs', () => { + const editor = makeEditor(); + initRevision(editor); + + try { + checkRevision(editor, '5'); + throw new Error('expected PlanError'); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe('REVISION_MISMATCH'); + } + }); + + it('checkRevision is a no-op when expectedRevision is undefined', () => { + const editor = makeEditor(); + initRevision(editor); + expect(() => checkRevision(editor, undefined)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// trackRevisions — transaction-based revision advancement +// --------------------------------------------------------------------------- + +describe('trackRevisions: transaction-based revision advancement', () => { + it('increments revision on docChanged transactions', () => { + const editor = makeEditor(); + initRevision(editor); + trackRevisions(editor); + + expect(getRevision(editor)).toBe('0'); + + // Simulate a doc-changing transaction + emitTransaction(editor, true); + expect(getRevision(editor)).toBe('1'); + + emitTransaction(editor, true); + expect(getRevision(editor)).toBe('2'); + }); + + it('does not increment revision on non-docChanged transactions', () => { + const editor = makeEditor(); + initRevision(editor); + trackRevisions(editor); + + // Selection-only transaction (no doc change) + emitTransaction(editor, false); + expect(getRevision(editor)).toBe('0'); + }); + + it('only subscribes once per editor (idempotent)', () => { + const editor = makeEditor(); + initRevision(editor); + + trackRevisions(editor); + trackRevisions(editor); + trackRevisions(editor); + + // Should only have one listener + const listeners = editor._listeners.get('transaction') ?? []; + expect(listeners).toHaveLength(1); + }); + + it('tracks direct edits alongside plan-engine mutations', () => { + const editor = makeEditor(); + initRevision(editor); + trackRevisions(editor); + + // Simulate: plan-engine mutation dispatches a transaction + emitTransaction(editor, true); + expect(getRevision(editor)).toBe('1'); + + // Simulate: user types directly (direct edit) + emitTransaction(editor, true); + expect(getRevision(editor)).toBe('2'); + + // Simulate: collaboration update arrives + emitTransaction(editor, true); + expect(getRevision(editor)).toBe('3'); + + // Selection-only (no increment) + emitTransaction(editor, false); + expect(getRevision(editor)).toBe('3'); + }); + + it('makes expectedRevision guards reject stale refs after external edits', () => { + const editor = makeEditor(); + initRevision(editor); + trackRevisions(editor); + + // Initial state: revision 0 + expect(getRevision(editor)).toBe('0'); + + // External edit happens (e.g., collaboration) + emitTransaction(editor, true); + expect(getRevision(editor)).toBe('1'); + + // A plan with expectedRevision: '0' should now fail + try { + checkRevision(editor, '0'); + throw new Error('expected PlanError'); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe('REVISION_MISMATCH'); + } + + // But expectedRevision: '1' should pass + expect(() => checkRevision(editor, '1')).not.toThrow(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/revision-tracker.ts b/packages/super-editor/src/document-api-adapters/plan-engine/revision-tracker.ts new file mode 100644 index 0000000000..191af98a85 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/revision-tracker.ts @@ -0,0 +1,64 @@ +/** + * Monotonic revision counter for optimistic concurrency control. + * + * Tracks document revisions as decimal string counters. Increments once + * per successfully dispatched transaction that changes document state. + * + * Revision is advanced by listening to the editor's `transaction` event, + * so it covers ALL document-changing transactions — plan-engine mutations, + * direct editor edits, collaboration updates, and plugin-generated changes. + */ + +import type { Editor } from '../../core/Editor.js'; +import { PlanError } from './errors.js'; + +const revisionMap = new WeakMap(); +const subscribedEditors = new WeakSet(); + +export function getRevision(editor: Editor): string { + const rev = revisionMap.get(editor) ?? 0; + return String(rev); +} + +export function incrementRevision(editor: Editor): string { + const current = revisionMap.get(editor) ?? 0; + const next = current + 1; + revisionMap.set(editor, next); + return String(next); +} + +export function initRevision(editor: Editor): void { + if (!revisionMap.has(editor)) { + revisionMap.set(editor, 0); + } +} + +/** + * Subscribe to the editor's transaction events so that revision advances + * on every document-changing transaction, regardless of source. + * + * Safe to call multiple times — only subscribes once per editor instance. + */ +export function trackRevisions(editor: Editor): void { + if (subscribedEditors.has(editor)) return; + subscribedEditors.add(editor); + + editor.on('transaction', ({ transaction }: { transaction: { docChanged: boolean } }) => { + if (transaction.docChanged) { + incrementRevision(editor); + } + }); +} + +export function checkRevision(editor: Editor, expectedRevision: string | undefined): void { + if (expectedRevision === undefined) return; + const current = getRevision(editor); + if (expectedRevision !== current) { + throw new PlanError( + 'REVISION_MISMATCH', + `REVISION_MISMATCH — expected revision "${expectedRevision}" but document is at "${current}"`, + undefined, + { expectedRevision, currentRevision: current }, + ); + } +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts new file mode 100644 index 0000000000..55f7de36dd --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureRunsInRange } from './style-resolver.js'; +import type { Editor } from '../../core/Editor.js'; + +// --------------------------------------------------------------------------- +// captureRunsInRange — block content offset tests +// --------------------------------------------------------------------------- + +describe('captureRunsInRange: block content offset (blockPos + 1)', () => { + /** + * ProseMirror block content starts at blockPos + 1 (the +1 skips the + * block node's opening token). This test verifies that captureRunsInRange + * correctly uses contentStart = blockPos + 1 when computing absolute + * positions for doc.nodesBetween. + */ + it('walks document starting at blockPos + 1, not blockPos', () => { + const nodesBetween = vi.fn(); + const editor = { + state: { + doc: { nodesBetween }, + }, + } as unknown as Editor; + + // Block at position 10, text offsets [2, 5) + // Content starts at 11 (blockPos + 1) + // So absolute range should be [13, 16) not [12, 15) + captureRunsInRange(editor, 10, 2, 5); + + expect(nodesBetween).toHaveBeenCalledTimes(1); + const [absFrom, absTo] = nodesBetween.mock.calls[0]; + expect(absFrom).toBe(13); // 10 + 1 + 2 + expect(absTo).toBe(16); // 10 + 1 + 5 + }); + + it('computes correct absolute positions for blockPos=0', () => { + const nodesBetween = vi.fn(); + const editor = { + state: { + doc: { nodesBetween }, + }, + } as unknown as Editor; + + // Block at position 0, text offsets [0, 3) + // Content starts at 1 (0 + 1) + captureRunsInRange(editor, 0, 0, 3); + + const [absFrom, absTo] = nodesBetween.mock.calls[0]; + expect(absFrom).toBe(1); // 0 + 1 + 0 + expect(absTo).toBe(4); // 0 + 1 + 3 + }); + + it('returns content-relative offsets in captured runs', () => { + // Simulate a text node at absolute position 11, size 5 + const textNode = { + isText: true, + nodeSize: 5, + marks: [], + }; + + const nodesBetween = vi.fn((from: number, to: number, cb: Function) => { + // Call back with a text node starting at absolute position 11 + cb(textNode, 11); + }); + + const editor = { + state: { + doc: { nodesBetween }, + }, + } as unknown as Editor; + + // Block at position 10, text offsets [0, 5) + // contentStart = 11, absFrom = 11, absTo = 16 + const result = captureRunsInRange(editor, 10, 0, 5); + + expect(result.runs).toHaveLength(1); + // Run offsets should be relative to contentStart (blockPos + 1) + // nodeStart = max(11, 11) = 11, relFrom = 11 - 11 = 0 + // nodeEnd = min(11 + 5, 16) = 16, relTo = 16 - 11 = 5 + expect(result.runs[0].from).toBe(0); + expect(result.runs[0].to).toBe(5); + expect(result.runs[0].charCount).toBe(5); + }); + + it('clamps run offsets to the requested range', () => { + // Text node extends beyond the requested range + const textNode = { + isText: true, + nodeSize: 10, + marks: [], + }; + + const nodesBetween = vi.fn((from: number, to: number, cb: Function) => { + // Text node starts at contentStart (11), extends to 21 + cb(textNode, 11); + }); + + const editor = { + state: { + doc: { nodesBetween }, + }, + } as unknown as Editor; + + // Block at position 10, requesting only text offsets [2, 5) + // contentStart = 11, absFrom = 13, absTo = 16 + const result = captureRunsInRange(editor, 10, 2, 5); + + expect(result.runs).toHaveLength(1); + // nodeStart = max(11, 13) = 13, relFrom = 13 - 11 = 2 + // nodeEnd = min(21, 16) = 16, relTo = 16 - 11 = 5 + expect(result.runs[0].from).toBe(2); + expect(result.runs[0].to).toBe(5); + expect(result.runs[0].charCount).toBe(3); + }); + + it('filters out metadata marks (trackInsert, commentMark, etc.)', () => { + const boldMark = { type: { name: 'bold' }, attrs: {}, eq: () => true }; + const trackMark = { type: { name: 'trackInsert' }, attrs: {}, eq: () => true }; + + const textNode = { + isText: true, + nodeSize: 5, + marks: [boldMark, trackMark], + }; + + const nodesBetween = vi.fn((_from: number, _to: number, cb: Function) => { + cb(textNode, 1); + }); + + const editor = { + state: { doc: { nodesBetween } }, + } as unknown as Editor; + + const result = captureRunsInRange(editor, 0, 0, 5); + + expect(result.runs).toHaveLength(1); + // Only bold should be present, trackInsert filtered out + expect(result.runs[0].marks).toHaveLength(1); + expect(result.runs[0].marks[0].type.name).toBe('bold'); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts new file mode 100644 index 0000000000..95f70d1d28 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts @@ -0,0 +1,444 @@ +/** + * Style resolver — captures inline marks from matched ranges and applies + * non-uniform resolution strategies for text.rewrite operations. + * + * Phase 7: Style capture and style-aware rewrite. + */ + +import type { InlineStylePolicy, SetMarks } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { planError } from './errors.js'; + +// --------------------------------------------------------------------------- +// Run types — describes contiguous spans sharing identical marks within a block +// --------------------------------------------------------------------------- + +/** A ProseMirror mark as seen on inline text nodes. */ +interface PmMark { + type: { name: string; create: (attrs?: Record | null) => PmMark }; + attrs: Record; + eq: (other: PmMark) => boolean; +} + +/** One contiguous run of text sharing identical marks. */ +export interface CapturedRun { + /** Offset relative to block start. */ + from: number; + /** Offset relative to block start. */ + to: number; + /** Character count (to - from). */ + charCount: number; + /** The active marks on this run. */ + marks: readonly PmMark[]; +} + +/** Mark capture result for a matched range. */ +export interface CapturedStyle { + /** Runs within the matched range, sorted by position. */ + runs: CapturedRun[]; + /** True if all runs share the exact same mark set. */ + isUniform: boolean; +} + +// --------------------------------------------------------------------------- +// Core mark names — the four marks that setMarks can override +// --------------------------------------------------------------------------- + +const CORE_MARK_NAMES = new Set(['bold', 'italic', 'underline', 'strike']); + +/** Mark names that are metadata (never affected by style policy). */ +const METADATA_MARK_NAMES = new Set([ + 'trackInsert', + 'trackDelete', + 'trackFormat', + 'commentMark', + 'aiMark', + 'aiAnimationMark', +]); + +// --------------------------------------------------------------------------- +// Capture — extract runs from a matched range +// --------------------------------------------------------------------------- + +/** + * Capture inline runs (mark spans) from a block-relative text range. + * + * Walks the ProseMirror document between the absolute positions corresponding + * to the block-relative `from`/`to` offsets, collecting each inline text node + * as a run with its marks. + */ +export function captureRunsInRange(editor: Editor, blockPos: number, from: number, to: number): CapturedStyle { + const doc = editor.state.doc; + // Block content starts at blockPos + 1 (the +1 skips the block node's opening token) + const contentStart = blockPos + 1; + const absFrom = contentStart + from; + const absTo = contentStart + to; + + const runs: CapturedRun[] = []; + + // Walk inline content between absFrom and absTo + doc.nodesBetween(absFrom, absTo, (node, pos) => { + if (!node.isText) return true; + + // Clamp to the matched range + const nodeStart = Math.max(pos, absFrom); + const nodeEnd = Math.min(pos + node.nodeSize, absTo); + if (nodeStart >= nodeEnd) return true; + + const relFrom = nodeStart - contentStart; + const relTo = nodeEnd - contentStart; + + // Filter out metadata marks + const formattingMarks = (node.marks as unknown as PmMark[]).filter((m) => !METADATA_MARK_NAMES.has(m.type.name)); + + runs.push({ + from: relFrom, + to: relTo, + charCount: relTo - relFrom, + marks: formattingMarks, + }); + + return true; + }); + + const isUniform = checkUniformity(runs); + + return { runs, isUniform }; +} + +/** + * Check whether all runs share the exact same mark set. + */ +function checkUniformity(runs: CapturedRun[]): boolean { + if (runs.length <= 1) return true; + + const reference = runs[0].marks; + for (let i = 1; i < runs.length; i++) { + if (!marksEqual(reference, runs[i].marks)) return false; + } + return true; +} + +/** + * Compare two mark arrays for structural equality (same types, same attrs). + */ +function marksEqual(a: readonly PmMark[], b: readonly PmMark[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!a[i].eq(b[i])) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Resolution — resolve non-uniform styles using strategies +// --------------------------------------------------------------------------- + +/** + * Resolve the mark set to apply for a text.rewrite step, given the captured + * style data and the inline style policy. + * + * Returns an array of PM marks to apply to the replacement text. + */ +export function resolveInlineStyle( + editor: Editor, + captured: CapturedStyle, + policy: InlineStylePolicy, + stepId: string, +): readonly PmMark[] { + if (policy.mode === 'clear') return []; + + if (policy.mode === 'set') { + return buildMarksFromPolicy(editor, policy.setMarks); + } + + // preserve or merge — need captured style data + + // requireUniform pre-check + if (policy.requireUniform && !captured.isUniform) { + throw planError( + 'STYLE_CONFLICT', + 'matched range has non-uniform inline styles and requireUniform is true', + stepId, + { runCount: captured.runs.length }, + ); + } + + let resolvedMarks: readonly PmMark[]; + + if (captured.isUniform || captured.runs.length === 0) { + // Uniform — use the marks from the first (and only distinct) run + resolvedMarks = captured.runs.length > 0 ? captured.runs[0].marks : []; + } else { + // Non-uniform — apply resolution strategy + const strategy = policy.onNonUniform ?? 'useLeadingRun'; + + if (strategy === 'error') { + throw planError( + 'STYLE_CONFLICT', + 'matched range has non-uniform inline styles and onNonUniform is "error"', + stepId, + { runCount: captured.runs.length }, + ); + } + + resolvedMarks = applyNonUniformStrategy(editor, captured.runs, strategy); + } + + // Apply setMarks overrides (preserve + setMarks or merge mode) + if (policy.setMarks) { + return applySetMarksToResolved(editor, resolvedMarks, policy.setMarks); + } + + return resolvedMarks; +} + +// --------------------------------------------------------------------------- +// Non-uniform resolution strategies +// --------------------------------------------------------------------------- + +function applyNonUniformStrategy( + editor: Editor, + runs: CapturedRun[], + strategy: 'useLeadingRun' | 'majority' | 'union', +): readonly PmMark[] { + switch (strategy) { + case 'useLeadingRun': + return resolveUseLeadingRun(runs); + case 'majority': + return resolveMajority(editor, runs); + case 'union': + return resolveUnion(editor, runs); + } +} + +/** + * Use the mark set of the first run (lowest document position). + */ +function resolveUseLeadingRun(runs: CapturedRun[]): readonly PmMark[] { + return runs.length > 0 ? runs[0].marks : []; +} + +/** + * Per-mark character-weighted voting. A mark is included if it covers strictly + * more than half the total characters. For value-bearing attributes, the value + * covering the most characters wins; ties go to the first run's value. + */ +function resolveMajority(editor: Editor, runs: CapturedRun[]): readonly PmMark[] { + const totalChars = runs.reduce((sum, r) => sum + r.charCount, 0); + if (totalChars === 0) return []; + + // Collect all unique mark type names across all runs + const allMarkNames = new Set(); + for (const run of runs) { + for (const mark of run.marks) { + allMarkNames.add(mark.type.name); + } + } + + const resultMarks: PmMark[] = []; + + for (const markName of allMarkNames) { + if (CORE_MARK_NAMES.has(markName)) { + // Boolean mark — include if active chars > totalChars / 2 (strict majority) + let activeChars = 0; + for (const run of runs) { + if (run.marks.some((m) => m.type.name === markName)) { + activeChars += run.charCount; + } + } + if (activeChars > totalChars / 2) { + // Find the mark instance from any run + for (const run of runs) { + const found = run.marks.find((m) => m.type.name === markName); + if (found) { + resultMarks.push(found); + break; + } + } + } + // Tie (exactly 50/50) → excluded + } else { + // Value-bearing mark (e.g., textStyle) — per-attribute majority voting + resolveValueBearingMarkMajority(runs, markName, totalChars, resultMarks); + } + } + + return resultMarks; +} + +/** + * For value-bearing marks (textStyle, etc.), resolve each attribute independently + * using character-weighted majority. Ties go to the first run's value. + */ +function resolveValueBearingMarkMajority( + runs: CapturedRun[], + markName: string, + totalChars: number, + resultMarks: PmMark[], +): void { + // Check if any run has this mark + let anyRunHasMark = false; + for (const run of runs) { + if (run.marks.some((m) => m.type.name === markName)) { + anyRunHasMark = true; + break; + } + } + if (!anyRunHasMark) return; + + // Collect all attribute keys across all instances of this mark + const allAttrKeys = new Set(); + const markInstances: Array<{ mark: PmMark; run: CapturedRun }> = []; + + for (const run of runs) { + const mark = run.marks.find((m) => m.type.name === markName); + if (mark) { + markInstances.push({ mark, run }); + for (const key of Object.keys(mark.attrs)) { + allAttrKeys.add(key); + } + } + } + + // For each attribute, find the majority value + const resolvedAttrs: Record = {}; + let hasAnyAttr = false; + + for (const key of allAttrKeys) { + // Tally: value → total chars + const valueTally = new Map(); + + for (let i = 0; i < runs.length; i++) { + const run = runs[i]; + const mark = run.marks.find((m) => m.type.name === markName); + const value = mark ? mark.attrs[key] : undefined; + const serialized = JSON.stringify(value); + + const existing = valueTally.get(serialized); + if (existing) { + existing.chars += run.charCount; + } else { + valueTally.set(serialized, { chars: run.charCount, firstRunIdx: i, value }); + } + } + + // Find winner — strict majority, ties go to first run's value + let winner: { chars: number; firstRunIdx: number; value: unknown } | undefined; + for (const entry of valueTally.values()) { + if ( + !winner || + entry.chars > winner.chars || + (entry.chars === winner.chars && entry.firstRunIdx < winner.firstRunIdx) + ) { + winner = entry; + } + } + + if (winner && winner.value !== undefined) { + resolvedAttrs[key] = winner.value; + hasAnyAttr = true; + } + } + + if (hasAnyAttr && markInstances.length > 0) { + // Create a mark with the resolved attrs using the first instance's type + const templateMark = markInstances[0].mark; + try { + const resolvedMark = templateMark.type.create(resolvedAttrs); + resultMarks.push(resolvedMark as unknown as PmMark); + } catch { + // If creation fails, use the first run's mark instance + resultMarks.push(templateMark); + } + } +} + +/** + * Include a mark if it appears on any run. For value-bearing attributes, use + * the value from the first run that has the attribute. + */ +function resolveUnion(editor: Editor, runs: CapturedRun[]): readonly PmMark[] { + // Collect all unique mark type names + const allMarkNames = new Set(); + for (const run of runs) { + for (const mark of run.marks) { + allMarkNames.add(mark.type.name); + } + } + + const resultMarks: PmMark[] = []; + + for (const markName of allMarkNames) { + if (CORE_MARK_NAMES.has(markName)) { + // Boolean mark — include if present on any run + for (const run of runs) { + const found = run.marks.find((m) => m.type.name === markName); + if (found) { + resultMarks.push(found); + break; + } + } + } else { + // Value-bearing mark — use first run's instance that has it + for (const run of runs) { + const found = run.marks.find((m) => m.type.name === markName); + if (found) { + resultMarks.push(found); + break; + } + } + } + } + + return resultMarks; +} + +// --------------------------------------------------------------------------- +// setMarks override helpers +// --------------------------------------------------------------------------- + +/** + * Build PM marks from a SetMarks declaration (for mode: 'set'). + */ +function buildMarksFromPolicy(editor: Editor, setMarks?: SetMarks): PmMark[] { + if (!setMarks) return []; + const { schema } = editor.state; + const marks: PmMark[] = []; + + if (setMarks.bold && schema.marks.bold) marks.push(schema.marks.bold.create() as unknown as PmMark); + if (setMarks.italic && schema.marks.italic) marks.push(schema.marks.italic.create() as unknown as PmMark); + if (setMarks.underline && schema.marks.underline) marks.push(schema.marks.underline.create() as unknown as PmMark); + if (setMarks.strike && schema.marks.strike) marks.push(schema.marks.strike.create() as unknown as PmMark); + + return marks; +} + +/** + * Apply setMarks overrides to an existing resolved mark set. + * setMarks acts as a patch: true adds, false removes, undefined leaves untouched. + */ +function applySetMarksToResolved(editor: Editor, existingMarks: readonly PmMark[], setMarks: SetMarks): PmMark[] { + const { schema } = editor.state; + let marks = [...existingMarks]; + + const overrides: Array<[boolean | undefined, unknown]> = [ + [setMarks.bold, schema.marks.bold], + [setMarks.italic, schema.marks.italic], + [setMarks.underline, schema.marks.underline], + [setMarks.strike, schema.marks.strike], + ]; + + for (const [value, markType] of overrides) { + if (value === undefined || !markType) continue; + if (value) { + if (!marks.some((m) => m.type === (markType as any))) { + marks.push((markType as any).create() as PmMark); + } + } else { + marks = marks.filter((m) => m.type !== (markType as any)); + } + } + + return marks; +} diff --git a/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/track-changes-wrappers.ts similarity index 51% rename from packages/super-editor/src/document-api-adapters/track-changes-adapter.ts rename to packages/super-editor/src/document-api-adapters/plan-engine/track-changes-wrappers.ts index 3d4d3aa9fc..b1aa233c19 100644 --- a/packages/super-editor/src/document-api-adapters/track-changes-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/track-changes-wrappers.ts @@ -1,6 +1,16 @@ -import type { Editor } from '../core/Editor.js'; +/** + * Track-changes convenience wrappers — bridge track-change operations to + * the plan engine's revision management and execution path. + * + * Read operations (list, get) are pure queries. + * Mutating operations (accept, reject, acceptAll, rejectAll) delegate to + * editor commands with plan-engine revision tracking. + */ + +import type { Editor } from '../../core/Editor.js'; import type { Receipt, + RevisionGuardOptions, TrackChangeInfo, TrackChangesAcceptAllInput, TrackChangesAcceptInput, @@ -11,16 +21,17 @@ import type { TrackChangeType, TrackChangesListResult, } from '@superdoc/document-api'; -import { DocumentApiAdapterError } from './errors.js'; -import { requireEditorCommand } from './helpers/mutation-helpers.js'; -import { paginate } from './helpers/adapter-utils.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { requireEditorCommand } from '../helpers/mutation-helpers.js'; +import { executeDomainCommand } from './plan-wrappers.js'; +import { paginate } from '../helpers/adapter-utils.js'; import { groupTrackedChanges, resolveTrackedChange, resolveTrackedChangeType, type GroupedTrackedChange, -} from './helpers/tracked-change-resolver.js'; -import { normalizeExcerpt, toNonEmptyString } from './helpers/value-utils.js'; +} from '../helpers/tracked-change-resolver.js'; +import { normalizeExcerpt, toNonEmptyString } from '../helpers/value-utils.js'; function buildTrackChangeInfo(editor: Editor, change: GroupedTrackedChange): TrackChangeInfo { const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')); @@ -67,7 +78,11 @@ function toNoOpReceipt(message: string, details?: unknown): Receipt { }; } -export function trackChangesListAdapter(editor: Editor, input?: TrackChangesListInput): TrackChangesListResult { +// --------------------------------------------------------------------------- +// Read operations (queries) +// --------------------------------------------------------------------------- + +export function trackChangesListWrapper(editor: Editor, input?: TrackChangesListInput): TrackChangesListResult { const query = input; const grouped = filterByType(groupTrackedChanges(editor), query?.type); const paged = paginate(grouped, query?.offset, query?.limit); @@ -81,55 +96,95 @@ export function trackChangesListAdapter(editor: Editor, input?: TrackChangesList }; } -export function trackChangesGetAdapter(editor: Editor, input: TrackChangesGetInput): TrackChangeInfo { +export function trackChangesGetWrapper(editor: Editor, input: TrackChangesGetInput): TrackChangeInfo { const { id } = input; return buildTrackChangeInfo(editor, requireTrackChangeById(editor, id)); } -export function trackChangesAcceptAdapter(editor: Editor, input: TrackChangesAcceptInput): Receipt { +// --------------------------------------------------------------------------- +// Mutating operations (wrappers) +// --------------------------------------------------------------------------- + +export function trackChangesAcceptWrapper( + editor: Editor, + input: TrackChangesAcceptInput, + options?: RevisionGuardOptions, +): Receipt { const { id } = input; const change = requireTrackChangeById(editor, id); - const acceptById = requireEditorCommand(editor.commands?.acceptTrackedChangeById, 'Accept tracked change'); - const didAccept = Boolean(acceptById(change.rawId)); - if (didAccept) return { success: true }; - return toNoOpReceipt(`Accept tracked change "${id}" produced no change.`, { id }); + const receipt = executeDomainCommand(editor, () => Boolean(acceptById(change.rawId)), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { + return toNoOpReceipt(`Accept tracked change "${id}" produced no change.`, { id }); + } + + return { success: true }; } -export function trackChangesRejectAdapter(editor: Editor, input: TrackChangesRejectInput): Receipt { +export function trackChangesRejectWrapper( + editor: Editor, + input: TrackChangesRejectInput, + options?: RevisionGuardOptions, +): Receipt { const { id } = input; const change = requireTrackChangeById(editor, id); - const rejectById = requireEditorCommand(editor.commands?.rejectTrackedChangeById, 'Reject tracked change'); - const didReject = Boolean(rejectById(change.rawId)); - if (didReject) return { success: true }; - return toNoOpReceipt(`Reject tracked change "${id}" produced no change.`, { id }); + const receipt = executeDomainCommand(editor, () => Boolean(rejectById(change.rawId)), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { + return toNoOpReceipt(`Reject tracked change "${id}" produced no change.`, { id }); + } + + return { success: true }; } -export function trackChangesAcceptAllAdapter(editor: Editor, _input: TrackChangesAcceptAllInput): Receipt { +export function trackChangesAcceptAllWrapper( + editor: Editor, + _input: TrackChangesAcceptAllInput, + options?: RevisionGuardOptions, +): Receipt { const acceptAll = requireEditorCommand(editor.commands?.acceptAllTrackedChanges, 'Accept all tracked changes'); if (groupTrackedChanges(editor).length === 0) { return toNoOpReceipt('Accept all tracked changes produced no change.'); } - const didAcceptAll = Boolean(acceptAll()); - if (didAcceptAll) return { success: true }; + const receipt = executeDomainCommand(editor, () => Boolean(acceptAll()), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { + return toNoOpReceipt('Accept all tracked changes produced no change.'); + } - return toNoOpReceipt('Accept all tracked changes produced no change.'); + return { success: true }; } -export function trackChangesRejectAllAdapter(editor: Editor, _input: TrackChangesRejectAllInput): Receipt { +export function trackChangesRejectAllWrapper( + editor: Editor, + _input: TrackChangesRejectAllInput, + options?: RevisionGuardOptions, +): Receipt { const rejectAll = requireEditorCommand(editor.commands?.rejectAllTrackedChanges, 'Reject all tracked changes'); if (groupTrackedChanges(editor).length === 0) { return toNoOpReceipt('Reject all tracked changes produced no change.'); } - const didRejectAll = Boolean(rejectAll()); - if (didRejectAll) return { success: true }; + const receipt = executeDomainCommand(editor, () => Boolean(rejectAll()), { + expectedRevision: options?.expectedRevision, + }); + + if (receipt.steps[0]?.effect !== 'changed') { + return toNoOpReceipt('Reject all tracked changes produced no change.'); + } - return toNoOpReceipt('Reject all tracked changes produced no change.'); + return { success: true }; } diff --git a/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts b/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts deleted file mode 100644 index 22cb40a30e..0000000000 --- a/packages/super-editor/src/document-api-adapters/track-changes-adapter.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { Editor } from '../core/Editor.js'; -import { - trackChangesAcceptAdapter, - trackChangesAcceptAllAdapter, - trackChangesGetAdapter, - trackChangesListAdapter, - trackChangesRejectAdapter, - trackChangesRejectAllAdapter, -} from './track-changes-adapter.js'; -import { TrackDeleteMarkName, TrackInsertMarkName } from '../extensions/track-changes/constants.js'; -import { getTrackChanges } from '../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; - -vi.mock('../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', () => ({ - getTrackChanges: vi.fn(), -})); - -function makeEditor(overrides: Partial = {}): Editor { - return { - state: { - doc: { - content: { size: 100 }, - textBetween(from: number, to: number) { - return `excerpt-${from}-${to}`; - }, - }, - }, - commands: { - acceptTrackedChangeById: vi.fn(() => true), - rejectTrackedChangeById: vi.fn(() => true), - acceptAllTrackedChanges: vi.fn(() => true), - rejectAllTrackedChanges: vi.fn(() => true), - }, - ...overrides, - } as unknown as Editor; -} - -describe('track-changes adapters', () => { - it('lists tracked changes with stable trackedChange entity addresses', () => { - vi.mocked(getTrackChanges).mockReturnValue([ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'tc-1', author: 'Ada', authorEmail: 'ada@example.com' }, - }, - from: 2, - to: 5, - }, - { - mark: { - type: { name: TrackDeleteMarkName }, - attrs: { id: 'tc-1' }, - }, - from: 5, - to: 8, - }, - ] as never); - - const result = trackChangesListAdapter(makeEditor()); - expect(result.total).toBe(1); - expect(result.matches[0]).toMatchObject({ - kind: 'entity', - entityType: 'trackedChange', - }); - expect(typeof result.matches[0]?.entityId).toBe('string'); - expect(result.changes?.[0]?.id).toBe(result.matches[0]?.entityId); - expect(result.changes?.[0]?.type).toBe('insert'); - expect(result.changes?.[0]?.excerpt).toContain('excerpt-2-8'); - }); - - it('respects list type filters and pagination', () => { - vi.mocked(getTrackChanges).mockReturnValue([ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'tc-1' }, - }, - from: 1, - to: 2, - }, - { - mark: { - type: { name: TrackDeleteMarkName }, - attrs: { id: 'tc-2' }, - }, - from: 3, - to: 4, - }, - ] as never); - - const result = trackChangesListAdapter(makeEditor(), { type: 'delete', limit: 1, offset: 0 }); - expect(result.total).toBe(1); - expect(result.matches).toHaveLength(1); - expect(result.changes?.[0]?.type).toBe('delete'); - }); - - it('gets a tracked change by id', () => { - vi.mocked(getTrackChanges).mockReturnValue([ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'tc-1' }, - }, - from: 2, - to: 5, - }, - ] as never); - - const editor = makeEditor(); - const listed = trackChangesListAdapter(editor, { limit: 1 }); - const id = listed.matches[0]?.entityId; - expect(typeof id).toBe('string'); - const change = trackChangesGetAdapter(editor, { id: id as string }); - expect(change.id).toBe(id); - }); - - it('throws for unknown tracked change ids', () => { - vi.mocked(getTrackChanges).mockReturnValue([] as never); - expect(() => trackChangesGetAdapter(makeEditor(), { id: 'missing' })).toThrow('was not found'); - - try { - trackChangesGetAdapter(makeEditor(), { id: 'missing' }); - } catch (error) { - expect((error as { code?: string }).code).toBe('TARGET_NOT_FOUND'); - } - }); - - it('maps accept/reject commands to receipts', () => { - vi.mocked(getTrackChanges).mockReturnValue([ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'tc-1' }, - }, - from: 1, - to: 2, - }, - ] as never); - - const acceptTrackedChangeById = vi.fn(() => true); - const rejectTrackedChangeById = vi.fn(() => true); - const acceptAllTrackedChanges = vi.fn(() => true); - const rejectAllTrackedChanges = vi.fn(() => true); - const editor = makeEditor({ - commands: { - acceptTrackedChangeById, - rejectTrackedChangeById, - acceptAllTrackedChanges, - rejectAllTrackedChanges, - } as never, - }); - - const listed = trackChangesListAdapter(editor, { limit: 1 }); - const id = listed.matches[0]?.entityId as string; - expect(typeof id).toBe('string'); - - expect(trackChangesAcceptAdapter(editor, { id }).success).toBe(true); - expect(trackChangesRejectAdapter(editor, { id }).success).toBe(true); - expect(trackChangesAcceptAllAdapter(editor, {}).success).toBe(true); - expect(trackChangesRejectAllAdapter(editor, {}).success).toBe(true); - expect(acceptTrackedChangeById).toHaveBeenCalledWith('tc-1'); - expect(rejectTrackedChangeById).toHaveBeenCalledWith('tc-1'); - }); - - it('throws TARGET_NOT_FOUND when accepting/rejecting an unknown id', () => { - vi.mocked(getTrackChanges).mockReturnValue([] as never); - - expect(() => trackChangesAcceptAdapter(makeEditor(), { id: 'missing' })).toThrow('was not found'); - expect(() => trackChangesRejectAdapter(makeEditor(), { id: 'missing' })).toThrow('was not found'); - - try { - trackChangesAcceptAdapter(makeEditor(), { id: 'missing' }); - } catch (error) { - expect((error as { code?: string }).code).toBe('TARGET_NOT_FOUND'); - } - }); - - it('throws CAPABILITY_UNAVAILABLE when accept/reject commands are missing', () => { - vi.mocked(getTrackChanges).mockReturnValue([ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'tc-1' }, - }, - from: 1, - to: 2, - }, - ] as never); - - const editor = makeEditor({ - commands: { - acceptTrackedChangeById: undefined, - rejectTrackedChangeById: undefined, - acceptAllTrackedChanges: vi.fn(() => true), - rejectAllTrackedChanges: vi.fn(() => true), - } as never, - }); - - const listed = trackChangesListAdapter(editor, { limit: 1 }); - const id = listed.matches[0]?.entityId as string; - - expect(() => trackChangesAcceptAdapter(editor, { id })).toThrow('Accept tracked change command is not available'); - expect(() => trackChangesRejectAdapter(editor, { id })).toThrow('Reject tracked change command is not available'); - }); - - it('returns NO_OP failure when accept/reject commands do not apply', () => { - vi.mocked(getTrackChanges).mockReturnValue([ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'tc-1' }, - }, - from: 1, - to: 2, - }, - ] as never); - - const editor = makeEditor({ - commands: { - acceptTrackedChangeById: vi.fn(() => false), - rejectTrackedChangeById: vi.fn(() => false), - acceptAllTrackedChanges: vi.fn(() => true), - rejectAllTrackedChanges: vi.fn(() => true), - } as never, - }); - - const listed = trackChangesListAdapter(editor, { limit: 1 }); - const id = listed.matches[0]?.entityId as string; - - const acceptReceipt = trackChangesAcceptAdapter(editor, { id }); - const rejectReceipt = trackChangesRejectAdapter(editor, { id }); - expect(acceptReceipt.success).toBe(false); - expect(acceptReceipt.failure?.code).toBe('NO_OP'); - expect(rejectReceipt.success).toBe(false); - expect(rejectReceipt.failure?.code).toBe('NO_OP'); - }); - - it('throws CAPABILITY_UNAVAILABLE for missing accept-all/reject-all commands', () => { - vi.mocked(getTrackChanges).mockReturnValue([] as never); - - const editor = makeEditor({ - commands: { - acceptTrackedChangeById: vi.fn(() => true), - rejectTrackedChangeById: vi.fn(() => true), - acceptAllTrackedChanges: undefined, - rejectAllTrackedChanges: undefined, - } as never, - }); - - expect(() => trackChangesAcceptAllAdapter(editor, {})).toThrow( - 'Accept all tracked changes command is not available', - ); - expect(() => trackChangesRejectAllAdapter(editor, {})).toThrow( - 'Reject all tracked changes command is not available', - ); - }); - - it('returns NO_OP failure when accept-all/reject-all do not apply', () => { - vi.mocked(getTrackChanges).mockReturnValue([] as never); - - const editor = makeEditor({ - commands: { - acceptTrackedChangeById: vi.fn(() => true), - rejectTrackedChangeById: vi.fn(() => true), - acceptAllTrackedChanges: vi.fn(() => false), - rejectAllTrackedChanges: vi.fn(() => false), - } as never, - }); - - const acceptAllReceipt = trackChangesAcceptAllAdapter(editor, {}); - const rejectAllReceipt = trackChangesRejectAllAdapter(editor, {}); - expect(acceptAllReceipt.success).toBe(false); - expect(acceptAllReceipt.failure?.code).toBe('NO_OP'); - expect(rejectAllReceipt.success).toBe(false); - expect(rejectAllReceipt.failure?.code).toBe('NO_OP'); - }); - - it('returns NO_OP failure when accept-all/reject-all report true but no tracked changes exist', () => { - vi.mocked(getTrackChanges).mockReturnValue([] as never); - - const editor = makeEditor({ - commands: { - acceptTrackedChangeById: vi.fn(() => true), - rejectTrackedChangeById: vi.fn(() => true), - acceptAllTrackedChanges: vi.fn(() => true), - rejectAllTrackedChanges: vi.fn(() => true), - } as never, - }); - - const acceptAllReceipt = trackChangesAcceptAllAdapter(editor, {}); - const rejectAllReceipt = trackChangesRejectAllAdapter(editor, {}); - expect(acceptAllReceipt.success).toBe(false); - expect(acceptAllReceipt.failure?.code).toBe('NO_OP'); - expect(rejectAllReceipt.success).toBe(false); - expect(rejectAllReceipt.failure?.code).toBe('NO_OP'); - }); - - it('resolves stable ids across calls when raw ids differ', () => { - const marks = [ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'raw-1', date: '2026-02-11T00:00:00.000Z' }, - }, - from: 2, - to: 5, - }, - ]; - - vi.mocked(getTrackChanges).mockImplementation(() => marks as never); - const editor = makeEditor(); - - const listed = trackChangesListAdapter(editor, { limit: 1 }); - const stableId = listed.matches[0]?.entityId; - expect(typeof stableId).toBe('string'); - - marks[0] = { - ...marks[0], - mark: { - ...marks[0].mark, - attrs: { ...marks[0].mark.attrs, id: 'raw-2' }, - }, - }; - - const resolved = trackChangesGetAdapter(editor, { id: stableId as string }); - expect(resolved.id).toBe(stableId); - }); - - it('throws TARGET_NOT_FOUND when accepting an id that was already processed', () => { - const marks = [ - { - mark: { - type: { name: TrackInsertMarkName }, - attrs: { id: 'raw-1' }, - }, - from: 2, - to: 5, - }, - ]; - - vi.mocked(getTrackChanges).mockImplementation(() => marks as never); - - const state = { - doc: { - content: { size: 100 }, - textBetween(from: number, to: number) { - return `excerpt-${from}-${to}`; - }, - }, - }; - const acceptTrackedChangeById = vi.fn(() => { - marks.splice(0, marks.length); - // Simulate ProseMirror creating a new doc reference after mutation - state.doc = { ...state.doc }; - return true; - }); - - const editor = makeEditor({ - state: state as never, - commands: { - acceptTrackedChangeById, - rejectTrackedChangeById: vi.fn(() => true), - acceptAllTrackedChanges: vi.fn(() => true), - rejectAllTrackedChanges: vi.fn(() => true), - } as never, - }); - - const listed = trackChangesListAdapter(editor, { limit: 1 }); - const stableId = listed.matches[0]?.entityId as string; - - expect(trackChangesAcceptAdapter(editor, { id: stableId }).success).toBe(true); - expect(() => trackChangesAcceptAdapter(editor, { id: stableId })).toThrow('was not found'); - }); -}); 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 09c4d3f4b8..dad06ce293 100644 --- a/packages/super-editor/src/document-api-adapters/write-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/write-adapter.ts @@ -10,6 +10,7 @@ import type { import { DocumentApiAdapterError } from './errors.js'; import { ensureTrackedCapability } from './helpers/mutation-helpers.js'; import { applyDirectMutationMeta } from './helpers/transaction-meta.js'; +import { checkRevision } from './plan-engine/revision-tracker.js'; import { resolveDefaultInsertTarget, resolveTextTarget, type ResolvedTextTarget } from './helpers/adapter-utils.js'; import { buildTextMutationResolution, readTextAtResolvedRange } from './helpers/text-mutation-resolution.js'; import { toCanonicalTrackedChangeId } from './helpers/tracked-change-resolver.js'; @@ -280,6 +281,8 @@ function toFailureReceipt(failure: ReceiptFailure, resolvedTarget: ResolvedWrite } export function writeAdapter(editor: Editor, request: WriteRequest, options?: MutationOptions): TextMutationReceipt { + checkRevision(editor, options?.expectedRevision); + // Normalize friendly locator fields (blockId + offset) into canonical TextAddress // before resolution. This is the adapter-layer normalization per the contract. const normalizedRequest = normalizeWriteLocator(request);