Initial Commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# error-codes/error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module defines the stable registry of programmatic
|
||||
failure codes raised by `@suaveplan/api-plugin-user`. Every code lives
|
||||
behind a `UserError` class extending the canonical `SuaveplanError`
|
||||
base, so callers can `instanceof UserError` to narrow `error.code` to
|
||||
the union of seven `USER.*` codes — never a free-form string. The
|
||||
registry is the contract: adding, removing, or renaming a code is a
|
||||
breaking change for consumers asserting on the value.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes covering every plugin failure path: lookup misses,
|
||||
invalid factory options, disabled self-delete, profile-update
|
||||
failures, missing services, schema-apply failures, and route-prefix
|
||||
collisions.
|
||||
- `defineCodes("USER", [...])` registers the codes globally so the
|
||||
unified error registry can resolve them by string.
|
||||
- `UserError` carries `userCode` alongside the canonical `code` field
|
||||
so downstream code that does not import the base class can still
|
||||
branch on the discriminant.
|
||||
- Each thrown instance carries `context.statusCode` so the host's
|
||||
error-handler middleware maps the failure to the right HTTP status
|
||||
without per-route boilerplate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { USER_CODES, UserError } from "@suaveplan/api-plugin-user/error-codes/error-codes";
|
||||
|
||||
if (!row) {
|
||||
throw new UserError(USER_CODES.NOT_FOUND, "user not found", {
|
||||
context: { statusCode: 404, userId: id },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose the registry with the `@suaveplan/error` `cause` chain so the
|
||||
original database error survives translation to HTTP without losing
|
||||
its stack:
|
||||
|
||||
```ts
|
||||
try {
|
||||
await db.execute(sql);
|
||||
} catch (cause) {
|
||||
throw new UserError(USER_CODES.PROFILE_UPDATE_FAILED, "patch failed", {
|
||||
cause,
|
||||
context: { statusCode: 500, userId, fields: Object.keys(patch) },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `USER_CODES`
|
||||
|
||||
`Readonly<Record<"NOT_FOUND" | "INVALID_INPUT" | "SELF_DELETE_DISABLED" | "PROFILE_UPDATE_FAILED" | "SERVICE_NOT_AVAILABLE" | "SCHEMA_APPLY_FAILED" | "ROUTE_PREFIX_COLLISION", string>>`.
|
||||
Each value is the fully-qualified `"USER.<NAME>"` string used as the
|
||||
discriminant.
|
||||
|
||||
### `UserError`
|
||||
|
||||
`class UserError extends SuaveplanError<UserErrorCode>` — public
|
||||
constructor `(code, message, options?)` so callers can construct the
|
||||
error directly. `userCode` mirrors `code` for ergonomic narrowing.
|
||||
|
||||
### `UserErrorCode`
|
||||
|
||||
Union of all seven literal code values.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defineCodes` performs duplicate-detection at module-load time; a
|
||||
duplicate name throws synchronously so a stale build fails loudly.
|
||||
- The seven codes are intentionally cohesive — admin-side listings,
|
||||
avatar uploads, and GDPR export are scoped to follow-up plugins with
|
||||
their own registries.
|
||||
- `statusCode` is always carried on `context`, not as a top-level
|
||||
field, so the error registry remains transport-agnostic.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { USER_CODES, UserError } from "./error-codes.js";
|
||||
|
||||
describe("USER_CODES registry", () => {
|
||||
it("declares 7 stable codes", { timeout: 5000 }, () => {
|
||||
const values = Object.values(USER_CODES);
|
||||
expect(values.length).toBe(7);
|
||||
expect(new Set(values).size).toBe(7);
|
||||
for (const v of values) {
|
||||
expect(v.startsWith("USER.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries each documented name", { timeout: 5000 }, () => {
|
||||
const names: Array<keyof typeof USER_CODES> = [
|
||||
"NOT_FOUND",
|
||||
"INVALID_INPUT",
|
||||
"SELF_DELETE_DISABLED",
|
||||
"PROFILE_UPDATE_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SCHEMA_APPLY_FAILED",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
];
|
||||
for (const n of names) {
|
||||
expect(typeof USER_CODES[n]).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserError class", () => {
|
||||
it("narrows code to userCode and stores message", { timeout: 5000 }, () => {
|
||||
const err = new UserError(USER_CODES.NOT_FOUND, "boom", {
|
||||
context: { statusCode: 404 },
|
||||
});
|
||||
expect(err).toBeInstanceOf(UserError);
|
||||
expect(err.code).toBe(USER_CODES.NOT_FOUND);
|
||||
expect(err.userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("preserves cause chain", { timeout: 5000 }, () => {
|
||||
const inner = new Error("inner");
|
||||
const err = new UserError(USER_CODES.PROFILE_UPDATE_FAILED, "outer", {
|
||||
cause: inner,
|
||||
});
|
||||
expect(err.cause).toBe(inner);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-user`.
|
||||
*
|
||||
* Every failure path in the user plugin raises a {@link UserError}
|
||||
* carrying one of these 7 `USER.*` codes. Consumers branch on the
|
||||
* string value (e.g. `error.code === USER_CODES.NOT_FOUND`).
|
||||
*
|
||||
* - `NOT_FOUND` — a lookup (`getById`, `updateProfile`) targeted a user
|
||||
* record that does not exist. The error-handler middleware maps this
|
||||
* to HTTP 404 via the `statusCode: 404` field on the error context.
|
||||
* - `INVALID_INPUT` — `createUserPlugin(options)` received options that
|
||||
* failed `UserPluginOptionsSchema` validation. `cause` holds the
|
||||
* `ZodError`.
|
||||
* - `SELF_DELETE_DISABLED` — `DELETE /users/me` was hit while the plugin
|
||||
* was configured with `allowSelfDelete: false`. Mapped to HTTP 403.
|
||||
* - `PROFILE_UPDATE_FAILED` — the `PATCH /users/me` write (or the
|
||||
* downstream `updateProfile` service call) failed at the database
|
||||
* layer. `cause` carries the underlying error.
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
|
||||
* absent. `context.missing` lists the missing slot names.
|
||||
* - `SCHEMA_APPLY_FAILED` — the bundled SurrealQL schema apply step
|
||||
* threw at `onInit` (`migrationRunner.run` or the inline `execute`
|
||||
* loop rejected).
|
||||
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
|
||||
* with routes already registered by another plugin in the host's
|
||||
* `OpenAPIRegistry`.
|
||||
*/
|
||||
export const USER_CODES = defineCodes("USER", [
|
||||
"NOT_FOUND",
|
||||
"INVALID_INPUT",
|
||||
"SELF_DELETE_DISABLED",
|
||||
"PROFILE_UPDATE_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SCHEMA_APPLY_FAILED",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
] as const);
|
||||
|
||||
export type UserErrorCode = (typeof USER_CODES)[keyof typeof USER_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-user package. Extends
|
||||
* {@link SuaveplanError} so `instanceof UserError` narrows `code` to
|
||||
* {@link UserErrorCode}.
|
||||
*/
|
||||
export class UserError extends SuaveplanError<UserErrorCode> {
|
||||
public readonly userCode: UserErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: UserErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.userCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { UserErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
USER_CODES,
|
||||
UserError,
|
||||
} from "./error-codes.js";
|
||||
Reference in New Issue
Block a user