Initial Commit
This commit is contained in:
114
openspec/changes/api-plugin-example-dual-shape/proposal.md
Normal file
114
openspec/changes/api-plugin-example-dual-shape/proposal.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Dual-shape api-plugin-example (trusted tier + WASI-stdio wasm tier)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-example` (refactor + additive subpath)
|
||||
**Status**: PROPOSED
|
||||
**Type**: in-package refactor + new `./wasm` subpath export
|
||||
**Blocks**: `wasm-compiler-javy` (keystone test needs this)
|
||||
|
||||
## Why
|
||||
|
||||
The wasm-compiler-javy build agent (2026-05-27 attempt 2) stopped with an architectural blocker: `@suaveplan/api-plugin-example` is a trusted-tier api-host plugin (Hono routes, OTel SDK Node, streaming responses, closure-injected ledger / publisher, `Request`-typed handlers) and cannot be compiled to WASM via Javy. Three independent walls:
|
||||
1. Plugin shape: exports `examplePluginModule: PluginModule` and `buildRoutes({ ledger, publisher })` — not `{ routes, handleRequest(SerializedRequest) }`.
|
||||
2. Bundling: transitive deps drag `@hono/node-server`, OTel SDK Node bits, `node:*` specifiers; `Bun.build({ target: "browser" })` trips its own gate.
|
||||
3. Runtime: Javy v8.1.1's QuickJS lacks usable `ReadableStream`/`WritableStream`, fetch, OTel SDK Node-isms.
|
||||
|
||||
Three options were considered (full transcript in the agent's stop report); the orchestrator chose **dual-shape**: keep the existing trusted-tier reference plugin AND add a WASI-stdio surface on the SAME package via a new `./wasm` subpath. This preserves the single reference-plugin name (`api-plugin-example` continues to be THE reference) and gives both tiers concrete deploy-targets that share a domain core. The wasm subpath has zero runtime overlap with the trusted exports — its dependency tree is disjoint from Hono/OTel-SDK/streaming.
|
||||
|
||||
## Concrete consumer
|
||||
|
||||
- `@suaveplan/wasm-compiler-javy` (next-up build) — keystone test compiles `@suaveplan/api-plugin-example/wasm` (NOT the root entry), produces a WASI-stdio `.wasm`, deploys it through `api-sandbox-wasm-host-bridge` with `abiVariant: "wasi-stdio"`.
|
||||
- `e2e/foundation-stack-full` Stage-8 closure smoke (gate 2.8) — exercises `api-plugin-example` under trusted + vm tiers AND `api-plugin-example/wasm` under wasm tier. All three isolation tiers covered by ONE reference name.
|
||||
|
||||
## Concrete host bridge
|
||||
|
||||
`@suaveplan/api-sandbox-wasm-host-bridge` (already shipped) — receives the produced `.wasm`, no changes needed.
|
||||
|
||||
## End-to-end test
|
||||
|
||||
- `packages/api-stack/api/api-plugin-example/tests/wasm/shape-conformance.test.ts` — boots the wasm subpath under a host-side WASI runner, exercises every route via `{kind:"request"}` envelopes, asserts each response matches the trusted-tier output for the equivalent endpoint (so the dual-shape isn't divergent semantics — it's two surfaces over one domain core).
|
||||
|
||||
## What changes
|
||||
|
||||
### Source layout (refactor)
|
||||
|
||||
```text
|
||||
packages/api-stack/api/api-plugin-example/src/
|
||||
index.ts ← unchanged trusted-tier barrel (preserves existing exports)
|
||||
domain/ ← NEW: pure, dependency-free invoice domain
|
||||
invoice.types.ts
|
||||
invoice.types.md
|
||||
invoice-rules.ts ← business rules (state transitions, validation)
|
||||
invoice-rules.md
|
||||
invoice-rules.test.ts
|
||||
in-memory-store.ts ← simple Map-backed store; no IO; deterministic
|
||||
in-memory-store.md
|
||||
in-memory-store.test.ts
|
||||
trusted/ ← existing routes/, plugin/, install/, events/ moved here
|
||||
index.ts ← internal barrel
|
||||
routes.ts ← references domain/ for business rules; Hono-tier handlers
|
||||
plugin.ts ← api-host PluginModule with hooks
|
||||
install.ts
|
||||
events.ts
|
||||
wasm/ ← NEW: WASI-stdio surface
|
||||
index.ts ← export const plugin = { routes, handleRequest, start, dispose }
|
||||
routes.ts ← export const routes: readonly SerializedRoute[]
|
||||
handle-request.ts ← (req: SerializedRequest) => SerializedResponse
|
||||
handle-request.md
|
||||
handle-request.test.ts
|
||||
start.ts ← () => void; primes in-memory store
|
||||
dispose.ts ← () => void; clears store
|
||||
wasm-state.ts ← module-singleton InMemoryStore instance
|
||||
```
|
||||
|
||||
The existing `error-codes/`, `instruments/`, `schemas/`, `events/` stay at the package root because they're shared between trusted and wasm tiers (only the trusted-tier instruments register against the OTel SDK; the wasm tier defines them as no-op shims to keep the API surface uniform).
|
||||
|
||||
### Public surface (additive — existing entry unchanged)
|
||||
|
||||
```jsonc
|
||||
// package.json exports (only the additions shown):
|
||||
{
|
||||
"./wasm": {
|
||||
"import": {
|
||||
"types": "./dist/wasm/index.d.ts",
|
||||
"default": "./dist/wasm/index.js"
|
||||
}
|
||||
},
|
||||
"./domain": {
|
||||
"import": {
|
||||
"types": "./dist/domain/index.d.ts",
|
||||
"default": "./dist/domain/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
plugin.routes; // readonly SerializedRoute[]
|
||||
await plugin.handleRequest(req); // SerializedRequest -> SerializedResponse
|
||||
plugin.start?.(); // optional
|
||||
plugin.dispose?.(); // optional
|
||||
```
|
||||
|
||||
### Dependency constraint on `wasm/`
|
||||
|
||||
The `wasm/` directory MUST NOT directly OR transitively import:
|
||||
- `@suaveplan/api-host`, `hono`, `@hono/*`
|
||||
- `@opentelemetry/sdk-metrics`, `@opentelemetry/sdk-trace-base`, `@opentelemetry/sdk-node`
|
||||
- Any `node:*` specifier
|
||||
- Any package whose dependency tree drags the above (transitive ban)
|
||||
|
||||
A new lint script `bun scripts/lint/check-wasm-subpath-deps.ts` (added as part of this change) enforces this by walking the imports from `wasm/index.ts` and rejecting any forbidden specifier. The script is wired into `bunx turbo lint`.
|
||||
|
||||
The `wasm/` directory MAY use `@opentelemetry/api` (types-only, browser-safe) for OTel context propagation if needed. It MAY use `zod` for envelope validation. It MAY use `@suaveplan/api-types/sandbox-bridge` for the wire-protocol types.
|
||||
|
||||
### Domain parity
|
||||
|
||||
The `tests/wasm/shape-conformance.test.ts` integration test asserts that for every route shared between trusted and wasm surfaces, calling the trusted handler with `Request("/foo", { method: "POST", body: JSON.stringify(x) })` and calling the wasm handler with `{ method: "POST", url: "/foo", headers: [...], bodyBase64: base64(JSON.stringify(x)), ... }` produces the SAME response status and the SAME response body. The shared `domain/` module is what guarantees this; the test verifies it stays true.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Streaming routes. The trusted-tier example has a `streamLargeReport` endpoint that returns a chunked `Response`. The wasm tier OMITS this endpoint — it's documented in `wasm/index.md` as "streaming endpoints don't apply under the wasm tier; use a polling endpoint or pagination instead". The shape-conformance test excludes it.
|
||||
- Plugin lifecycle hooks beyond `start`/`dispose`. The trusted tier's `health` / `uninstall` hooks have no wasm-tier analogue.
|
||||
- Changes to the existing trusted-tier exports. Anything imported from `@suaveplan/api-plugin-example` (root) keeps its current shape and dependency graph.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Capability Spec Delta: api-plugin-example (dual-shape)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-example`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: The package SHALL expose a `./wasm` subpath surfacing a WASI-stdio-shaped plugin
|
||||
|
||||
`@suaveplan/api-plugin-example/wasm` SHALL export `const plugin` whose shape is `{ routes: readonly SerializedRoute[]; handleRequest(req: SerializedRequest): Promise<SerializedResponse>; start?(): void | Promise<void>; dispose?(): void | Promise<void> }`. The wire types are imported from `@suaveplan/api-types/sandbox-bridge`.
|
||||
|
||||
#### Scenario: wasm subpath imports cleanly under browser-target bundling
|
||||
|
||||
- **WHEN** `Bun.build({ entrypoints: ["packages/api-stack/api/api-plugin-example/src/wasm/index.ts"], target: "browser", format: "esm" })` runs
|
||||
- **THEN** the build succeeds AND the produced ESM bundle imports no `node:*` specifier AND no Hono/OTel-SDK specifier
|
||||
|
||||
**Verified by:** `tests/wasm/browser-bundle.test.ts::"wasm subpath bundles for browser target"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: The `./wasm` subpath SHALL have a constrained dependency graph
|
||||
|
||||
A new lint script `bun scripts/lint/check-wasm-subpath-deps.ts` MUST walk every static import from `src/wasm/index.ts` transitively and reject any specifier matching the wasm-tier ban list: `@suaveplan/api-host`, `hono`, `@hono/*`, `@opentelemetry/sdk-metrics`, `@opentelemetry/sdk-trace-base`, `@opentelemetry/sdk-node`, any `node:*` specifier, and any package whose own dependency tree drags one of the above. The lint MUST be wired into `bunx turbo lint` so CI rejects accidental dependency creep.
|
||||
|
||||
#### Scenario: Adding `import "hono"` inside `src/wasm/` fails the lint
|
||||
|
||||
- **WHEN** a contributor adds `import { Hono } from "hono"` to any file reachable from `src/wasm/index.ts`
|
||||
- **THEN** `bun scripts/lint/check-wasm-subpath-deps.ts` exits non-zero with a message naming `hono` and the importing file
|
||||
|
||||
**Verified by:** `scripts/lint/check-wasm-subpath-deps.test.ts::"rejects banned specifier in wasm subpath"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Trusted-tier and wasm-tier surfaces SHALL share a single domain core
|
||||
|
||||
The package SHALL contain a `src/domain/` directory with pure (no IO, no Hono, no OTel SDK, no Request/Response) modules: `invoice.types.ts`, `invoice-rules.ts`, `in-memory-store.ts`. Both the trusted-tier routes (`src/trusted/`) AND the wasm-tier handlers (`src/wasm/`) MUST call the same rule functions from `src/domain/invoice-rules.ts` for any business decision (state transitions, validation, etc.).
|
||||
|
||||
#### Scenario: Trusted and wasm handlers produce identical bodies for the same input
|
||||
|
||||
- **WHEN** the same `POST /invoices` payload is dispatched through the trusted handler AND through `plugin.handleRequest(...)`
|
||||
- **THEN** both produce the same HTTP status AND the same JSON response body
|
||||
|
||||
**Verified by:** `tests/wasm/shape-conformance.test.ts::"trusted vs wasm parity for every shared route"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: The existing root export surface SHALL remain unchanged
|
||||
|
||||
The shipped `@suaveplan/api-plugin-example` exports (`examplePluginModule`, `buildRoutes`, `EXAMPLE_CODES`, error class) MUST keep their existing types and runtime semantics after the refactor. The internal `git mv` from `src/{routes,plugin,install,events}/` to `src/trusted/{routes,plugin,install,events}/` MUST be invisible to consumers — the root barrel re-exports the same names from the new locations.
|
||||
|
||||
#### Scenario: api-host's existing consumer of api-plugin-example still passes
|
||||
|
||||
- **WHEN** `bunx --bun turbo test:coverage --filter=@suaveplan/api-host` runs after the refactor
|
||||
- **THEN** every test that imports `@suaveplan/api-plugin-example` still passes AND no test file in `api-host/` requires source modification
|
||||
|
||||
**Verified by:** `tests/trusted/root-export-shape.test.ts::"root exports preserve existing types and names"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: The wasm subpath SHALL omit endpoints that cannot run under the sandbox tier
|
||||
|
||||
The wasm-tier `routes` array MUST NOT include endpoints that require streaming responses, server-sent events, or any other transport pattern incompatible with the synchronous `(SerializedRequest) => SerializedResponse` contract. The trusted-tier `streamLargeReport` endpoint is the canonical example — it stays in the trusted-tier exports and is documented in `src/wasm/index.md` as a deliberate omission.
|
||||
|
||||
#### Scenario: Wasm routes contain no streaming endpoint
|
||||
|
||||
- **WHEN** the wasm `routes` array is inspected
|
||||
- **THEN** no entry's `path` matches `/stream/*` or any documented streaming pattern
|
||||
|
||||
**Verified by:** `src/wasm/routes.test.ts::"wasm routes omit streaming endpoints"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Lifecycle hooks SHALL initialise and tear down the in-memory store deterministically
|
||||
|
||||
`plugin.start()` SHALL prime the module-singleton `InMemoryStore` with the same seed data the trusted-tier `install` hook uses. `plugin.dispose()` SHALL clear the store. Repeated calls to either MUST be idempotent (the second call MUST NOT throw).
|
||||
|
||||
#### Scenario: Dispose then start re-primes the store
|
||||
|
||||
- **WHEN** `plugin.dispose()` runs, then `plugin.start()` runs
|
||||
- **THEN** the wasm-tier `GET /invoices` route returns the seeded list of invoices
|
||||
|
||||
**Verified by:** `src/wasm/start.test.ts::"start re-primes after dispose"`
|
||||
64
openspec/changes/api-plugin-example-dual-shape/tasks.md
Normal file
64
openspec/changes/api-plugin-example-dual-shape/tasks.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Tasks — Dual-shape api-plugin-example
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Clean working tree; account for every `.claude/worktrees/agent-*`
|
||||
- [x] 0.2 `MANIFEST.md` package count matches `find packages -maxdepth 3 -name package.json`
|
||||
- [x] 0.3 §0a Sync: worktree-isolated agent fast-forwards to orchestrator-supplied SHA
|
||||
|
||||
## 1. Extract the pure-domain core
|
||||
|
||||
- [x] 1.1 Author `src/domain/invoice.types.ts` + `.md` — pure type definitions (Invoice, InvoiceState, InvoiceLine, etc.) extracted from current `schemas/` / `routes/` / `events/`. Zero deps beyond `@suaveplan/api-types`, `@suaveplan/error`.
|
||||
- [x] 1.2 Author `src/domain/invoice-rules.ts` + `.md` + `.test.ts` — state-transition machine, validation, plus any pure business rules the existing routes perform inline. NO Hono, NO OTel SDK, NO Request/Response.
|
||||
- [x] 1.3 Author `src/domain/in-memory-store.ts` + `.md` + `.test.ts` — deterministic Map-backed store with `create / get / list / setState / delete`. Used by the wasm subpath.
|
||||
- [x] 1.4 `src/domain/index.ts` — explicit named re-exports (rule 10 — no `export *`).
|
||||
- [x] 1.5 100% istanbul coverage on every domain module.
|
||||
|
||||
## 2. Move existing source under `trusted/`
|
||||
|
||||
- [x] 2.1 `git mv` every existing `src/routes/`, `src/plugin/`, `src/install/` (if present), `src/events/` directory under `src/trusted/`. Preserve git history via `git mv`.
|
||||
- [x] 2.2 Update all internal imports in those files to use the new paths.
|
||||
- [x] 2.3 Update existing route handlers to delegate to `src/domain/invoice-rules.ts` for business rules — they SHOULD call the same rule functions the wasm tier will call. This is the contract that makes shape-conformance pass.
|
||||
- [x] 2.4 `src/trusted/index.ts` — internal barrel; re-exports `examplePluginModule` + `buildRoutes` for the root `src/index.ts` to forward.
|
||||
- [x] 2.5 Existing `src/error-codes/`, `src/instruments/`, `src/schemas/` stay at the package root (shared between tiers). Existing exports remain unchanged.
|
||||
|
||||
## 3. Author the wasm-tier surface
|
||||
|
||||
- [x] 3.1 `src/wasm/wasm-state.ts` + `.md` + `.test.ts` — module-singleton `InMemoryStore` instance; `resetState()` helper for test isolation.
|
||||
- [x] 3.2 `src/wasm/routes.ts` + `.md` + `.test.ts` — `export const routes: readonly SerializedRoute[]` listing every endpoint the wasm tier serves (excluding `streamLargeReport`).
|
||||
- [x] 3.3 `src/wasm/handle-request.ts` + `.md` + `.test.ts` — `(req: SerializedRequest) => Promise<SerializedResponse>`. Routing is a simple `method + pathname` lookup against `routes`. Handlers delegate to `src/domain/invoice-rules.ts`. Body decoded from `bodyBase64`; response body re-encoded to `bodyBase64`. Headers shaped as `ReadonlyArray<[string, string]>`. `SuaveplanError`-shaped throws are NOT caught here — the WASI-stdio ABI shim (in wasm-compiler-javy) catches them and emits `{kind:"error"}` envelopes.
|
||||
- [x] 3.4 `src/wasm/start.ts` + `.md` + `.test.ts` — primes the in-memory store with the same seed data the trusted-tier `install` hook uses.
|
||||
- [x] 3.5 `src/wasm/dispose.ts` + `.md` + `.test.ts` — clears the in-memory store.
|
||||
- [x] 3.6 `src/wasm/index.ts` — `export const plugin = { routes, handleRequest, start, dispose } as const`. Explicit named re-exports (no `export *`).
|
||||
|
||||
## 4. Subpath exports + dep-graph lint
|
||||
|
||||
- [x] 4.1 `package.json` — add `./wasm` AND `./domain` subpath exports per the proposal.
|
||||
- [x] 4.2 `tsconfig.lib.json` — confirm new subpaths build into `dist/{wasm,domain}/`.
|
||||
- [x] 4.3 `scripts/lint/check-wasm-subpath-deps.ts` (NEW) — walks every static import from `src/wasm/index.ts` transitively; rejects any specifier matching the wasm-tier ban list (`@suaveplan/api-host`, `hono`, `@hono/*`, `@opentelemetry/sdk-*`, `node:*`, plus any package whose graph includes those). Wired into `bunx turbo lint` as a new `lint:wasm-subpath-deps` task. Add a corresponding task entry in `turbo.json` and a top-level `package.json` script (`lint:wasm-subpath-deps`).
|
||||
- [x] 4.4 Run the new lint script — must pass against the new layout.
|
||||
|
||||
## 5. Conformance test
|
||||
|
||||
- [x] 5.1 `tests/wasm/shape-conformance.test.ts` — for every route shared between tiers, assert that:
|
||||
- `await handleTrustedRequest(new Request(...))` returns the same `status` + body bytes as
|
||||
- `await plugin.handleRequest({ ...serializedRequest equivalent ... })`.
|
||||
- The `streamLargeReport` endpoint is excluded with a documented `it.skip` and an explanatory comment.
|
||||
- [x] 5.2 100% istanbul coverage on every wasm/* file.
|
||||
- [x] 5.3 Public-constructor error class preserved; zero `// biome-ignore`; every test imports from `@suaveplan/testing/runner`.
|
||||
|
||||
## 6. Cross-package check
|
||||
|
||||
- [x] 6.1 The existing root exports (`@suaveplan/api-plugin-example` → trusted-tier exports) MUST remain byte-compatible. Run `bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-host --filter=@suaveplan/api-plugin-example` and confirm api-host's consumers of api-plugin-example still pass.
|
||||
- [x] 6.2 Author `tests/trusted/root-export-shape.test.ts` — snapshot assertion that the root barrel still exports `examplePluginModule`, `buildRoutes`, `EXAMPLE_CODES`, `ExamplePluginError` with their existing types. Pinned guard against accidental signature drift during the refactor.
|
||||
|
||||
## 7. Gate
|
||||
|
||||
- [x] 7.1 `bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-plugin-example --filter=@suaveplan/api-host --filter=@suaveplan/api-types --filter=@suaveplan/api-sandbox-wasm-host-bridge --concurrency=4` green
|
||||
- [x] 7.2 `bun scripts/folder-reorg/generate-manifest.ts` — package count unchanged (same package, additive subpath).
|
||||
|
||||
## 8. Archive (orchestrator step)
|
||||
|
||||
- [ ] 8.1 Fold delta into `openspec/specs/api-plugin-example/spec.md` (create if not yet present).
|
||||
- [ ] 8.2 Move folder → `openspec/archive/2026-05-27-api-plugin-example-dual-shape/`.
|
||||
- [ ] 8.3 Refresh `MANIFEST.md`.
|
||||
Reference in New Issue
Block a user