first commit
This commit is contained in:
105
docs/api-stack/api/api-plugin-example/README.md
Normal file
105
docs/api-stack/api/api-plugin-example/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @suaveplan/api-plugin-example - Reference plugin for the @suaveplan/api-host plugin system. Exercises every contract surface reachable through createTestHost: definePlugin spec, REST routes (public, RBAC-gated, Zod-validated, streaming, idempotent, rate-limited, schema-violation), custom PLUGIN.EXAMPLE.* error codes mapped to HTTP statuses, manifest-declared OTel instruments (counter + histogram + gauge), seeded secrets, tenant-scoped DB, event publish + subscribe handlers. Ships its own integration suite that runs end-to-end against api-host/testing.createTestHost so any host-side change that breaks the plugin contract fails in one place.
|
||||
|
||||
Reference plugin for the `@suaveplan/api-host` plugin system. The
|
||||
package is a load-bearing integration target, not a tutorial: every
|
||||
contract surface the host exposes through `createTestHost` is wired
|
||||
through one route or one hook here, and the package's own integration
|
||||
test runs that surface end-to-end. When a host-side change breaks the
|
||||
plugin contract, this test fails in one place against the in-memory
|
||||
stack.
|
||||
|
||||
The plugin doubles as the canonical onboarding doc. When a plugin
|
||||
author asks "how do I use capability X?", the answer is "look at how
|
||||
`api-plugin-example` uses it."
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The reference plugin owns a small set of stable error codes that the integration suite asserts are surfaced through the…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The reference plugin owns a small set of stable error codes that the integration suite asserts are surfaced through the…
|
||||
- [events](./src/events/events.md) — The reference plugin both publishes and subscribes to events. This module exposes the topic names and the Zod payload s…
|
||||
- [events](./src/events/events.md) — The reference plugin both publishes and subscribes to events. This module exposes the topic names and the Zod payload s…
|
||||
- [instruments](./src/instruments/instruments.md) — The reference plugin declares three OTel instruments that mirror the `telemetry.metrics[]` block in `plugin.json`. The…
|
||||
- [instruments](./src/instruments/instruments.md) — The reference plugin declares three OTel instruments that mirror the `telemetry.metrics[]` block in `plugin.json`. The…
|
||||
- [plugin](./src/plugin/plugin.md) — The reference plugin's `definePlugin` invocation lives in its own module so the manifest, the integration test, and any…
|
||||
- [plugin](./src/plugin/plugin.md) — The reference plugin's `definePlugin` invocation lives in its own module so the manifest, the integration test, and any…
|
||||
- [routes](./src/routes/routes.md) — The route table is the heart of the reference plugin. Every route demonstrates one piece of the §6 11-step request pipe…
|
||||
- [routes](./src/routes/routes.md) — The route table is the heart of the reference plugin. Every route demonstrates one piece of the §6 11-step request pipe…
|
||||
- [schemas](./src/schemas/schemas.md) — Every route in the reference plugin is wired against a Zod schema so the §6 input-validation step always has a contract…
|
||||
- [schemas](./src/schemas/schemas.md) — Every route in the reference plugin is wired against a Zod schema so the §6 input-validation step always has a contract…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Capability map (scope, against `createTestHost`)
|
||||
|
||||
| Capability | Surface in this package |
|
||||
| --------------------------- | -------------------------------------------------------------------------- |
|
||||
| `definePlugin` lifecycle | `src/plugin/plugin.ts` — install / start / stop / uninstall / health all wired |
|
||||
| REST — public | `GET /example/public/ping`, `GET /example/health` |
|
||||
| REST — RBAC-gated | `GET /example/invoices`, `GET /example/invoices/:id`, `POST /example/invoices` |
|
||||
| REST — Zod-validated body | `POST /example/invoices` against `CreateInvoiceRequest` |
|
||||
| REST — idempotent flag | `POST /example/invoices` (declared in `plugin.json` — host gate) |
|
||||
| REST — rate-limited | `GET /example/rate-limited` (declared in `plugin.json` — host gate) |
|
||||
| REST — streaming | `GET /example/stream/large-report` (chunked NDJSON) |
|
||||
| REST — custom error codes | `PLUGIN.EXAMPLE.{SUBSCRIPTION_INACTIVE, INVOICE_NOT_FOUND, INVALID_INVOICE_STATE}` mapped in `errors.statusMap` |
|
||||
| REST — response validation | `GET /_/test/fail-response?failResponse=1` (intentional schema violation) |
|
||||
| Event publish | `example.invoice.created` on every `POST /example/invoices` happy path |
|
||||
| Event subscribe | `users.user.deleted` via `makeUserDeletedHandler` |
|
||||
| Secrets | `EXAMPLE_STRIPE_KEY`, `EXAMPLE_HMAC_SIGNING_KEY`, `EXAMPLE_WEBHOOK_TOKEN` |
|
||||
| Tenant resolution | Seeded `tenant-int` reached via `X-Tenant-Id` |
|
||||
| OTel instruments | `example.invoices_finalised_total`, `example.stripe.call_duration_ms`, `example.outstanding_invoice_count` |
|
||||
|
||||
## Capability map (declared in `plugin.json` for future Stages)
|
||||
|
||||
These surfaces are present in `plugin.json` because the design doc
|
||||
§13.5 reference plugin SHALL declare every top-level manifest key.
|
||||
The runtime implementations land alongside the host substrates as
|
||||
those substrates ship in subsequent changes.
|
||||
|
||||
| Capability | Declared in `plugin.json` | Host substrate status |
|
||||
| --------------------------- | ------------------------------------------------------------------ | ------------------------------------ |
|
||||
| GraphQL types + subscription | `graphql.queries` / `mutations` / `subscriptions` | `@suaveplan/api-graphql-yoga` lands later |
|
||||
| WebSocket fan-out (multi-host) | `websockets[]` with topic mapping | `@suaveplan/api-ws-bun` + broker shipped; cross-host federation later |
|
||||
| SSE with `Last-Event-Id` | `sse[]` with `resume: "last-event-id"` | `@suaveplan/api-sse-bun` shipped; resume wiring later |
|
||||
| Saga (3-step) + 2PC | `sagas[]` + `twoPhaseCommit` | `@suaveplan/api-saga` + `api-2pc` orchestrators land later |
|
||||
| Migrations (v1 → v2) | `database.schemaVersion: 2` + `migrationsDir` | `@suaveplan/api-migrations` rollback driver lands later |
|
||||
| Sandboxed (vm + wasm) | Future: rebuild against `api-sandbox-vm` / `api-sandbox-wasm` | Both shipped; isolation wiring through `createHost` lands later |
|
||||
| Per-tenant cron + queued jobs | `jobs.cron[]` (incl. `perTenant`) + `jobs.queued[]` | `@suaveplan/job-scheduler` already lives universal; host bridge later |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-plugin-example
|
||||
```
|
||||
|
||||
The integration suite at
|
||||
`tests/integration/full-flow.integration.test.ts` runs against
|
||||
`createTestHost` with the seeded principal, tenants, and secrets — no
|
||||
external services required.
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
plugin.json # Manifest declaring every top-level key
|
||||
src/index.ts # Package barrel
|
||||
src/plugin/plugin.ts # Frozen definePlugin record
|
||||
src/routes/routes.ts # REST surface (10 routes)
|
||||
src/schemas/schemas.ts # Zod request / response schemas
|
||||
src/events/events.ts # Topic constants + handler factories
|
||||
src/error-codes/error-codes.ts # PLUGIN.EXAMPLE.* registry + status map
|
||||
src/instruments/instruments.ts # OTel counter + histogram + gauge-as-counter
|
||||
tests/integration/full-flow.integration.test.ts
|
||||
# End-to-end against createTestHost
|
||||
tests/setup/polyfills.ts # In-memory MeterProvider install
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
The acceptance gate runs:
|
||||
|
||||
```bash
|
||||
bunx --bun turbo lint typecheck test:coverage --filter=@suaveplan/api-plugin-example
|
||||
```
|
||||
|
||||
100% Istanbul coverage is enforced; the manifest `errors.statusMap`
|
||||
is verified end-to-end against the runtime by the integration suite.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# in-memory-store
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic `Map`-backed invoice store consumed by every business
|
||||
rule in `invoice-rules.ts`. The store performs zero IO and runs
|
||||
identically on Node, Bun, and Javy/QuickJS — both the trusted-tier
|
||||
route handlers and the wasm-tier `handleRequest` hold one of these as
|
||||
their single source of truth for invoice persistence.
|
||||
|
||||
The store exposes the minimal vocabulary the rules need: monotonic id
|
||||
allocation, create/get/list/setState/delete, plus `clear()` for
|
||||
disposal and `setCounter()` for test pinning. Keeping the surface small
|
||||
makes the shape-conformance test trivial — both tiers issue the same
|
||||
sequence of store operations for the same input.
|
||||
|
||||
## Features
|
||||
|
||||
- Monotonic `inv-N` id allocator (`nextId()`).
|
||||
- Strict create: throws when the id already exists.
|
||||
- Get / list / setState / delete primitives.
|
||||
- Bulk `clear()` for the wasm-tier `dispose()` hook.
|
||||
- `setCounter(n)` for deterministic id pinning in tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInMemoryStore } from "@suaveplan/api-plugin-example/domain";
|
||||
|
||||
const store = createInMemoryStore();
|
||||
const inv = store.create({ id: store.nextId(), status: "open", amountCents: 1500, currency: "USD" });
|
||||
console.log(store.list()); // [inv]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createInMemoryStore } from "@suaveplan/api-plugin-example/domain";
|
||||
|
||||
const store = createInMemoryStore();
|
||||
store.setCounter(99); // next id will be inv-100
|
||||
const seeded = store.create({ id: store.nextId(), status: "open", amountCents: 1, currency: "USD" });
|
||||
store.setState(seeded.id, "paid");
|
||||
store.delete(seeded.id);
|
||||
store.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInMemoryStore(): InMemoryStore` — factory.
|
||||
- `nextId(): string` — `inv-1`, `inv-2`, ...
|
||||
- `create(invoice): Invoice` — inserts, throws on duplicate id.
|
||||
- `get(id): Invoice | undefined`.
|
||||
- `list(): readonly Invoice[]` — insertion-ordered snapshot.
|
||||
- `setState(id, state): Invoice` — throws when id absent.
|
||||
- `delete(id): boolean` — `true` when removed.
|
||||
- `clear(): void` — erases every entry and resets the counter.
|
||||
- `setCounter(value): void` — pin counter; next `nextId()` returns `inv-${value+1}`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The store is a closure over a private `Map` and a private counter — no
|
||||
prototype methods, no `this` binding, no shared global state. Tests
|
||||
instantiate a fresh store per test for isolation; the wasm-tier holds
|
||||
a module-singleton instance because the WASI-stdio surface has no
|
||||
request-scoped context to thread one through.
|
||||
@@ -0,0 +1,85 @@
|
||||
# invoice-rules
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure business rules consumed by both the trusted-tier route handlers
|
||||
and the wasm-tier `handleRequest`. Every state transition, validation
|
||||
rule, and lookup goes through one of the functions in this module so
|
||||
the two surfaces cannot drift in semantics — the shape-conformance
|
||||
test asserts response equality across both tiers and this is the
|
||||
shared core that guarantees it.
|
||||
|
||||
All rules are synchronous, take a domain value or a store reference,
|
||||
and either return a new domain value or throw an `ExamplePluginError`
|
||||
carrying a stable code. Adapter-tier work (Hono `Request` parsing,
|
||||
OTel metric recording, response envelope construction) lives at the
|
||||
boundary in each tier.
|
||||
|
||||
## Features
|
||||
|
||||
- `CREATE_INVOICE_INPUT_SHAPE` — declarative schema for the create-invoice body (no Zod).
|
||||
- `parseCreateInvoiceInput(raw)` — runs `assertShape` and returns a `safeParse`-shaped discriminated union.
|
||||
- `createInvoice(store, input)` — allocates an id, inserts as `"open"`.
|
||||
- `getInvoiceOrThrow(store, id)` — throws `INVOICE_NOT_FOUND` when absent.
|
||||
- `listInvoices(store)` — snapshot list.
|
||||
- `finaliseInvoice(store, id)` — FSM transition `open → paid`, rejects re-finalisation.
|
||||
- `raiseSubscriptionInactive()` — throws the canonical 409.
|
||||
- `buildFailResponsePayload(fail)` — payload for the response-validation gate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryStore,
|
||||
createInvoice,
|
||||
finaliseInvoice,
|
||||
} from "@suaveplan/api-plugin-example/domain";
|
||||
|
||||
const store = createInMemoryStore();
|
||||
const opened = createInvoice(store, { customerId: "c-1", amountCents: 1500, currency: "USD" });
|
||||
const paid = finaliseInvoice(store, opened.id);
|
||||
console.log(paid.status); // "paid"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryStore,
|
||||
finaliseInvoice,
|
||||
parseCreateInvoiceInput,
|
||||
} from "@suaveplan/api-plugin-example/domain";
|
||||
import { EXAMPLE_CODES, ExamplePluginError } from "@suaveplan/api-plugin-example";
|
||||
|
||||
const store = createInMemoryStore();
|
||||
const parsed = parseCreateInvoiceInput({ customerId: "c", amountCents: 1, currency: "USD" });
|
||||
if (parsed.success) {
|
||||
try {
|
||||
finaliseInvoice(store, "missing");
|
||||
} catch (e) {
|
||||
if (e instanceof ExamplePluginError && e.code === EXAMPLE_CODES.INVOICE_NOT_FOUND) {
|
||||
// host adapter maps to HTTP 404
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CREATE_INVOICE_INPUT_SHAPE: Schema` — the descriptor consumed by `assertShape`.
|
||||
- `parseCreateInvoiceInput(raw): { success: true; data } | { success: false; issues }`.
|
||||
- `createInvoice(store, input): Invoice`.
|
||||
- `getInvoiceOrThrow(store, id): Invoice` — throws `INVOICE_NOT_FOUND`.
|
||||
- `listInvoices(store): readonly Invoice[]`.
|
||||
- `finaliseInvoice(store, id): Invoice` — throws `INVOICE_NOT_FOUND` or `INVALID_INVOICE_STATE`.
|
||||
- `raiseSubscriptionInactive(): never` — throws `SUBSCRIPTION_INACTIVE`.
|
||||
- `buildFailResponsePayload(fail): { id; status; amountCents; currency }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Errors are thrown via `ExamplePluginError`, which extends the shared
|
||||
`SuaveplanError`. The status-mapping lives in `error-codes/error-codes.ts`
|
||||
(`EXAMPLE_STATUS_MAP`); both tier adapters consult that map at the
|
||||
serialisation boundary so the same code → status mapping applies on
|
||||
trusted and wasm paths. The rule module itself has zero knowledge of
|
||||
HTTP status.
|
||||
@@ -0,0 +1,64 @@
|
||||
# invoice.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the pure domain types — `Invoice`, `InvoiceState`,
|
||||
`CreateInvoiceInput`, `FinaliseInvoiceResult` — shared between the
|
||||
trusted-tier and wasm-tier surfaces of `@suaveplan/api-plugin-example`.
|
||||
Pure means no IO, no Hono types, no `Request`/`Response` references,
|
||||
no OTel SDK imports — these types round-trip through JSON cleanly so
|
||||
the wasm-tier can marshal them through `SerializedRequest` /
|
||||
`SerializedResponse` bodies with no additional reshaping.
|
||||
|
||||
The types live in `src/domain/` so the dependency-graph lint
|
||||
(`scripts/lint/check-wasm-subpath-deps.ts`) can confirm the wasm
|
||||
subpath's import closure contains zero references to Hono, the OTel
|
||||
SDK, or `node:*` specifiers. Both tiers import the same names from this
|
||||
file and the two surfaces stay byte-compatible.
|
||||
|
||||
## Features
|
||||
|
||||
- `InvoiceState` — the lifecycle FSM symbols (`draft`/`open`/`paid`).
|
||||
- `Invoice` — the canonical persisted record (id, status, amount, currency).
|
||||
- `CreateInvoiceInput` — accepted shape on `POST /example/invoices`.
|
||||
- `FinaliseInvoiceResult` — alias for `Invoice` returned by `finaliseInvoice`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { Invoice, InvoiceState } from "@suaveplan/api-plugin-example/domain";
|
||||
|
||||
const inv: Invoice = {
|
||||
id: "inv-1",
|
||||
status: "open",
|
||||
amountCents: 1500,
|
||||
currency: "USD",
|
||||
};
|
||||
const next: InvoiceState = "paid";
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CreateInvoiceInput,
|
||||
Invoice,
|
||||
} from "@suaveplan/api-plugin-example/domain";
|
||||
|
||||
function asInvoice(input: CreateInvoiceInput, id: string): Invoice {
|
||||
return { id, status: "open", amountCents: input.amountCents, currency: input.currency };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type InvoiceState = "draft" | "open" | "paid"`.
|
||||
- `interface Invoice` — `id`, `status`, `amountCents`, `currency`.
|
||||
- `interface CreateInvoiceInput` — `customerId`, `amountCents`, `currency`.
|
||||
- `type FinaliseInvoiceResult = Invoice`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All four type symbols are pure type aliases — they do not survive
|
||||
emission. The runtime store enforces the FSM via the rule functions in
|
||||
`invoice-rules.ts`; this file is purely the type contract.
|
||||
@@ -0,0 +1,94 @@
|
||||
# assert-shape
|
||||
|
||||
## Purpose
|
||||
|
||||
Hand-rolled validator that walks an unknown value against a `Schema`
|
||||
descriptor and returns a `safeParse`-shaped discriminated union. This
|
||||
exists so the wasm-tier surface of `@suaveplan/api-plugin-example` can
|
||||
validate inbound request bodies WITHOUT importing Zod — Zod's runtime
|
||||
writes `globalThis.__zod_globalConfig` at module-evaluation time, which
|
||||
the `@suaveplan/api-sandbox-vm` host's frozen-globals Proxy guard
|
||||
rejects with `SAND_VM.EVAL_REJECTED`.
|
||||
|
||||
The checker is exhaustive over the descriptor vocabulary declared in
|
||||
`./schema.types.ts` — adding a new descriptor kind to the union without
|
||||
also adding a case here is a TypeScript compile error, which keeps the
|
||||
parity between the descriptor surface and the runtime checker mechanical.
|
||||
|
||||
## Features
|
||||
|
||||
- `assertShape<T>(value, schema)` → `{ success: true; data } | { success: false; issues }`.
|
||||
- Issue records carry `path` (root-to-leaf), `code` (stable kebab-case),
|
||||
and `message` (human-readable) — Zod-compatible shape.
|
||||
- Composes objects, arrays, optional fields, enum strings, length/range
|
||||
constraints, and positive-integer numbers.
|
||||
- Zero closures over outer bindings, zero `globalThis` writes — bundle-safe
|
||||
for both the wasm tier and the vm tier.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { assertShape } from "@suaveplan/api-plugin-example/domain/validators/assert-shape";
|
||||
import type { Schema } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
|
||||
|
||||
const Shape: Schema = {
|
||||
kind: "object",
|
||||
fields: { name: { kind: "string", min: 1 } },
|
||||
};
|
||||
|
||||
const result = assertShape<{ name: string }>({ name: "ok" }, Shape);
|
||||
if (result.success) console.log(result.data.name);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { assertShape } from "@suaveplan/api-plugin-example/domain/validators/assert-shape";
|
||||
import type { Schema } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
|
||||
|
||||
interface Bulk {
|
||||
readonly rows: ReadonlyArray<{ type: "create" | "delete" }>;
|
||||
}
|
||||
|
||||
const Shape: Schema = {
|
||||
kind: "object",
|
||||
fields: {
|
||||
rows: {
|
||||
kind: "array",
|
||||
items: {
|
||||
kind: "object",
|
||||
fields: {
|
||||
type: { kind: "oneOf", values: ["create", "delete"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = assertShape<Bulk>({ rows: [{ type: "wat" }] }, Shape);
|
||||
if (!result.success) {
|
||||
for (const issue of result.issues) {
|
||||
console.error(issue.path.join("."), issue.code, issue.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `assertShape<T>(value: unknown, schema: Schema): AssertShapeResult<T>`
|
||||
- `AssertShapeResult<T> = { success: true; data: T } | { success: false; issues: ReadonlyArray<ValidationIssue> }`
|
||||
- Issue codes emitted: `invalid_type`, `too_small`, `invalid_length`,
|
||||
`invalid_enum_value`, `not_integer`, `required`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The walker collects every failing leaf rather than short-circuiting on
|
||||
the first error — this matches Zod's default `safeParse` behaviour and
|
||||
keeps the response payload predictable for the §6 step-4 input
|
||||
validation gate which renders the full issue list back to the caller.
|
||||
|
||||
The checker does not call `JSON.parse` — callers are expected to feed it
|
||||
already-parsed JS values (`parseRequestBody` in `wasm/handle-request.ts`
|
||||
performs the parse one layer up). This keeps the checker pure data
|
||||
manipulation, which in turn keeps it bundle-stable across the wasm and
|
||||
vm tiers.
|
||||
@@ -0,0 +1,90 @@
|
||||
# schema.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declarative descriptor vocabulary the wasm-tier handler uses to validate
|
||||
inbound request bodies WITHOUT pulling Zod into its dependency graph. The
|
||||
`@suaveplan/api-sandbox-vm` host rejects any module that mutates
|
||||
`globalThis` at evaluation time, and Zod's runtime installs
|
||||
`globalThis.__zod_globalConfig` on import — which means the same
|
||||
`api-plugin-example/src/wasm/index.ts` source could not be deployed in
|
||||
both the wasm tier and the vm tier as long as Zod was reachable from the
|
||||
import closure. This module is the typed surface; the matching runtime
|
||||
checker lives in `./assert-shape.ts`.
|
||||
|
||||
The descriptors compose: an `object` whose field is an `array` whose
|
||||
item is an `object` with a `string` field is a valid `Schema`. The
|
||||
checker walks the descriptor tree breadth-first and emits one
|
||||
`ValidationIssue` per failing leaf, so error reporting is shape-equivalent
|
||||
to Zod's `safeParse(...).error.issues` for the small subset of validators
|
||||
the example plugin needs (`string`, `string.min`, `string.length`,
|
||||
`number.int.positive`, `oneOf`, `array`, `object`, `optional`).
|
||||
|
||||
## Features
|
||||
|
||||
- `PrimitiveType` — atomic `"string" | "number" | "integer" | "boolean"`.
|
||||
- `MinLengthString` / `ExactLengthString` / `OneOfString` — string refinements.
|
||||
- `PositiveInteger` — `number.int.positive` shorthand.
|
||||
- `ArrayOf` / `ObjectOf` — composite descriptors.
|
||||
- `OptionalField` — wrapper marking a field as optional.
|
||||
- `Schema` — discriminated union of every descriptor atom.
|
||||
- `ValidationIssue` — emitted failure record (path + code + message).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { Schema } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
|
||||
|
||||
const CreateInvoiceShape: Schema = {
|
||||
kind: "object",
|
||||
fields: {
|
||||
customerId: { kind: "string", min: 1 },
|
||||
amountCents: { kind: "positiveInteger" },
|
||||
currency: { kind: "length", length: 3 },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { Schema, ValidationIssue } from "@suaveplan/api-plugin-example/domain/validators/schema.types";
|
||||
|
||||
const BulkShape: Schema = {
|
||||
kind: "object",
|
||||
fields: {
|
||||
rows: {
|
||||
kind: "array",
|
||||
items: {
|
||||
kind: "object",
|
||||
fields: {
|
||||
type: { kind: "oneOf", values: ["create", "delete"] },
|
||||
note: { kind: "optional", inner: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function render(issues: ReadonlyArray<ValidationIssue>): string {
|
||||
return issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.code} (${i.message})`)
|
||||
.join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Schema` — the discriminated-union descriptor.
|
||||
- `ValidationIssue { path; code; message }` — emitted failure record.
|
||||
- Atom kinds: `string` (with `min`), `oneOf`, `length`, `positiveInteger`,
|
||||
`array`, `object`, `optional`, plus bare primitive strings.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The vocabulary is intentionally narrow — only the constructs reachable
|
||||
from `parseCreateInvoiceInput` (and any future input the example plugin
|
||||
adds). When a new validator atom is needed (e.g. `email` format), add
|
||||
the descriptor here AND the matching case in `assert-shape.ts`; the
|
||||
plugin's tests will fail closed for any missing case via the
|
||||
`exhaustiveness` assertion in the checker.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `error-codes` — `PLUGIN.EXAMPLE.*` registry + status map
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reference plugin owns a small set of stable error codes that the
|
||||
integration suite asserts are surfaced through the host's error
|
||||
boundary with the manifest-declared HTTP statuses. The registry lives
|
||||
in a single file so the runtime, the manifest, and the integration
|
||||
tests share one source of truth.
|
||||
|
||||
The plugin throws an {@link ExamplePluginError} carrying a
|
||||
{@link ExamplePluginCode}. The host error boundary consults
|
||||
{@link EXAMPLE_STATUS_MAP} to compute the response status. The map is
|
||||
mirrored verbatim in `plugin.json` under `errors.statusMap` so a code
|
||||
appearing on one side and missing on the other fails the
|
||||
`validate-error-status-map-shape` manifest validator.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated `ExamplePluginCode` union — every code one literal.
|
||||
- Frozen `EXAMPLE_CODES` constant for index-by-name access.
|
||||
- `EXAMPLE_STATUS_MAP` aligned with `plugin.json`.
|
||||
- Plain `Error` subclass — no heavyweight `SuaveplanError` import.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
EXAMPLE_CODES,
|
||||
ExamplePluginError,
|
||||
} from "@suaveplan/api-plugin-example/error-codes/error-codes";
|
||||
|
||||
if (invoice === null) {
|
||||
throw new ExamplePluginError(
|
||||
EXAMPLE_CODES.INVOICE_NOT_FOUND,
|
||||
`invoice ${id} not found`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The integration test reads the registry directly to assert every code
|
||||
is raised by at least one production path:
|
||||
|
||||
```ts
|
||||
import {
|
||||
EXAMPLE_CODES,
|
||||
EXAMPLE_STATUS_MAP,
|
||||
} from "@suaveplan/api-plugin-example/error-codes/error-codes";
|
||||
|
||||
for (const code of Object.values(EXAMPLE_CODES)) {
|
||||
expect(EXAMPLE_STATUS_MAP[code]).toBeDefined();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EXAMPLE_CODES` — Frozen `Record<string, ExamplePluginCode>`.
|
||||
- `EXAMPLE_STATUS_MAP` — Frozen `Record<ExamplePluginCode, number>`.
|
||||
- `ExamplePluginError(code, message)` — Plain `Error` subclass with
|
||||
`code` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ExamplePluginCode` is a string-literal union, not a `enum`, so the
|
||||
runtime cost is zero and the values appear unchanged in stack traces.
|
||||
The registry is `Object.freeze`d to prevent silent drift between the
|
||||
manifest and runtime. The Suaveplan convention is to reuse `Error`
|
||||
rather than `SuaveplanError` for leaf plugins — the host's boundary
|
||||
only reads `code`, so any subclass works.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `instruments` — OpenTelemetry instruments for `api-plugin-example`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reference plugin declares three OTel instruments that mirror the
|
||||
`telemetry.metrics[]` block in `plugin.json`. The instruments are
|
||||
lazily materialised against the global `MeterProvider` so the test
|
||||
setup can install an `InMemoryMetricExporter` before the first call.
|
||||
The integration suite reads the in-memory exporter to assert each
|
||||
manifest-declared instrument emits values during the run.
|
||||
|
||||
## Features
|
||||
|
||||
- `example.invoices_finalised_total` — counter, no unit.
|
||||
- `example.stripe.call_duration_ms` — histogram, `unit: "ms"`.
|
||||
- `example.outstanding_invoice_count` — counter modelling a gauge.
|
||||
- Lazy single-shot resolution — second call returns the cached object.
|
||||
- `resetExampleInstrumentsForTest` — clears the cache between suites.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getExampleInstruments } from "@suaveplan/api-plugin-example/instruments/instruments";
|
||||
|
||||
const m = getExampleInstruments();
|
||||
m.invoicesFinalisedTotal.add(1, { tenant_id: ctx.tenantId ?? "none" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The histogram is recorded around outbound Stripe calls. Wrap the
|
||||
network call to capture duration even on error:
|
||||
|
||||
```ts
|
||||
const start = performance.now();
|
||||
try {
|
||||
await stripe.charges.create({ amount, currency });
|
||||
} finally {
|
||||
m.stripeCallDurationMs.record(performance.now() - start);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getExampleInstruments(): ExampleInstruments` — Lazy accessor.
|
||||
- `resetExampleInstrumentsForTest(): void` — Cache reset.
|
||||
- `ExampleInstruments` — `{ invoicesFinalisedTotal, stripeCallDurationMs, outstandingInvoiceCount }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are materialised against `metrics.getMeter(METER_NAME)`
|
||||
rather than a passed-in provider so plugin code does not need to
|
||||
plumb a meter through every handler. The provider is installed once
|
||||
in `tests/setup/polyfills.ts` and the cached registry binds to it on
|
||||
first call. The cache is intentionally process-wide rather than
|
||||
per-`MeterProvider` because plugin authors typically install a global
|
||||
provider at boot and never swap it; tests that DO swap providers call
|
||||
`resetExampleInstrumentsForTest` between cases so the second call
|
||||
re-materialises against the new meter. The OTel SDK's
|
||||
`InMemoryMetricExporter` is the only test substrate that needs the
|
||||
cache reset — production paths bind to the global provider for the
|
||||
lifetime of the host process and any datapoint emitted goes through
|
||||
the same instrument instance. The histogram declares `unit: "ms"` so
|
||||
downstream Prometheus / OTLP collectors expose the unit in their
|
||||
metric metadata. The counter and the gauge-modelling counter both
|
||||
omit the `unit` field because they are dimensionless.
|
||||
66
docs/api-stack/api/api-plugin-example/src/schemas/schemas.md
Normal file
66
docs/api-stack/api/api-plugin-example/src/schemas/schemas.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# `schemas` — Zod request/response schemas owned by `api-plugin-example`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Every route in the reference plugin is wired against a Zod schema so
|
||||
the §6 input-validation step always has a contract to consult. The
|
||||
schemas live in a single module so the plugin manifest can resolve
|
||||
them from a stable specifier and so the integration tests can
|
||||
hand-craft both valid and malformed payloads against the same source.
|
||||
|
||||
The schemas are also the canonical reference for the response-
|
||||
validation route — `/_/test/fail-response?failResponse=1` returns a
|
||||
payload that intentionally violates `CreateInvoiceResponse` so the
|
||||
host's `responseValidation` toggle exercises both `enforce` (500) and
|
||||
`warn` (200 + counter increment) modes.
|
||||
|
||||
## Features
|
||||
|
||||
- `CreateInvoiceRequest` / `CreateInvoiceResponse` — paired schemas.
|
||||
- `InvoiceResponse` — alias for the GET-one response.
|
||||
- `PingResponse` — public ping shape.
|
||||
- `StreamChunk` — chunked-transfer envelope.
|
||||
- Re-exported `z.infer<...>` type aliases for handler-side use.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateInvoiceRequest } from "@suaveplan/api-plugin-example/schemas/schemas";
|
||||
|
||||
const parsed = CreateInvoiceRequest.safeParse(ctx.body);
|
||||
if (!parsed.success) {
|
||||
return new Response(JSON.stringify(parsed.error), { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The response-validation route emits a payload that violates
|
||||
`CreateInvoiceResponse` — the schema is the canonical reference the
|
||||
test asserts against:
|
||||
|
||||
```ts
|
||||
import { CreateInvoiceResponse } from "@suaveplan/api-plugin-example/schemas/schemas";
|
||||
|
||||
const body = { id: "x", status: "invalid", amountCents: -1, currency: "ZZ" };
|
||||
expect(CreateInvoiceResponse.safeParse(body).success).toBe(false);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreateInvoiceRequest: ZodObject` — POST body schema.
|
||||
- `CreateInvoiceResponse: ZodObject` — 200 response schema.
|
||||
- `InvoiceResponse` — alias for `CreateInvoiceResponse`.
|
||||
- `PingResponse` — `{ ok: true, pluginName: string }`.
|
||||
- `StreamChunk` — `{ seq: number, payload: string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default-import form (`import z from "zod"`) is the only Bun-safe
|
||||
form per `feedback_zod_import.md`. Inferred types are re-exported via
|
||||
`type` so consumers can opt out of paying the runtime cost when they
|
||||
only need typing.
|
||||
@@ -0,0 +1,69 @@
|
||||
# `events` — topics + payload schemas published and subscribed
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reference plugin both publishes and subscribes to events. This
|
||||
module exposes the topic names and the Zod payload schemas so the
|
||||
manifest, the runtime, and the integration test all agree on the
|
||||
shape. The integration test publishes a `users.user.deleted` payload
|
||||
through the in-memory bus and asserts the subscriber handler ran
|
||||
exactly once with the parsed payload.
|
||||
|
||||
The publication side mirrors that — the plugin's `POST /example/invoices`
|
||||
handler emits `example.invoice.created` after a successful insert and
|
||||
the test asserts the payload landed on the bus via
|
||||
`host.waitForEvent`.
|
||||
|
||||
## Features
|
||||
|
||||
- `EXAMPLE_INVOICE_CREATED_TOPIC` / `USERS_USER_DELETED_TOPIC` literals.
|
||||
- `ExampleInvoiceCreatedPayload` / `UsersUserDeletedPayload` Zod schemas.
|
||||
- `makeUserDeletedHandler({ seen })` — factory returning a parsed-payload
|
||||
handler that records into a caller-owned sink.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
EXAMPLE_INVOICE_CREATED_TOPIC,
|
||||
ExampleInvoiceCreatedPayload,
|
||||
} from "@suaveplan/api-plugin-example/events/events";
|
||||
|
||||
host.adapters.eventBus.publish(EXAMPLE_INVOICE_CREATED_TOPIC, {
|
||||
id: "i-1",
|
||||
customerId: "c-1",
|
||||
amountCents: 100,
|
||||
currency: "USD",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The subscriber-side factory accepts a sink so tests can observe
|
||||
delivery without mutating module state:
|
||||
|
||||
```ts
|
||||
import { makeUserDeletedHandler } from "@suaveplan/api-plugin-example/events/events";
|
||||
const sink = { seen: new Set<string>() };
|
||||
host.adapters.eventBus.on("users.user.deleted", makeUserDeletedHandler(sink));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EXAMPLE_INVOICE_CREATED_TOPIC: "example.invoice.created"`
|
||||
- `USERS_USER_DELETED_TOPIC: "users.user.deleted"`
|
||||
- `ExampleInvoiceCreatedPayload: ZodObject` — published payload.
|
||||
- `UsersUserDeletedPayload: ZodObject` — subscribed payload.
|
||||
- `makeUserDeletedHandler({ seen: Set<string> }): (raw: unknown) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`makeUserDeletedHandler` swallows parse failures rather than throwing
|
||||
because the in-memory event bus delivers FIFO with `Promise.resolve`
|
||||
chaining — a throwing handler would terminate the chain for every
|
||||
downstream subscriber. The integration test asserts a valid payload
|
||||
populates the sink; a malformed payload simply doesn't.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `plugin` — frozen `definePlugin` record
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The reference plugin's `definePlugin` invocation lives in its own
|
||||
module so the manifest, the integration test, and any host-side
|
||||
loader read the same frozen `PluginModule` value. Every lifecycle
|
||||
hook is wired but performs only the work the integration suite
|
||||
asserts against — `install` / `start` / `stop` / `uninstall` /
|
||||
`health` are present so the four-phase FSM exercises each one even
|
||||
though the bodies are no-ops.
|
||||
|
||||
The `requires` array is empty because the routes consume capabilities
|
||||
through the per-request context shape (`ctx.principal`, `ctx.tenant`,
|
||||
`ctx.secrets`, `ctx.db`) rather than the typed capability registry.
|
||||
As the registry matures, the literal list will tighten to the exact
|
||||
set the routes need.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `PluginModule<readonly []>` value.
|
||||
- Five lifecycle hooks wired so the four-phase FSM walks each.
|
||||
- `isolation: "trusted"` / `trustLevel: "first-party"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { examplePluginModule } from "@suaveplan/api-plugin-example/plugin/plugin";
|
||||
|
||||
await host.installFixturePlugin({
|
||||
spec: examplePluginModule,
|
||||
routes: buildRoutes({ ledger, publisher }),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The module is `Object.freeze`d by `definePlugin`, so the host can
|
||||
hash a stable representation for the rollback journal:
|
||||
|
||||
```ts
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify(examplePluginModule))
|
||||
.digest("hex");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `examplePluginModule: PluginModule<readonly []>` — Frozen module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runtime body of every hook is a no-op because the host's
|
||||
lifecycle FSM is the focus — the example exists to exercise each
|
||||
transition, not to encode business logic in the hooks. The
|
||||
integration test installs the module through
|
||||
`host.installFixturePlugin` so the routes table is registered
|
||||
alongside the module rather than threaded through `start`.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `routes` — REST surface for `api-plugin-example`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The route table is the heart of the reference plugin. Every route
|
||||
demonstrates one piece of the §6 11-step request pipeline: public
|
||||
(no auth), RBAC-gated, Zod-validated body, streaming response,
|
||||
custom error code → manifest-mapped HTTP status, and the intentional
|
||||
schema-violation route that exercises the response-validation
|
||||
toggle. The factory takes an injected ledger and event publisher so
|
||||
the integration test materialises a fresh isolation per suite.
|
||||
|
||||
The routes throw `ExamplePluginError` for plugin-domain failures
|
||||
(`PLUGIN.EXAMPLE.*`) and a raw `Response` for host-domain concerns
|
||||
like missing scope, which keeps the plugin's error namespace narrow.
|
||||
|
||||
## Features
|
||||
|
||||
- 10 routes covering every accessible §6 pipeline outcome.
|
||||
- Injected ledger + publisher — zero module state, suites are
|
||||
independent.
|
||||
- Manifest-aligned `statusMap` on every error-emitting registration.
|
||||
- `createInMemoryLedger()` factory the tests reuse.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildRoutes,
|
||||
createInMemoryLedger,
|
||||
} from "@suaveplan/api-plugin-example/routes/routes";
|
||||
|
||||
const ledger = createInMemoryLedger();
|
||||
const routes = buildRoutes({
|
||||
ledger,
|
||||
publisher: { publish: (t, p) => host.adapters.eventBus.publish(t, p) },
|
||||
});
|
||||
await host.installFixturePlugin({ spec, routes });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The `requireScope` helper throws a raw `Response` so the host's
|
||||
error boundary surfaces a 403 without the example's own error
|
||||
namespace getting involved. The pattern keeps plugin-domain errors
|
||||
(`PLUGIN.EXAMPLE.*`) separate from host-domain RBAC. To assert the
|
||||
RBAC envelope in tests:
|
||||
|
||||
```ts
|
||||
const res = await host.fetchAs({ id: "p", scopes: [] }, ping);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("RBAC_MISSING_SCOPE");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildRoutes({ ledger, publisher }): ExampleRoute[]` — Factory.
|
||||
- `createInMemoryLedger(): InvoiceLedger` — Test ledger.
|
||||
- `InvoiceLedger` — `{ invoices: Map, nextId(): string }`.
|
||||
- `RoutePublisher` — `{ publish(topic, payload): void }`.
|
||||
- `ExampleRoute` — `{ method, path, handler, statusMap? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The streaming route enqueues NDJSON chunks through a `ReadableStream`
|
||||
so the host pipeline observes `transfer-encoding: chunked`. The body
|
||||
schema validation uses `safeParse` and returns a 400 directly rather
|
||||
than throwing — the §6 step-4 validator is the host's concern; this
|
||||
plugin is showing how a plugin author defends against malformed
|
||||
input ahead of the validator landing.
|
||||
49
docs/api-stack/api/api-plugin-example/src/wasm/dispose.md
Normal file
49
docs/api-stack/api/api-plugin-example/src/wasm/dispose.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# wasm/dispose
|
||||
|
||||
## Purpose
|
||||
|
||||
Lifecycle hook invoked when the wasm-tier host bridge tears down the
|
||||
plugin. Clears the module-singleton `InMemoryStore` so the next
|
||||
`start()` begins from a deterministic empty state. The hook is
|
||||
idempotent — repeated calls do not throw and leave the store empty.
|
||||
|
||||
`dispose()` is paired with `start()` to provide a deterministic
|
||||
warm-restart cycle for the host bridge. Calling
|
||||
`dispose() → start()` is the canonical "reset the plugin" sequence
|
||||
the bridge invokes on a config change or hot-reload.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent: repeated calls leave the store empty.
|
||||
- Pairs with `start()` for warm-restart cycles.
|
||||
- Clears every persisted invoice and resets the id counter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
plugin.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
// Warm-restart cycle the bridge uses on hot-reload.
|
||||
plugin.dispose();
|
||||
plugin.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `dispose(): void` — clears the store; idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`dispose()` calls `getStore().clear()`, which empties the underlying
|
||||
`Map` and resets the id counter. The store reference itself is
|
||||
preserved (the singleton swap is reserved for `resetState()` in
|
||||
tests). The bridge consumes `dispose()` synchronously — no async
|
||||
work is performed.
|
||||
@@ -0,0 +1,87 @@
|
||||
# wasm/handle-request
|
||||
|
||||
## Purpose
|
||||
|
||||
Entry point for the wasm-tier surface. Receives a `SerializedRequest`
|
||||
(the WASI-stdio envelope payload), routes by method + URL pathname,
|
||||
and dispatches to a per-route handler that delegates every business
|
||||
decision to `src/domain/invoice-rules.ts`. The result is a
|
||||
`SerializedResponse` whose body is base64-url-no-pad encoded.
|
||||
|
||||
The function is deliberately small — routing is a flat `if`-chain
|
||||
rather than a generic radix-tree matcher because the route set is
|
||||
stable and tiny. The shape-conformance test compares byte-for-byte
|
||||
against the trusted-tier handler for every overlapping route; the
|
||||
flat dispatch keeps the parity contract auditable at a glance.
|
||||
|
||||
## Features
|
||||
|
||||
- Synchronous-style routing — no router dependency.
|
||||
- Domain delegation — every business decision goes through `domain/invoice-rules.ts`.
|
||||
- Base64-url-no-pad body encoding (JSON-clean wire shape).
|
||||
- `SuaveplanError` throws propagate to the ABI shim (NOT caught here).
|
||||
- RBAC via `SerializedRequest.capabilities`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
const res = await plugin.handleRequest({
|
||||
url: "http://t/example/health",
|
||||
method: "GET",
|
||||
headers: [],
|
||||
bodyBase64: "",
|
||||
principalId: null,
|
||||
tenantId: null,
|
||||
capabilities: [],
|
||||
requestId: "r-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
console.log(res.status); // 200
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
function encode(body: object): string {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(body));
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
const res = await plugin.handleRequest({
|
||||
url: "http://t/example/invoices",
|
||||
method: "POST",
|
||||
headers: [["content-type", "application/json"]],
|
||||
bodyBase64: encode({ customerId: "c", amountCents: 1, currency: "USD" }),
|
||||
principalId: null,
|
||||
tenantId: null,
|
||||
capabilities: ["example:write"],
|
||||
requestId: "r-2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `handleRequest(req: SerializedRequest): Promise<SerializedResponse>` — entry point.
|
||||
- Dispatches to: `/example/public/ping`, `/example/health`, `/example/invoices`
|
||||
(GET list, GET by id, POST create), `/example/invoices/:id/finalise`,
|
||||
`/example/rate-limited`, `/example/subscription-required`,
|
||||
`/_/test/fail-response`. Unknown routes return 404 with `code: ROUTE_NOT_FOUND`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `await Promise.resolve()` inside `finaliseInvoice` mirrors the
|
||||
trusted tier's microtask yield. The trusted tier uses it to keep its
|
||||
`example.stripe.call_duration_ms` histogram observation above zero;
|
||||
the wasm tier has no histogram to record but keeps the yield so
|
||||
control-flow timing remains identical for the parity test.
|
||||
|
||||
Base64-url-no-pad encoding uses `btoa` / `atob` — the wasm-tier
|
||||
runtime (Javy/QuickJS) ships both. The encoder replaces `+` with
|
||||
`-`, `/` with `_`, and strips trailing `=` padding.
|
||||
51
docs/api-stack/api/api-plugin-example/src/wasm/routes.md
Normal file
51
docs/api-stack/api/api-plugin-example/src/wasm/routes.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# wasm/routes
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the static `SerializedRoute[]` the wasm-tier surface exposes
|
||||
to the host bridge. The list is the contract the
|
||||
`api-sandbox-wasm-host-bridge` reads at boot time to register a
|
||||
matching set of `PipelineRoute` entries on the host's router.
|
||||
|
||||
The route list deliberately omits the trusted-tier
|
||||
`/example/stream/large-report` endpoint. Chunked `ReadableStream`
|
||||
responses cannot be expressed under the synchronous WASI-stdio
|
||||
`(SerializedRequest) => SerializedResponse` contract — Javy's
|
||||
QuickJS lacks a usable `ReadableStream` and the WASI-stdio envelope
|
||||
has no streaming response shape. Use pagination at the trusted tier
|
||||
when the wasm tier needs equivalent throughput.
|
||||
|
||||
## Features
|
||||
|
||||
- `routes: readonly SerializedRoute[]` — frozen at module init.
|
||||
- Identical order to the trusted-tier route table.
|
||||
- Streaming endpoints omitted by design.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { routes } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
console.log(routes.length); // 9
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
for (const r of plugin.routes) {
|
||||
console.log(`${r.method} ${r.path}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `routes: readonly SerializedRoute[]` — every wasm-tier endpoint.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SerializedRoute` is imported as a type-only specifier from
|
||||
`@suaveplan/api-types/sandbox-bridge`. The runtime shape is a plain
|
||||
`[{ method, path }, ...]` array — no runtime dependency on
|
||||
`api-types`.
|
||||
52
docs/api-stack/api/api-plugin-example/src/wasm/start.md
Normal file
52
docs/api-stack/api/api-plugin-example/src/wasm/start.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# wasm/start
|
||||
|
||||
## Purpose
|
||||
|
||||
Lifecycle hook invoked when the wasm-tier host bridge instantiates the
|
||||
plugin. Primes the module-singleton `InMemoryStore` with the same
|
||||
seed data the trusted-tier `install` hook uses. The reference
|
||||
plugin's seed is empty (so `start()` is effectively a no-op), but
|
||||
the hook is kept for symmetry with the trusted tier and so future
|
||||
forks have a documented place to add their own seed records.
|
||||
|
||||
The function is idempotent — repeated calls do not throw and do not
|
||||
duplicate any seed records. The shape-conformance test asserts that
|
||||
`dispose()` followed by `start()` re-primes the store such that
|
||||
`GET /example/invoices` returns the seeded list.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent: repeated calls are safe.
|
||||
- Touches the store singleton (no-op materialise).
|
||||
- Documented insertion point for plugin forks that need real seed data.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
plugin.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { plugin } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
// Lifecycle round-trip from the host bridge.
|
||||
plugin.start();
|
||||
const res = await plugin.handleRequest({ /* ... */ } as never);
|
||||
plugin.dispose();
|
||||
plugin.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `start(): void` — idempotent no-op (in the reference plugin).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The reference plugin's `start()` deliberately performs no work; the
|
||||
empty body is correct because the trusted-tier `install` hook is
|
||||
also empty. Forks SHOULD insert their seed records here by calling
|
||||
`getStore().create(...)`.
|
||||
52
docs/api-stack/api/api-plugin-example/src/wasm/wasm-state.md
Normal file
52
docs/api-stack/api/api-plugin-example/src/wasm/wasm-state.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# wasm-state
|
||||
|
||||
## Purpose
|
||||
|
||||
Holds the module-singleton `InMemoryStore` consumed by the wasm-tier
|
||||
`handleRequest`. The WASI-stdio contract surfaces a synchronous
|
||||
`(SerializedRequest) => SerializedResponse` shape with no
|
||||
per-request context the host could thread a store reference through —
|
||||
so the wasm tier keeps exactly one store at module scope.
|
||||
|
||||
`resetState()` is the test-isolation primitive. The shape-conformance
|
||||
test calls it before each parity assertion so every iteration starts
|
||||
with `inv-1` as the next allocated id and an empty invoice list.
|
||||
Production deploys never call `resetState()` directly — `dispose()`
|
||||
clears the singleton's contents in place rather than swapping it out.
|
||||
|
||||
## Features
|
||||
|
||||
- `getStore()` — read the current module-scoped store.
|
||||
- `resetState()` — swap in a fresh empty store (test isolation).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getStore } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
const store = getStore();
|
||||
console.log(store.list());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { resetState } from "@suaveplan/api-plugin-example/wasm";
|
||||
|
||||
// Inside a test
|
||||
beforeEach(() => {
|
||||
resetState();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getStore(): InMemoryStore`.
|
||||
- `resetState(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module-singleton pattern is unusual in this project (most packages
|
||||
prefer dependency injection), but the WASI-stdio ABI has no place to
|
||||
thread state. The escape hatch is `resetState()` for tests and
|
||||
`plugin.dispose()` / `plugin.start()` for production lifecycle reset.
|
||||
Reference in New Issue
Block a user