Skip to content

Commit dc1c158

Browse files
committed
feat(document-api): add invoke dynamic dispatch with typed operation registry
Adds `api.invoke({ operationId, input, options })` for dynamic/AI callers alongside type-safe overloads for TypeScript consumers. Includes: - OperationRegistry with bidirectional compile-time completeness checks - Runtime dispatch table mapping all 36 operations to direct API methods - DynamicInvokeRequest overload accepting unknown input - hasOwnProperty guard with clear error for unknown operation IDs - Contract parity check extended to verify dispatch table keys - 13 new tests covering completeness, parity, error handling, and dynamic dispatch
1 parent 4304a3a commit dc1c158

6 files changed

Lines changed: 582 additions & 2 deletions

File tree

packages/document-api/scripts/check-contract-parity.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import {
1414
isValidOperationIdFormat,
1515
type DocumentApiAdapters,
1616
} from '../src/index.js';
17+
import { buildDispatchTable } from '../src/invoke/invoke.js';
18+
19+
/**
20+
* Meta-methods on DocumentApi that are not operations.
21+
* These are excluded from operation-to-member-path parity checks.
22+
*/
23+
const META_MEMBER_PATHS = ['invoke'] as const;
1724

1825
function collectFunctionMemberPaths(value: unknown, prefix = ''): string[] {
1926
if (!value || typeof value !== 'object') return [];
@@ -188,7 +195,10 @@ function run(): void {
188195
}
189196

190197
const api = createDocumentApi(createNoopAdapters());
191-
const runtimeMemberPaths = collectFunctionMemberPaths(api).sort();
198+
const metaPathSet = new Set<string>(META_MEMBER_PATHS);
199+
const runtimeMemberPaths = collectFunctionMemberPaths(api)
200+
.filter((path) => !metaPathSet.has(path))
201+
.sort();
192202
const declaredMemberPaths = [...DOCUMENT_API_MEMBER_PATHS].sort();
193203

194204
const missingRuntimeMembers = diff(declaredMemberPaths, runtimeMemberPaths);
@@ -199,6 +209,16 @@ function run(): void {
199209
);
200210
}
201211

212+
// Verify invoke dispatch table keys match OPERATION_IDS exactly.
213+
const dispatchKeys = Object.keys(buildDispatchTable(api)).sort();
214+
const missingDispatch = diff(operationIds, dispatchKeys);
215+
const extraDispatch = diff(dispatchKeys, operationIds);
216+
if (missingDispatch.length > 0 || extraDispatch.length > 0) {
217+
errors.push(
218+
`invoke dispatch table parity failed (missing: ${missingDispatch.join(', ') || 'none'}, extra: ${extraDispatch.join(', ') || 'none'})`,
219+
);
220+
}
221+
202222
const mappedMemberPaths = Object.values(OPERATION_MEMBER_PATH_MAP).sort();
203223
const missingMapMembers = diff(declaredMemberPaths, mappedMemberPaths);
204224
const extraMapMembers = diff(mappedMemberPaths, declaredMemberPaths);

packages/document-api/src/contract/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './command-catalog.js';
33
export * from './schemas.js';
44
export * from './operation-map.js';
55
export * from './reference-doc-map.js';
6+
export * from './operation-registry.js';
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Canonical type-level mapping from OperationId to input, options, and output types.
3+
*
4+
* This interface is the single source of truth for the invoke dispatch layer.
5+
* The bidirectional completeness checks at the bottom of this file guarantee
6+
* that every OperationId has a registry entry and vice versa.
7+
*/
8+
9+
import type { OperationId } from './types.js';
10+
11+
import type { NodeAddress, NodeInfo, QueryResult, Selector, Query } from '../types/index.js';
12+
import type { TextMutationReceipt, Receipt } from '../types/receipt.js';
13+
import type { DocumentInfo } from '../types/info.types.js';
14+
import type { CreateParagraphInput, CreateParagraphResult } from '../types/create.types.js';
15+
16+
import type { FindOptions } from '../find/find.js';
17+
import type { GetNodeByIdInput } from '../get-node/get-node.js';
18+
import type { GetTextInput } from '../get-text/get-text.js';
19+
import type { InfoInput } from '../info/info.js';
20+
import type { InsertInput } from '../insert/insert.js';
21+
import type { ReplaceInput } from '../replace/replace.js';
22+
import type { DeleteInput } from '../delete/delete.js';
23+
import type { MutationOptions } from '../write/write.js';
24+
import type { FormatBoldInput } from '../format/format.js';
25+
import type {
26+
AddCommentInput,
27+
EditCommentInput,
28+
ReplyToCommentInput,
29+
MoveCommentInput,
30+
ResolveCommentInput,
31+
RemoveCommentInput,
32+
SetCommentInternalInput,
33+
SetCommentActiveInput,
34+
GoToCommentInput,
35+
GetCommentInput,
36+
} from '../comments/comments.js';
37+
import type { CommentInfo, CommentsListQuery, CommentsListResult } from '../comments/comments.types.js';
38+
import type {
39+
TrackChangesListInput,
40+
TrackChangesGetInput,
41+
TrackChangesAcceptInput,
42+
TrackChangesRejectInput,
43+
TrackChangesAcceptAllInput,
44+
TrackChangesRejectAllInput,
45+
} from '../track-changes/track-changes.js';
46+
import type { TrackChangeInfo, TrackChangesListResult } from '../types/track-changes.types.js';
47+
import type { DocumentApiCapabilities } from '../capabilities/capabilities.js';
48+
import type {
49+
ListsListQuery,
50+
ListsListResult,
51+
ListsGetInput,
52+
ListItemInfo,
53+
ListInsertInput,
54+
ListsInsertResult,
55+
ListSetTypeInput,
56+
ListsMutateItemResult,
57+
ListTargetInput,
58+
ListsExitResult,
59+
} from '../lists/lists.types.js';
60+
61+
export interface OperationRegistry {
62+
// --- Singleton reads ---
63+
find: { input: Selector | Query; options: FindOptions; output: QueryResult };
64+
getNode: { input: NodeAddress; options: never; output: NodeInfo };
65+
getNodeById: { input: GetNodeByIdInput; options: never; output: NodeInfo };
66+
getText: { input: GetTextInput; options: never; output: string };
67+
info: { input: InfoInput; options: never; output: DocumentInfo };
68+
69+
// --- Singleton mutations ---
70+
insert: { input: InsertInput; options: MutationOptions; output: TextMutationReceipt };
71+
replace: { input: ReplaceInput; options: MutationOptions; output: TextMutationReceipt };
72+
delete: { input: DeleteInput; options: MutationOptions; output: TextMutationReceipt };
73+
74+
// --- format.* ---
75+
'format.bold': { input: FormatBoldInput; options: MutationOptions; output: TextMutationReceipt };
76+
77+
// --- create.* ---
78+
'create.paragraph': { input: CreateParagraphInput; options: MutationOptions; output: CreateParagraphResult };
79+
80+
// --- lists.* ---
81+
'lists.list': { input: ListsListQuery | undefined; options: never; output: ListsListResult };
82+
'lists.get': { input: ListsGetInput; options: never; output: ListItemInfo };
83+
'lists.insert': { input: ListInsertInput; options: MutationOptions; output: ListsInsertResult };
84+
'lists.setType': { input: ListSetTypeInput; options: MutationOptions; output: ListsMutateItemResult };
85+
'lists.indent': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult };
86+
'lists.outdent': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult };
87+
'lists.restart': { input: ListTargetInput; options: MutationOptions; output: ListsMutateItemResult };
88+
'lists.exit': { input: ListTargetInput; options: MutationOptions; output: ListsExitResult };
89+
90+
// --- comments.* ---
91+
'comments.add': { input: AddCommentInput; options: never; output: Receipt };
92+
'comments.edit': { input: EditCommentInput; options: never; output: Receipt };
93+
'comments.reply': { input: ReplyToCommentInput; options: never; output: Receipt };
94+
'comments.move': { input: MoveCommentInput; options: never; output: Receipt };
95+
'comments.resolve': { input: ResolveCommentInput; options: never; output: Receipt };
96+
'comments.remove': { input: RemoveCommentInput; options: never; output: Receipt };
97+
'comments.setInternal': { input: SetCommentInternalInput; options: never; output: Receipt };
98+
'comments.setActive': { input: SetCommentActiveInput; options: never; output: Receipt };
99+
'comments.goTo': { input: GoToCommentInput; options: never; output: Receipt };
100+
'comments.get': { input: GetCommentInput; options: never; output: CommentInfo };
101+
'comments.list': { input: CommentsListQuery | undefined; options: never; output: CommentsListResult };
102+
103+
// --- trackChanges.* ---
104+
'trackChanges.list': { input: TrackChangesListInput | undefined; options: never; output: TrackChangesListResult };
105+
'trackChanges.get': { input: TrackChangesGetInput; options: never; output: TrackChangeInfo };
106+
'trackChanges.accept': { input: TrackChangesAcceptInput; options: never; output: Receipt };
107+
'trackChanges.reject': { input: TrackChangesRejectInput; options: never; output: Receipt };
108+
'trackChanges.acceptAll': { input: TrackChangesAcceptAllInput; options: never; output: Receipt };
109+
'trackChanges.rejectAll': { input: TrackChangesRejectAllInput; options: never; output: Receipt };
110+
111+
// --- capabilities ---
112+
'capabilities.get': { input: undefined; options: never; output: DocumentApiCapabilities };
113+
}
114+
115+
// --- Bidirectional completeness checks ---
116+
// If either assertion fails, the `false extends true` branch produces a compile error.
117+
118+
type Assert<_T extends true> = void;
119+
120+
/** Fails to compile if OperationRegistry is missing any OperationId key. */
121+
type _AllOpsHaveRegistryEntry = Assert<OperationId extends keyof OperationRegistry ? true : false>;
122+
123+
/** Fails to compile if OperationRegistry has extra keys not in OperationId. */
124+
type _NoExtraRegistryKeys = Assert<keyof OperationRegistry extends OperationId ? true : false>;
125+
126+
// --- Invoke request/result types ---
127+
128+
/**
129+
* Typed invoke request. TypeScript narrows input and options based on operationId.
130+
*/
131+
export type InvokeRequest<T extends OperationId> = {
132+
operationId: T;
133+
input: OperationRegistry[T]['input'];
134+
} & (OperationRegistry[T]['options'] extends never
135+
? Record<string, never>
136+
: { options?: OperationRegistry[T]['options'] });
137+
138+
/**
139+
* Typed invoke result, narrowed by operationId.
140+
*/
141+
export type InvokeResult<T extends OperationId> = OperationRegistry[T]['output'];
142+
143+
/**
144+
* Loose invoke request for dynamic callers who don't know the operation at compile time.
145+
* Invalid inputs will produce adapter-level errors, not input-validation errors.
146+
*/
147+
export type DynamicInvokeRequest = {
148+
operationId: OperationId;
149+
input: unknown;
150+
options?: unknown;
151+
};

packages/document-api/src/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ import {
109109
type CapabilitiesAdapter,
110110
type DocumentApiCapabilities,
111111
} from './capabilities/capabilities.js';
112+
import type { OperationId } from './contract/types.js';
113+
import type { DynamicInvokeRequest, InvokeRequest, InvokeResult } from './contract/operation-registry.js';
114+
import { buildDispatchTable } from './invoke/invoke.js';
112115

113116
export type { FindAdapter, FindOptions } from './find/find.js';
114117
export type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js';
@@ -243,6 +246,19 @@ export interface DocumentApi {
243246
* Callable directly (`capabilities()`) or via `.get()`.
244247
*/
245248
capabilities: CapabilitiesApi;
249+
/**
250+
* Dynamically dispatch any operation by its operation ID.
251+
*
252+
* For TypeScript consumers, the return type narrows based on the operationId.
253+
* For dynamic callers (AI agents, automation), accepts {@link DynamicInvokeRequest}
254+
* with `unknown` input. Invalid inputs produce adapter-level errors.
255+
*
256+
* @param request - Operation envelope with operationId, input, and optional options.
257+
* @returns The operation-specific result payload from the dispatched handler.
258+
* @throws {Error} When operationId is unknown.
259+
*/
260+
invoke<T extends OperationId>(request: InvokeRequest<T>): InvokeResult<T>;
261+
invoke(request: DynamicInvokeRequest): unknown;
246262
}
247263

248264
export interface DocumentApiAdapters {
@@ -279,7 +295,7 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi {
279295
const capFn = () => executeCapabilities(adapters.capabilities);
280296
const capabilities: CapabilitiesApi = Object.assign(capFn, { get: capFn });
281297

282-
return {
298+
const api: DocumentApi = {
283299
find(selectorOrQuery: Selector | Query, options?: FindOptions): QueryResult {
284300
return executeFind(adapters.find, selectorOrQuery, options);
285301
},
@@ -396,5 +412,16 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi {
396412
return executeListsExit(adapters.lists, input, options);
397413
},
398414
},
415+
invoke(request: DynamicInvokeRequest): unknown {
416+
if (!Object.prototype.hasOwnProperty.call(dispatch, request.operationId)) {
417+
throw new Error(`Unknown operationId: "${request.operationId}"`);
418+
}
419+
const handler = dispatch[request.operationId];
420+
return handler(request.input, request.options);
421+
},
399422
};
423+
424+
const dispatch = buildDispatchTable(api);
425+
426+
return api;
400427
}

0 commit comments

Comments
 (0)