Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
# `error-codes.ts` — webhook plugin error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the ten stable `WEBHOOK_*` error codes raised by the api-plugin-webhook package and the concrete `WebhookError` class used to surface them. Every failure path in the plugin — invalid options, missing endpoints, exhausted retries, dead-letter writes, route-prefix collisions, missing `ServiceRegistry` slots, inactive endpoints, tenant mismatches, missing subscriptions, and persistence-layer failures — funnels through this module so consumers can branch on the typed `error.code` string without reaching for `instanceof` for individual subclasses.
## Features
- Stable string codes registered through `@suaveplan/error`'s `defineCodes("WEBHOOK", [...])` (process-wide deduplication, OTel span-event compatible).
- Concrete `WebhookError extends SuaveplanError<WebhookErrorCode>` so `instanceof WebhookError` narrows `code` to the typed union.
- Re-exported under the package barrel and at the `./error-codes/error-codes` subpath for tree-shaking.
## Usage: Basic
```ts
import { WEBHOOK_CODES, WebhookError } from "@suaveplan/api-plugin-webhook/error-codes/error-codes";
throw new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
"endpoint missing",
{ context: { endpointId: "abc" } }
);
```
## Usage: Advanced
```ts
import { WEBHOOK_CODES, WebhookError } from "@suaveplan/api-plugin-webhook";
try {
await webhookService.send("missing", "order.created", {});
} catch (err) {
if (err instanceof WebhookError) {
switch (err.webhookCode) {
case WEBHOOK_CODES.ENDPOINT_NOT_FOUND:
return ctx.json({ error: "unknown endpoint" }, 404);
case WEBHOOK_CODES.DELIVERY_EXHAUSTED:
return ctx.json({ error: "delivery failed, see dead letter" }, 502);
case WEBHOOK_CODES.INACTIVE_ENDPOINT:
return ctx.json({ error: "endpoint disabled" }, 409);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `WEBHOOK_CODES` — readonly string-literal map. Each property name is one of the ten registered codes and the value is the `"WEBHOOK_<NAME>"` string carried on `WebhookError.code`.
- `WebhookErrorCode` — string-literal union derived from `typeof WEBHOOK_CODES[keyof typeof WEBHOOK_CODES]`.
- `WebhookError` — extends `SuaveplanError<WebhookErrorCode>`. Constructor signature: `new WebhookError(code, message, options?)` where `options` accepts `{ cause?, context?, status?, retryable?, exposeMessage? }` via `SuaveplanErrorOptions`. Exposes `webhookCode` (alias for the typed `code`) so consumers can branch without a cast.
## Implementation Notes
The ten codes were chosen to cover every distinct failure mode the plugin can surface to a caller: factory-level (`INVALID_INPUT`), service-resolution (`SERVICE_NOT_AVAILABLE`), route-registration (`ROUTE_PREFIX_COLLISION`), persistence (`STORE_ERROR`), authorization (`TENANT_MISMATCH`), state (`INACTIVE_ENDPOINT`, `ENDPOINT_NOT_FOUND`, `EVENT_NOT_SUBSCRIBED`), and delivery (`DELIVERY_EXHAUSTED`, `DEAD_LETTER_WRITE_FAILED`). Adding a new code requires a follow-up OpenSpec change so the spec-conformance test (`api-plugin-webhook.spec-conformance.test.ts`) stays in lock-step with the registry. The `defineCodes` factory throws on registry collision at module load — guarantees one canonical owner per code.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
WEBHOOK_CODES,
WebhookError,
type WebhookErrorCode,
} from "./error-codes.js";
describe("WEBHOOK_CODES + WebhookError", () => {
it("registers all ten declared codes with the WEBHOOK_ prefix", {
timeout: 5000,
}, () => {
const expected: readonly WebhookErrorCode[] = [
"WEBHOOK.INVALID_INPUT",
"WEBHOOK.ENDPOINT_NOT_FOUND",
"WEBHOOK.TENANT_MISMATCH",
"WEBHOOK.EVENT_NOT_SUBSCRIBED",
"WEBHOOK.DELIVERY_EXHAUSTED",
"WEBHOOK.DEAD_LETTER_WRITE_FAILED",
"WEBHOOK.STORE_ERROR",
"WEBHOOK.SERVICE_NOT_AVAILABLE",
"WEBHOOK.ROUTE_PREFIX_COLLISION",
"WEBHOOK.INACTIVE_ENDPOINT",
];
const observed = Object.values(WEBHOOK_CODES).sort();
expect(observed).toEqual([...expected].sort());
});
it("constructs WebhookError with code, message, and context", {
timeout: 5000,
}, () => {
const cause = new Error("downstream");
const err = new WebhookError(
WEBHOOK_CODES.ENDPOINT_NOT_FOUND,
"missing",
{ cause, context: { endpointId: "abc" } }
);
expect(err).toBeInstanceOf(WebhookError);
expect(err.code).toBe("WEBHOOK.ENDPOINT_NOT_FOUND");
expect(err.webhookCode).toBe("WEBHOOK.ENDPOINT_NOT_FOUND");
expect(err.context).toEqual({ endpointId: "abc" });
expect(err.cause).toBe(cause);
});
it("narrows webhookCode via instanceof guard", { timeout: 5000 }, () => {
const err: unknown = new WebhookError(
WEBHOOK_CODES.DELIVERY_EXHAUSTED,
"boom"
);
if (err instanceof WebhookError) {
const code: WebhookErrorCode = err.webhookCode;
expect(code).toBe("WEBHOOK.DELIVERY_EXHAUSTED");
} else {
throw new Error("guard failed");
}
});
});

View File

@@ -0,0 +1,70 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-webhook`.
*
* Every failure path in the webhook plugin raises a {@link WebhookError}
* carrying one of these ten `WEBHOOK.*` codes. Consumers branch on the
* string value (e.g. `error.code === WEBHOOK_CODES.ENDPOINT_NOT_FOUND`).
*
* - `INVALID_INPUT` — `createWebhookPlugin(options)` received options
* that failed `WebhookPluginOptionsSchema` validation. `cause` holds
* the `ZodError`.
* - `ENDPOINT_NOT_FOUND` — `EndpointStore.get(id)` returned `null` or
* the resolved endpoint was filtered out by tenant scope. The
* `context.endpointId` field carries the offending id.
* - `TENANT_MISMATCH` — a write touched an endpoint owned by a
* different tenant than the requesting principal. `context.expected`
* and `context.actual` carry the tenant ids.
* - `EVENT_NOT_SUBSCRIBED` — `WebhookService.sendAll(event)` was called
* but no active endpoint listed that event in its subscriptions.
* - `DELIVERY_EXHAUSTED` — every retry attempt failed; the dead-letter
* sink has the full attempt history. The original webhook-client
* failure is carried as `cause`.
* - `DEAD_LETTER_WRITE_FAILED` — `DeadLetterSink.record` threw after a
* delivery exhausted. The sink error is carried as `cause` while the
* triggering delivery failure travels on the chain.
* - `STORE_ERROR` — `EndpointStore` operation rejected (custom store
* implementations only — `MemoryEndpointStore` cannot raise this).
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
* absent at `onInit`. `context.missing` lists the missing slot names.
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
* with routes already registered by another plugin in the host's
* `OpenAPIRegistry`.
* - `INACTIVE_ENDPOINT` — `WebhookService.send` was called against an
* endpoint whose `active === false`. No delivery is attempted.
*/
export const WEBHOOK_CODES = defineCodes("WEBHOOK", [
"INVALID_INPUT",
"ENDPOINT_NOT_FOUND",
"TENANT_MISMATCH",
"EVENT_NOT_SUBSCRIBED",
"DELIVERY_EXHAUSTED",
"DEAD_LETTER_WRITE_FAILED",
"STORE_ERROR",
"SERVICE_NOT_AVAILABLE",
"ROUTE_PREFIX_COLLISION",
"INACTIVE_ENDPOINT",
] as const);
export type WebhookErrorCode =
(typeof WEBHOOK_CODES)[keyof typeof WEBHOOK_CODES];
/**
* Concrete error class for the api-plugin-webhook package. Extends
* {@link SuaveplanError} so `instanceof WebhookError` narrows `code` to
* {@link WebhookErrorCode}.
*/
export class WebhookError extends SuaveplanError<WebhookErrorCode> {
public readonly webhookCode: WebhookErrorCode;
public constructor(
code: WebhookErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.webhookCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { WebhookErrorCode } from "./error-codes.js";
export {
WEBHOOK_CODES,
WebhookError,
} from "./error-codes.js";