Initial package implementation: operations registry, call protocol, and adapters

Extracted from alkhub_ts packages/core/operations/ and packages/core/mcp/.
- Runtime-agnostic (injected fs/env deps, no Deno globals)
- Direct @logtape/logtape import instead of logger wrapper
- PendingRequestMap with pubsub-wired call protocol
- Peer-dep isolation for MCP adapter (sub-path export)
- Schema const naming convention (XSchema + X type alias)
- 68 tests passing, build + lint + test all green
This commit is contained in:
2026-04-30 12:34:26 +00:00
parent 9c41f683ee
commit 29f0dd7af0
37 changed files with 9287 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
# ADR-001: Direct @logtape/logtape Import
**Status**: Accepted
**Date**: 2026-04-30
## Context
The operations package needs structured logging. Within alkhub, logging went through a wrapper module (`core/logger/mod.ts`) that configured logtape categories and re-exported a logger instance. Now that operations is a standalone package, we need to decide how to handle logging.
Two approaches:
1. **Wrapper module** — create `src/logger.ts` that configures logtape and exports configured logger instances
2. **Direct import** — import `getLogger` from `@logtape/logtape` directly in each module
## Decision
Import `@logtape/logtape` directly. No wrapper module.
## Rationale
1. **Simpler** — one less file to maintain. The logtape API (`getLogger("category")`) is already clean and doesn't need wrapping.
2. **Category convention** — logtape uses dot-separated category strings (e.g., `"operations:registry"`, `"operations:call"`). These are self-documenting and don't need a function to build them.
3. **Configuration is external** — logtape configuration (log levels, sinks, categories) belongs to the application, not the library. The library just emits logs; the consumer decides what to do with them. A wrapper module would imply the library owns configuration, which it shouldn't.
4. **Consistent with logtape's design** — logtape's `getLogger()` is designed to be called directly in each module. It's not a per-invocation cost — `getLogger` returns a cached instance.
5. **No hidden state** — a wrapper module could carry configuration state that makes the library harder to reason about in isolation. Direct import means the library is stateless with respect to logging.
## Consequences
- Consumers must configure `@logtape/logtape` in their application if they want to see log output from this package
- Log categories follow the `operations:{module}` convention throughout
- `@logtape/logtape` is a direct runtime dependency (not a peer dep — it's small and we control its version)

View File

@@ -0,0 +1,48 @@
# ADR-002: Inject Filesystem Dependencies for Runtime Agnosticism
**Status**: Accepted
**Date**: 2026-04-30
## Context
The operations package must work in both Node.js and Deno. Two functions need filesystem access:
1. `scanOperations(dirPath, fs)` — recursive directory scan for `.ts` operation files
2. `FromOpenAPIFile(path, config, fs?)` — read OpenAPI JSON spec from filesystem
In Node.js, these use `node:fs/promises` and `node:path`. In Deno, they would use `Deno.readDir()` and `Deno.cwd()`. Direct use of Node APIs would break Deno; direct use of Deno globals would break Node.
## Decision
Inject filesystem dependencies through interfaces, not global imports.
```ts
interface ScannerFS {
readdir(path: string): AsyncIterable<{ name: string; isFile: boolean; isDirectory: boolean }>
cwd(): string
}
interface OpenAPIFS {
readFile(path: string): Promise<string>
}
```
Callers provide the FS implementation. When `OpenAPIFS` is not provided, `FromOpenAPIFile` falls back to `node:fs/promises` via dynamic import.
## Rationale
1. **No platform globals in source** — no `Deno.*` calls anywhere in `src/`. Both Node and Deno consumers work by providing the right FS interface.
2. **Testability** — tests provide mock FS implementations. No filesystem mocking libraries needed.
3. **Consistent pattern**`ScannerFS` and `OpenAPIFS` follow the same pattern: minimal interface, consumer-provided implementation, optional Node fallback.
4. **Deno path module** — the original alkhub scanner used `@std/path` (Deno standard library path module) for `resolve()` and `extname()`. The extracted version avoids this dependency by using simple string operations (`endsWith(".ts")`, path construction with `/`).
5. **Node fallback is dynamic**`FromOpenAPIFile` uses `await import("node:fs/promises")` as a fallback when no `fs` is provided. This keeps the Node path out of the module graph when a custom FS is injected, and avoids top-level Node imports that would break Deno.
## Consequences
- Callers in Deno must provide `ScannerFS` and `OpenAPIFS` implementations using `Deno.readDir()` and `Deno.readTextFile()`
- Callers in Node can omit the `fs` parameter for `FromOpenAPIFile` (Node fallback) but must provide `ScannerFS` for `scanOperations`
- The `pathToFileURL` helper in scanner uses a simple `file://` prefix construction rather than `url.pathToFileURL()` to avoid importing Node's `url` module

View File

@@ -0,0 +1,54 @@
# ADR-003: Peer Dependencies for Adapter Isolation
**Status**: Accepted
**Date**: 2026-04-30
## Context
The MCP adapter (`from_mcp.ts`) depends on `@modelcontextprotocol/sdk` for `Client`, `StdioClientTransport`, and `StreamableHTTPClientTransport`. This dependency is heavy (transitive deps) and only needed by consumers that connect to MCP servers. Other adapters may have similar heavy dependencies in the future (e.g., gRPC, GraphQL).
Two approaches:
1. **Regular dependency** — list `@modelcontextprotocol/sdk` as a direct dependency. All consumers install it.
2. **Optional peer dependency + sub-path export** — list it as an optional peer dependency, import dynamically, and expose via a separate `./from-mcp` sub-path export.
## Decision
Use optional peer dependency with sub-path export.
```json
{
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": { "optional": true }
},
"exports": {
".": { ... },
"./from-mcp": { ... }
}
}
```
## Rationale
1. **Zero-cost for non-MCP consumers**`npm install @alkdev/operations` does not install `@modelcontextprotocol/sdk`. Only consumers that `import { createMCPClient } from "@alkdev/operations/from-mcp"` need to install it.
2. **Dynamic import**`from_mcp.ts` uses `await import("@modelcontextprotocol/sdk/client/index.js")` and `await import("@modelcontextprotocol/sdk/client/stdio.js")`. The MCP SDK is loaded only when `createMCPClient` is actually called, not at module parse time.
3. **Explicit dependency declaration** — the sub-path import makes it clear at the import site that this code needs the MCP SDK. A barrel-only import doesn't communicate this.
4. **No bundler reliance** — sub-path exports don't depend on the consumer's bundler correctly tree-shaking. Not all consumers use bundlers (Deno, Node with `--experimental-strip-types`).
5. **Follows established pattern**`@alkdev/pubsub` uses the same approach for its Redis adapter (sub-path export with optional ioredis peer dep).
6. **Incremental** — future adapters (gRPC, GraphQL) will follow the same pattern. Each adds one peer dep entry and one sub-path export.
## Consequences
- `package.json` has a peer dep entry for each adapter's external dependency
- Both barrel and sub-path work — barrel re-exports everything for convenience, sub-path for explicitness
- `tsup` must list each adapter as a separate entry point
- Consumer docs should recommend sub-path imports for adapter-specific code
- The `from_mcp.ts` module also imports from `from_schema.ts` and `types.ts` (core), which are bundled into the sub-path output by tsup's code splitting

View File

@@ -0,0 +1,50 @@
# ADR-004: Schema Const Naming Convention
**Status**: Accepted
**Date**: 2026-04-30
## Context
TypeBox schemas and their inferred types need different names but represent the same concept. For example, the `AccessControl` schema defines the runtime shape and the `AccessControl` type provides the TypeScript interface. Using the same name for both creates a naming collision.
Two naming conventions are common in TypeBox codebases:
1. **Same name**`AccessControl` for both the schema constant and the inferred type (relies on TypeScript's value/type namespace separation)
2. **Different names**`AccessControlSchema` for the schema constant, `AccessControl` for the inferred type
## Decision
Use the `Schema` suffix for schema constants. The inferred type uses the bare name.
```ts
export const AccessControlSchema = Type.Object({ ... })
export type AccessControl = Static<typeof AccessControlSchema>
export const OperationSpecSchema = Type.Object({ ... })
export type OperationSpec = Static<typeof OperationSpecSchema>
export const ErrorDefinitionSchema = Type.Object({ ... })
export type ErrorDefinition = Static<typeof ErrorDefinitionSchema>
export const OperationDefinitionSchema = Type.Object({ ... })
export interface IOperationDefinition<...> extends OperationSpec<...> { ... }
```
## Rationale
1. **No ambiguity**`AccessControl` always refers to the type, `AccessControlSchema` always refers to the runtime schema. No mental overhead to distinguish them.
2. **TypeScript namespace separation is fragile** — while TypeScript technically allows the same name for a value and a type (they occupy different namespaces), this creates confusion when reading code. `const ac: AccessControl = ...` — which `AccessControl`? The schema or the type? With the `Schema` suffix, it's immediately clear.
3. **Consistent with TypeBox conventions** — TypeBox's own `Type.*` factory functions produce schema objects. Naming the const with a `Schema` suffix aligns with the mental model that these are schema definitions, not data instances.
4. **Export clarity** — in `index.ts`, both are exported. Having distinct names means `import { AccessControlSchema, AccessControl }` is immediately clear: one is a runtime value, one is a type.
5. **Existing pattern** — this convention is already used in `OperationDefinitionSchema` vs `IOperationDefinition` and `OperationSpecSchema` vs `OperationSpec` in the codebase.
## Consequences
- All schema const names end in `Schema`
- All type names are the bare concept name (or prefixed with `I` for interfaces)
- This applies to `AccessControlSchema`/`AccessControl`, `ErrorDefinitionSchema`/`ErrorDefinition`, `OperationContextSchema`/`OperationContext`, `OperationSpecSchema`/`OperationSpec`, `OperationDefinitionSchema`/`IOperationDefinition`
- When adding new schemas, follow this convention: `FooSchema` for the const, `Foo` for the type