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:
35
docs/architecture/decisions/001-logger-direct-import.md
Normal file
35
docs/architecture/decisions/001-logger-direct-import.md
Normal 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)
|
||||
48
docs/architecture/decisions/002-fs-injection.md
Normal file
48
docs/architecture/decisions/002-fs-injection.md
Normal 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
|
||||
54
docs/architecture/decisions/003-peer-dep-adapters.md
Normal file
54
docs/architecture/decisions/003-peer-dep-adapters.md
Normal 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
|
||||
50
docs/architecture/decisions/004-schema-const-naming.md
Normal file
50
docs/architecture/decisions/004-schema-const-naming.md
Normal 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
|
||||
Reference in New Issue
Block a user