@owlmeans/error 0.1.18-rc.2 → 0.1.18-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,78 +1,264 @@
1
1
  # @owlmeans/error
2
2
 
3
- Serializable error base class with type registration for cross-process error propagation.
4
-
5
- ## Overview
6
-
7
- - `ResilientError` base class survives JSON serialization/deserialization across service boundaries
8
- - Error class registry ensures custom error types are correctly reconstructed after transport
9
- - Helpers for marshaling errors to strings and back
3
+ Serializable error base class with a type registry, so a typed error thrown in one process is
4
+ caught as the same class in another. An app uses it for every error that has to cross a service
5
+ boundary (HTTP, socket, queue) or reach a UI as a translated message: declare a `ResilientError`
6
+ subclass, register it in the shared module both sides import, and normalize whatever you catch
7
+ with `ResilientError.ensure`. It is not for wiring faults — an unknown alias or a missing service
8
+ is a `SyntaxError` that must crash the process. Before declaring a new family, reuse the ones
9
+ framework packages already ship: authentication and authorization failures from
10
+ [`@owlmeans/auth`](../auth), storage failures from [`@owlmeans/resource`](../resource).
10
11
 
11
12
  ## Installation
12
13
 
13
14
  ```bash
14
- bun add @owlmeans/error
15
+ bun add @owlmeans/error@^0.1.18-rc.18
15
16
  ```
16
17
 
18
+ ## Concepts
19
+
20
+ - **Resilient error** — a `ResilientError` carries a `type` (the class's static `typeName`) next to
21
+ its `message`; the pair identifies the failure anywhere it travels.
22
+ - **Error family** — a base subclass that prefixes every message (`invoice:`) and subclasses that
23
+ add their own prefix and re-stamp `type`, so the final message reads `invoice:not-found:<id>`.
24
+ - **Registry** — `ResilientError.converters`, filled by `registerErrorClass`; only a registered
25
+ class comes back as itself after transport.
26
+ - **Marshalling** — `marshal` flattens `type`, `message` and the original stack into one plain
27
+ `Error` whose message is joined by `SEPARATOR` (`|||`); `ensure` recognises that prefix and
28
+ rebuilds the registered class.
29
+ - **Errors namespace** — importing the package registers the `errors` i18n library; UIs resolve a
30
+ message by the error's `type`, never by the thrown string.
31
+
17
32
  ## Usage
18
33
 
19
- Define and register a custom error type:
34
+ ### Declare and register an error family
20
35
 
21
- ```typescript
36
+ Keep errors in the shared (`common`) module that both the server and the client import, and
37
+ register every class at module load.
38
+
39
+ ```ts
22
40
  import { ResilientError } from '@owlmeans/error'
23
41
 
24
- export class ProjectResourceError extends ResilientError {
25
- static typeName = 'viable-project:error'
42
+ export class InvoiceError extends ResilientError {
43
+ public static override typeName = 'MyAppInvoiceError'
44
+
45
+ constructor(message: string = 'error') {
46
+ super(InvoiceError.typeName, `invoice:${message}`)
47
+ }
48
+ }
49
+
50
+ export class InvoiceNotFound extends InvoiceError {
51
+ public static override typeName = `NotFound${InvoiceError.typeName}`
52
+
53
+ constructor(message: string = 'error') {
54
+ super(`not-found:${message}`)
55
+ this.type = InvoiceNotFound.typeName
56
+ }
57
+ }
58
+
59
+ export class InvoiceLocked extends InvoiceError {
60
+ public static override typeName = `Locked${InvoiceError.typeName}`
26
61
 
27
62
  constructor(message: string = 'error') {
28
- super(`viable-project:${message}`)
29
- this.type = ProjectResourceError.typeName
63
+ super(`locked:${message}`)
64
+ this.type = InvoiceLocked.typeName
30
65
  }
31
66
  }
32
67
 
33
- ResilientError.registerErrorClass(ProjectResourceError)
68
+ ResilientError.registerErrorClass(InvoiceError)
69
+ ResilientError.registerErrorClass(InvoiceNotFound)
70
+ ResilientError.registerErrorClass(InvoiceLocked)
71
+
72
+ throw new InvoiceNotFound('inv-42') // message: 'invoice:not-found:inv-42'
34
73
  ```
35
74
 
36
- Marshal errors across service boundaries:
75
+ A subclass of a subclass calls `super` with the message alone — the parent adds its own prefix and
76
+ `type`, so the subclass re-stamps `this.type` afterwards.
77
+
78
+ ### Normalize what you caught
37
79
 
38
- ```typescript
39
- import { marshalError, ResilientError } from '@owlmeans/error'
80
+ `ensure` accepts an `Error` or a string and always returns a `ResilientError`, so a handler can
81
+ branch on the registered classes it knows and fall through for the rest.
40
82
 
41
- // Server side: serialize for transport
42
- const serialized = marshalError(caughtError)
83
+ ```ts
84
+ import { ResilientError } from '@owlmeans/error'
85
+ import { InvoiceLocked, InvoiceNotFound } from '@my-app/common'
43
86
 
44
- // Client side: reconstruct the typed error
45
- const restored = ResilientError.ensure(receivedError)
87
+ export const settle = async (invoiceId: string) => {
88
+ try {
89
+ await payInvoice(invoiceId)
90
+ } catch (e) {
91
+ const err = ResilientError.ensure(e as Error)
92
+ if (err instanceof InvoiceNotFound || err instanceof InvoiceLocked) {
93
+ return { settled: false, reason: err.type }
94
+ }
95
+ throw err
96
+ }
97
+ }
46
98
  ```
47
99
 
48
- ## API
100
+ A small helper keeps catch blocks typed when the caught value is `unknown`:
101
+
102
+ ```ts
103
+ import { ResilientError } from '@owlmeans/error'
104
+
105
+ export const asError = <T extends ResilientError>(err: unknown): T =>
106
+ typeof err === 'string' || err instanceof Error
107
+ ? ResilientError.ensure(err) as T
108
+ : ResilientError.ensure(`Unknown error ${String(err)}`) as T
109
+ ```
110
+
111
+ ### Cross a service boundary
112
+
113
+ The HTTP transports already do this: `@owlmeans/server-api` answers a thrown error with
114
+ `ResilientError.marshal(ResilientError.ensure(error)).message` as the body, and `@owlmeans/api`
115
+ turns a string body back into the registered class with `ResilientError.ensure`. So a backend throw
116
+ is caught as the same class around `context.entrypoint(invoiceProtocols.settle).call(...)` in the
117
+ client. A boundary you own — a WebSocket close reason, a stored failure record — does the same by hand:
118
+
119
+ ```ts
120
+ import { enuserError, marshalError } from '@owlmeans/error'
121
+ import { InvoiceError } from '@my-app/common'
122
+
123
+ // Producer: send only the flattened string.
124
+ socket.close(1011, marshalError(caught).message)
125
+
126
+ // Consumer: rebuild the registered class, typed to the family you expect.
127
+ socket.addEventListener('close', event => {
128
+ const err = enuserError<InvoiceError>(event.reason)
129
+ if (err instanceof InvoiceError) {
130
+ showInvoiceProblem(err.type)
131
+ }
132
+ })
133
+ ```
134
+
135
+ The round trip is worth a test for any error whose identity the far side depends on:
136
+
137
+ ```ts
138
+ import { expect, test } from 'bun:test'
139
+ import { ResilientError } from '@owlmeans/error'
140
+ import { InvoiceNotFound } from '@my-app/common'
141
+
142
+ test('an invoice miss survives the hop as its own class', () => {
143
+ const restored = ResilientError.ensure(
144
+ ResilientError.marshal(new InvoiceNotFound('inv-42')).message
145
+ )
49
146
 
50
- ### `ResilientError`
147
+ expect(restored).toBeInstanceOf(InvoiceNotFound)
148
+ expect(restored.message).toBe('invoice:not-found:inv-42')
149
+ })
150
+ ```
51
151
 
52
- Base class for all OwlMeans errors.
152
+ ### Rebuild state after unmarshalling
53
153
 
54
- - `static typeName: string` — override in subclasses to identify the error type
55
- - `static registerErrorClass(cls)` — register a subclass for automatic reconstruction from JSON
56
- - `static ensure(err, throwOnUnknown?)` — convert any error to `ResilientError`
57
- - `static marshal(err)` — serialize an error to a transportable `Error` object
58
- - `type: string` — error type identifier set in the constructor
154
+ Only `type`, `message` and the stack travel. A subclass that exposes structured data derives it
155
+ from the message in `finalizeUnmarshal()`, which runs after the message is restored.
59
156
 
60
- ### `marshalError(err): Error`
157
+ ```ts
158
+ import { ResilientError } from '@owlmeans/error'
61
159
 
62
- Convenience: ensures the error is a `ResilientError`, then marshals it.
160
+ export class QuotaExceeded extends ResilientError {
161
+ public static override typeName = 'MyAppQuotaExceeded'
162
+
163
+ public limit?: number
164
+
165
+ constructor(message: string = 'error') {
166
+ super(QuotaExceeded.typeName, `quota:${message}`)
167
+ this.finalizeUnmarshal()
168
+ }
169
+
170
+ override finalizeUnmarshal(): void {
171
+ const tail = Number(this.message.split(':').pop())
172
+ this.limit = Number.isFinite(tail) ? tail : undefined
173
+ }
174
+ }
63
175
 
64
- ### `enuserError<T>(err): T`
176
+ ResilientError.registerErrorClass(QuotaExceeded)
177
+ ```
65
178
 
66
- Ensures an unknown caught value is a `ResilientError`. Alias for `ResilientError.ensure`.
179
+ ### Translate errors in the UI
67
180
 
68
- ### `ValueOrError<T>`
181
+ Panel components resolve an error through `errors.<type>` — a form- or screen-scoped
182
+ `<name>.errors.<type>` key first, then `errors.<type>` in the screen's namespace, then the shared
183
+ `errors` library. Ship a translation for every type you declare, in every supported language:
69
184
 
70
- ```typescript
71
- type ValueOrError<T> = T | ResilientError
185
+ ```json
186
+ {
187
+ "errors": {
188
+ "MyAppInvoiceError": "The invoice could not be processed",
189
+ "NotFoundMyAppInvoiceError": "This invoice no longer exists",
190
+ "LockedMyAppInvoiceError": "The invoice is being settled, try again shortly"
191
+ }
192
+ }
72
193
  ```
73
194
 
74
- ## Related Packages
195
+ ## API
196
+
197
+ ### Classes
198
+
199
+ | Symbol | Kind | Purpose |
200
+ |--------|------|---------|
201
+ | `ResilientError` | class | Base class of every framework error; `constructor(type, message, stack?)` |
202
+ | `ResilientError.typeName` | static property | Type identifier; override in each subclass (`'ResilientError'` on the base) |
203
+ | `ResilientError.separator` | static property | Marshalling separator, initialised from `SEPARATOR` |
204
+ | `ResilientError.converters` | static property | The registry of `Converter` entries |
205
+ | `ResilientError.registerErrorClass(Class, errorClass?)` | static method | Registers a subclass so it survives a round trip; returns its `Converter` |
206
+ | `ResilientError.ensure(err, throwOnUnknown?)` | static method | Turns an `Error` or string into a `ResilientError`, unmarshalling registered classes |
207
+ | `ResilientError.marshal(err)` | static method | Flattens an error into a plain `Error` whose message is `type`, `message` and stack joined by `SEPARATOR` |
208
+ | `error.type` | instance property | The error's type identifier |
209
+ | `error.oiriginalStack` | instance property | The stack captured at the original throw (spelling as in source) |
210
+ | `error.marshal()` | instance method | `ResilientError.marshal(this)` |
211
+ | `error.finalizeUnmarshal()` | instance method | Hook run after unmarshalling; no-op by default |
212
+
213
+ ### Functions and constants
214
+
215
+ | Symbol | Kind | Purpose |
216
+ |--------|------|---------|
217
+ | `enuserError<T>(err, throwOnUnknown?)` | function | `ResilientError.ensure`, typed to the subclass you expect |
218
+ | `marshalError(err)` | function | `ensure` then `marshal`, for a boundary that only carries an `Error` or a string |
219
+ | `SEPARATOR` | constant | Three pipe characters — joins the marshalled fields |
220
+ | `RESILENT_ERROR` | constant | `'ResilientError'` — the base type name |
221
+
222
+ ### Types
223
+
224
+ | Symbol | Kind | Purpose |
225
+ |--------|------|---------|
226
+ | `ValueOrError<T>` | type | `T \| ResilientError`, for a result that carries either |
227
+ | `Converter` | interface | `{ match, convert, isMarshaled, unmarshal }` — one registry entry |
228
+ | `ResilientErrorConstructor<T>` | interface | The constructor shape `registerErrorClass` accepts: `new (message, stack?)` plus `typeName` |
229
+
230
+ ### Side effect
231
+
232
+ Importing the package registers the `errors` i18n library (via `addI18nLib` from
233
+ `@owlmeans/i18n`) for `en`, `pl`, `ru`, `be`, `uk`, `es` and `de`. There are no subpath exports.
234
+
235
+ ## Common pitfalls
236
+
237
+ - Register every subclass. An unregistered error comes back as a bare `ResilientError` whose
238
+ `type` is the whole `Type|||message|||stack` string.
239
+ - `ensure` on an unregistered, unmarshalled throw shifts the fields: `new Error('boom')` arrives
240
+ with `type: 'boom'` and the stack as its `message`. Read `.type` only on errors that came back
241
+ through the registered path.
242
+ - `SyntaxError` is rethrown by `ensure`, never converted. Do not throw one for a runtime condition a
243
+ caller is expected to handle.
244
+ - The second argument of `registerErrorClass` and `ensure`'s `throwOnUnknown` have no effect — a
245
+ catch-all converter is matched first. Register the class alone and treat `ensure` as one-argument.
246
+ - A subclass constructor must accept `(message, stack?)`: unmarshalling calls `new Class(message,
247
+ stack)`. Keep extra state in the message and rebuild it in `finalizeUnmarshal()`.
248
+ - Registration happens at import time. The module declaring the errors must be loaded on the
249
+ receiving side before an error is ensured, or the class cannot be rebuilt.
250
+ - Keep `typeName` unique per process; show users the translation of `errors.<type>`, never the
251
+ thrown message.
252
+
253
+ ## Related packages
75
254
 
255
+ - [`@owlmeans/i18n`](../i18n) — the registry the `errors` library is added to
256
+ - [`@owlmeans/client-i18n`](../client-i18n) — React hooks that resolve `errors.<type>` keys
257
+ - [`@owlmeans/auth`](../auth) — the authentication and authorization error hierarchy
258
+ - [`@owlmeans/resource`](../resource) — resource errors built on `ResilientError`
259
+ - [`@owlmeans/server-api`](../server-api) — marshals thrown errors into HTTP responses
260
+ - [`@owlmeans/api`](../api) — rebuilds marshalled errors from HTTP responses
261
+ - [`@owlmeans/client-panel`](../client-panel) — panel and form components that translate errors by type
76
262
  - [`@owlmeans/context`](../context) — context and services that propagate errors through the app
77
263
 
78
264
  <!-- owlmeans:agent-guidance:start -->
@@ -83,7 +269,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
83
269
  your project's skill store (`.agents/skills/`):
84
270
 
85
271
  ```sh
86
- npx @owlmeans/agent-skills
272
+ npx @owlmeans/agent-skills@^0.1.18-rc.25
87
273
  ```
88
274
 
89
275
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/error",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.513Z",
4
+ "version": "0.1.18-rc.21",
5
+ "generatedAt": "2026-09-15T13:06:05.536Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: error
3
- description: How to use @owlmeans/error — base error classes (ResilientError), error normalization, and i18n-aware error types. Auto-invoked when importing from this package or throwing/catching framework errors.
3
+ description: How to use @owlmeans/error — ResilientError, the error class registry, marshalling errors across a service boundary and back, and the i18n namespace error messages resolve through. Auto-invoked when importing from this package, declaring a typed framework error, or normalizing a caught error.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,44 +8,111 @@ user-invocable: false
8
8
  # @owlmeans/error
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/error": "^0.1.18-rc.0"` in `dependencies`
11
+ **Install:** `"@owlmeans/error": "^0.1.18-rc.21"` in `dependencies`
12
12
 
13
13
  ## Key Exports
14
14
 
15
15
  | Export | Description |
16
16
  |--------|-------------|
17
- | `ResilientError` | Base error class — all framework errors extend this |
18
- | `ErrorNormalizer` | Normalize unknown errors into ResilientError instances |
19
- | `ErrorTypes` | Built-in error type aliases |
20
- | i18n helpers | Resolve error messages through the i18n layer |
17
+ | `ResilientError` | Base class — every framework error extends it |
18
+ | `ResilientError.registerErrorClass(Class)` | Register a subclass so it survives a round trip |
19
+ | `ResilientError.ensure(err)` | Turn anything caught into a `ResilientError` |
20
+ | `ResilientError.marshal(err)` / `err.marshal()` | Flatten into a plain `Error` a transport can carry |
21
+ | `enuserError(err)` | `ensure`, typed to the subclass you expect |
22
+ | `marshalError(err)` | `ensure` then `marshal`, for a boundary that only sends `Error` |
23
+ | `SEPARATOR` (`'\|\|\|'`), `RESILENT_ERROR` | The marshalling separator and the base type name |
24
+ | `Converter` | `{ match, convert, isMarshaled, unmarshal }` — one registry entry |
25
+ | `ResilientErrorConstructor` | The constructor shape `registerErrorClass` accepts |
26
+ | `ValueOrError<T>` | `T \| ResilientError`, for a result that carries either |
21
27
 
22
- ## Usage
28
+ ## Declaring an error
23
29
 
24
- Subclass `ResilientError` for typed framework errors with i18n-resolvable messages:
30
+ A subclass owns a static `typeName` and prefixes its messages, so the pair `type` + `message` is
31
+ enough to identify what went wrong anywhere the error travels. **Register it** — registration is
32
+ what makes a marshaled error come back as the class it was thrown as. Skip it and the far side gets
33
+ an unusable `ResilientError` whose `type` is the whole marshaled string (see below).
25
34
 
26
35
  ```typescript
27
36
  import { ResilientError } from '@owlmeans/error'
28
37
 
29
- export class AgentApiError extends ResilientError {
30
- public static override typeName = 'AgentApiError'
31
- public constructor(message: string = 'unknown') {
32
- super(AgentApiError.typeName, `agent-api:${message}`)
38
+ export class ApiError extends ResilientError {
39
+ public static override typeName = 'ApiError'
40
+
41
+ constructor(message: string = 'error') {
42
+ super(ApiError.typeName, `api:${message}`)
43
+ }
44
+ }
45
+
46
+ export class RateLimitError extends ApiError {
47
+ public static override typeName = `${ApiError.typeName}:RateLimit`
48
+
49
+ constructor(message: string = 'error') {
50
+ super(`rate-limit:${message}`)
51
+ this.type = RateLimitError.typeName
33
52
  }
34
53
  }
35
54
 
36
- // In a handler:
37
- throw new AgentApiError('rate-limited')
55
+ ResilientError.registerErrorClass(ApiError)
56
+ ResilientError.registerErrorClass(RateLimitError)
57
+
58
+ throw new RateLimitError('per-minute')
38
59
  ```
39
60
 
40
- Catch and normalize:
61
+ A subclass of a subclass calls `super` with the message alone and then re-stamps `this.type` — the
62
+ parent supplies its own prefix, so the final message reads `api:rate-limit:per-minute`.
63
+
64
+ `registerErrorClass` takes a second, native-class argument, and `ensure` takes a second
65
+ `throwOnUnknown` argument. **Neither has any effect** — a catch-all converter is pushed onto the
66
+ registry when this package loads and it is the first entry `ensure` tests for conversion, so no
67
+ later converter and no `throwOnUnknown` branch is ever reached. Register the class alone, and treat
68
+ `ensure` as taking one argument.
69
+
70
+ ## Normalizing what you caught
71
+
72
+ `ensure` gives you a `ResilientError` for anything caught, but only a **registered, marshaled**
73
+ error survives with its identity intact. Route errors that must keep their type through
74
+ `marshal`/`ensure`; do not rely on `ensure` alone to normalize an arbitrary throw.
75
+
41
76
  ```typescript
42
- import { ErrorNormalizer } from '@owlmeans/error'
43
- try { ... } catch (e) {
44
- const err = ErrorNormalizer.normalize(e)
45
- // err is now a ResilientError with stable .type and .message
77
+ try { /* ... */ } catch (e) {
78
+ const err = ResilientError.ensure(e as Error)
79
+ if (err instanceof RateLimitError) { /* the registered class came back */ }
46
80
  }
47
81
  ```
48
82
 
83
+ What `ensure` actually does, in order:
84
+
85
+ | Input | Result |
86
+ |-------|--------|
87
+ | A `ResilientError` | returned untouched |
88
+ | A `SyntaxError` | rethrown — never converted |
89
+ | An `Error` marshaled from a **registered** class | unmarshaled into that class, `type` and `message` restored |
90
+ | Anything else | a bare `ResilientError` whose **`type` is the original `message`** and whose **`message` is the original stack** |
91
+
92
+ That last row is the trap: the fields are shifted, so an unregistered marshaled error arrives with
93
+ `type` set to the whole `Type|||message|||stack` string, and a plain `new Error('boom')` arrives with
94
+ `type: 'boom'`. Read `.type` only where the error came back through the registered path; keep the
95
+ original around when you need its message.
96
+
97
+ **`SyntaxError` is never converted — it is rethrown.** A `SyntaxError` in this framework means the
98
+ process is wired wrong (an unknown alias, a missing service, a route cycle), and it must crash
99
+ rather than reach a user as a handled failure. Do not throw one for a runtime condition a caller is
100
+ expected to handle.
101
+
102
+ ## Crossing a service boundary
103
+
104
+ `marshal` flattens `type`, `message` and the original stack into one `Error` message joined by
105
+ `SEPARATOR`. On the far side `ensure` recognises the prefix and rebuilds the registered class, so a
106
+ typed error thrown in a backend is caught as the same class in a client. Override
107
+ `finalizeUnmarshal()` on a subclass that needs to rebuild state from its message after that.
108
+
109
+ ## Messages are i18n keys
110
+
111
+ Importing this package registers the `errors` translation library for every bundled locale. UIs
112
+ resolve an error by its `type` — `errors.<type>`, with a form- or screen-scoped key tried first —
113
+ so the message a user reads comes from the translations, never from the thrown string. Ship a
114
+ translation for each error type you declare.
115
+
49
116
  ## Depends On
50
117
 
51
- - `@owlmeans/i18n` — for resolving error messages by key
118
+ - `@owlmeans/i18n` — the translation library the `errors` namespace is registered in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/error",
3
- "version": "0.1.18-rc.2",
3
+ "version": "0.1.18-rc.21",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@owlmeans/i18n": "^0.1.18-rc.2"
24
+ "@owlmeans/i18n": "^0.1.18-rc.21"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@owlmeans/dep-config": "workspace:*",
package/build/.gitkeep DELETED
File without changes