@procedures/client 0.35.0 → 0.36.0

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.
Files changed (120) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/adapters/http/server/handler.d.ts.map +1 -1
  4. package/dist/adapters/http/server/handler.js +17 -3
  5. package/dist/adapters/http/server/handler.js.map +1 -1
  6. package/dist/adapters/port/transport.d.ts.map +1 -1
  7. package/dist/adapters/port/transport.js +2 -0
  8. package/dist/adapters/port/transport.js.map +1 -1
  9. package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
  10. package/dist/adapters/websocket/server/transport.js +2 -0
  11. package/dist/adapters/websocket/server/transport.js.map +1 -1
  12. package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
  13. package/dist/adapters/websocket/shared/rpc.js +14 -5
  14. package/dist/adapters/websocket/shared/rpc.js.map +1 -1
  15. package/dist/browser.d.ts +6 -4
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +4 -1
  18. package/dist/browser.js.map +1 -1
  19. package/dist/client/errors/index.d.ts +1 -1
  20. package/dist/client/errors/index.d.ts.map +1 -1
  21. package/dist/client/errors/index.js +1 -1
  22. package/dist/client/errors/index.js.map +1 -1
  23. package/dist/client/errors/registry.d.ts +10 -0
  24. package/dist/client/errors/registry.d.ts.map +1 -1
  25. package/dist/client/errors/registry.js +12 -0
  26. package/dist/client/errors/registry.js.map +1 -1
  27. package/dist/client/index.d.ts +1 -1
  28. package/dist/client/index.d.ts.map +1 -1
  29. package/dist/client/index.js +1 -1
  30. package/dist/client/index.js.map +1 -1
  31. package/dist/events/bus.d.ts.map +1 -1
  32. package/dist/events/bus.js +5 -1
  33. package/dist/events/bus.js.map +1 -1
  34. package/dist/events/index.d.ts +1 -1
  35. package/dist/events/index.d.ts.map +1 -1
  36. package/dist/events/publisher.d.ts +18 -2
  37. package/dist/events/publisher.d.ts.map +1 -1
  38. package/dist/events/publisher.js +142 -25
  39. package/dist/events/publisher.js.map +1 -1
  40. package/dist/events/recheck.d.ts +27 -5
  41. package/dist/events/recheck.d.ts.map +1 -1
  42. package/dist/events/recheck.js +11 -10
  43. package/dist/events/recheck.js.map +1 -1
  44. package/dist/procedures/collection/admission.d.ts +26 -7
  45. package/dist/procedures/collection/admission.d.ts.map +1 -1
  46. package/dist/procedures/collection/admission.js +39 -7
  47. package/dist/procedures/collection/admission.js.map +1 -1
  48. package/dist/procedures/collection/changes.d.ts +28 -4
  49. package/dist/procedures/collection/changes.d.ts.map +1 -1
  50. package/dist/procedures/collection/changes.js +50 -6
  51. package/dist/procedures/collection/changes.js.map +1 -1
  52. package/dist/procedures/collection/hint-filter.d.ts +143 -0
  53. package/dist/procedures/collection/hint-filter.d.ts.map +1 -0
  54. package/dist/procedures/collection/hint-filter.js +311 -0
  55. package/dist/procedures/collection/hint-filter.js.map +1 -0
  56. package/dist/procedures/collection/index.d.ts +2 -0
  57. package/dist/procedures/collection/index.d.ts.map +1 -1
  58. package/dist/procedures/collection/index.js +2 -0
  59. package/dist/procedures/collection/index.js.map +1 -1
  60. package/dist/procedures/collection/procedures.d.ts +2 -2
  61. package/dist/procedures/collection/procedures.d.ts.map +1 -1
  62. package/dist/procedures/collection/procedures.js +6 -4
  63. package/dist/procedures/collection/procedures.js.map +1 -1
  64. package/dist/procedures/index.d.ts +2 -2
  65. package/dist/procedures/index.d.ts.map +1 -1
  66. package/dist/procedures/index.js +3 -1
  67. package/dist/procedures/index.js.map +1 -1
  68. package/dist/server/error-policy.d.ts +96 -0
  69. package/dist/server/error-policy.d.ts.map +1 -0
  70. package/dist/server/error-policy.js +196 -0
  71. package/dist/server/error-policy.js.map +1 -0
  72. package/dist/server/error-response.d.ts +30 -0
  73. package/dist/server/error-response.d.ts.map +1 -0
  74. package/dist/server/error-response.js +90 -0
  75. package/dist/server/error-response.js.map +1 -0
  76. package/dist/server/index.d.ts +2 -0
  77. package/dist/server/index.d.ts.map +1 -1
  78. package/dist/server/index.js +1 -0
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/last-resort.d.ts +17 -0
  81. package/dist/server/last-resort.d.ts.map +1 -0
  82. package/dist/server/last-resort.js +19 -0
  83. package/dist/server/last-resort.js.map +1 -0
  84. package/dist/server/procedure-server.d.ts +3 -11
  85. package/dist/server/procedure-server.d.ts.map +1 -1
  86. package/dist/server/procedure-server.js +7 -42
  87. package/dist/server/procedure-server.js.map +1 -1
  88. package/dist/server/server.d.ts +43 -1
  89. package/dist/server/server.d.ts.map +1 -1
  90. package/dist/server/server.js +39 -2
  91. package/dist/server/server.js.map +1 -1
  92. package/dist/server/types.d.ts +12 -0
  93. package/dist/server/types.d.ts.map +1 -1
  94. package/dist/server/types.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/adapters/http/server/handler.ts +18 -3
  97. package/src/adapters/port/transport.ts +2 -0
  98. package/src/adapters/websocket/server/transport.ts +2 -0
  99. package/src/adapters/websocket/shared/rpc.ts +14 -5
  100. package/src/browser.ts +11 -0
  101. package/src/client/errors/index.ts +1 -0
  102. package/src/client/errors/registry.ts +13 -0
  103. package/src/client/index.ts +1 -0
  104. package/src/events/bus.ts +6 -1
  105. package/src/events/index.ts +1 -1
  106. package/src/events/publisher.ts +150 -26
  107. package/src/events/recheck.ts +38 -13
  108. package/src/procedures/collection/admission.ts +50 -9
  109. package/src/procedures/collection/changes.ts +68 -7
  110. package/src/procedures/collection/hint-filter.ts +376 -0
  111. package/src/procedures/collection/index.ts +3 -0
  112. package/src/procedures/collection/procedures.ts +8 -6
  113. package/src/procedures/index.ts +5 -0
  114. package/src/server/error-policy.ts +263 -0
  115. package/src/server/error-response.ts +91 -0
  116. package/src/server/index.ts +2 -0
  117. package/src/server/last-resort.ts +23 -0
  118. package/src/server/procedure-server.ts +8 -41
  119. package/src/server/server.ts +73 -3
  120. package/src/server/types.ts +13 -0
@@ -62,6 +62,7 @@ import { collectionChangeTopic, startChangeChain, writeWithChangeEvent } from ".
62
62
  import { partitionOfCall, storageOfCall, withPartition, type PartitionInput } from "./partition.js";
63
63
  import { admitsRow, admittedStorage, checkAdmission, type CollectionAdmission } from "./admission.js";
64
64
  import { patchItem } from "./patch-in-parts.js";
65
+ import { withCollectionRules } from "./hint-filter.js";
65
66
 
66
67
  // =============================================================================
67
68
  // Handler Factory
@@ -125,8 +126,8 @@ export interface CollectionProceduresOptions {
125
126
  */
126
127
  changeEvents?: boolean | CollectionChangeEventsOptions | undefined;
127
128
  /**
128
- * The rules of the rows (roadmap CLI-41). `admit` examines each row of each read and of each
129
- * change event with values. `guard` examines each write before the storage changes, and a
129
+ * The rules of the rows (roadmap CLI-41). `admit` examines each row of each read, and each row of
130
+ * each change event for each subscriber (roadmap CLI-78). `guard` examines each write before the storage changes, and a
130
131
  * refused write gets `FORBIDDEN` (reason `write-not-allowed`). With `versionOf`, a guarded write
131
132
  * expects the version that the guard read. `maxScanned` stops the scan of a `find` (roadmap
132
133
  * CLI-55). Without it, the procedures check only the partition.
@@ -185,7 +186,7 @@ export function createCollectionProcedures(collectionName: string, options: Coll
185
186
  checkAdmission(admission);
186
187
  /** A write, and then its change event on the bus of the context. The chain of the events belongs to the storage, not to its view. */
187
188
  const changing = <R>(ctx: ProcedureContext, partition: string | undefined, storage: CollectionStorage<unknown>, ids: readonly string[] | "*", origin: string | undefined, write: () => Promise<R>): Promise<R> =>
188
- writeWithChangeEvent(ctx.bus, storage, topicRule ? topicOf(ctx, partition) : undefined, ids, origin, write, withValues, admission?.admit);
189
+ writeWithChangeEvent(ctx.bus, storage, topicRule ? topicOf(ctx, partition) : undefined, ids, origin, write, withValues, admission);
189
190
  /**
190
191
  * The partition and the storage of a call (roadmap CLI-17). The repository of the call has the
191
192
  * partitions of the collection and their resolver: the tenant comes from the principal. Each read
@@ -195,10 +196,11 @@ export function createCollectionProcedures(collectionName: string, options: Coll
195
196
  const collection = getCollectionFromPath(ctx.path);
196
197
  const partition = await partitionOfCall(ctx, collection, input.partition);
197
198
  const storage = storageOfCall<unknown>(ctx, collection, partition);
198
- return { storage, view: admittedStorage(storage, admission, ctx), partition };
199
+ return { storage, view: admittedStorage(storage, admission, ctx, partition), partition };
199
200
  };
200
201
 
201
- return [
202
+ // The rules of the rows on the metadata: the publisher of the events filters each event with them (roadmap CLI-78)
203
+ return withCollectionRules([
202
204
  // Get
203
205
  defineProcedure({
204
206
  path: [...basePath, "get"],
@@ -485,5 +487,5 @@ export function createCollectionProcedures(collectionName: string, options: Coll
485
487
  return changing(ctx, partition, storage, input.ids, input.origin, () => view.deleteBatch(input.ids, writeOptionsOf(input)));
486
488
  },
487
489
  }),
488
- ];
490
+ ], admission);
489
491
  }
@@ -141,6 +141,9 @@ export {
141
141
  // The rules of the rows of a collection (roadmap CLI-41)
142
142
  admittedStorage,
143
143
  admitsRow,
144
+ // The images of the rows of a change event, for the row filter of each subscriber (roadmap CLI-78)
145
+ withHintImages,
146
+ hintImagesOf,
144
147
  DEFAULT_FIND_LIMIT,
145
148
  DEFAULT_QUERY_OPERATORS,
146
149
  DEFAULT_QUERY_POLICY,
@@ -177,6 +180,8 @@ export type {
177
180
  CollectionChangeEvent,
178
181
  CollectionAdmission,
179
182
  CollectionRow,
183
+ HintImages,
184
+ HintRowState,
180
185
  CollectionWrite,
181
186
  CollectionWriteGuard,
182
187
  CollectionPartitions,
@@ -0,0 +1,263 @@
1
+ /**
2
+ * The error policy of a server (roadmap CLI-80): what an error tells the caller when it leaves
3
+ * the process.
4
+ *
5
+ * `Server.handle()` applies the policy to each response, so each face of the server (HTTP,
6
+ * WebSocket, MCP, a message port, the local face of a host) sends the same error. When
7
+ * `handle()` throws, or a face fails after it (for example, a value that JSON cannot encode), the
8
+ * last-resort catch of the face applies the same policy through `lastResortError`. A nested call
9
+ * of `ctx.client` does not go through `handle()`, so it keeps the real error.
10
+ *
11
+ * - `expose: "all"` (the default): each error goes to the caller as before.
12
+ * - `expose: "coded"`: an error with a known code (`isKnownErrorCode`, or a code of `codes`) goes
13
+ * to the caller as it is. Each other error goes to the caller as `INTERNAL_ERROR` with the
14
+ * message `"internal error"`, and the server logs it one time.
15
+ * - `map` runs first. Its result goes to the caller as it is. `undefined` gives the error to
16
+ * `expose`.
17
+ *
18
+ * The policy reads the real error of a response through the links of `error-response.ts`: the
19
+ * status object, and the request object with the code and the message of the status. A copy of
20
+ * the response or of its status keeps a link.
21
+ */
22
+
23
+ import type { ProcedurePath } from "../procedures/types.js";
24
+ import { closableUnread } from "../procedures/invoke.js";
25
+ import { isKnownErrorCode } from "../client/errors/registry.js";
26
+ import { errorCodeOf } from "../protocol.js";
27
+ import { errorResponse, realErrorOf } from "./error-response.js";
28
+ import { methodToPath } from "./method.js";
29
+ import { rawErrorOf } from "./last-resort.js";
30
+ export { lastResortError } from "./last-resort.js";
31
+ import { ServerError, type ServerRequest, type ServerResponse } from "./types.js";
32
+
33
+ /** The error that the caller gets: its code, its message and its retryable flag. A `ServerError` is one. */
34
+ export interface ExposedError {
35
+ /** The error code, for example `NOT_FOUND`, or a code of the application. */
36
+ readonly code: string;
37
+ /** The message that the caller gets. */
38
+ readonly message: string;
39
+ /**
40
+ * True when a retry of the same call can succeed. Without it, the error gets the flag of its
41
+ * code before the first item of a stream, and false after it (`retryableOf`).
42
+ */
43
+ readonly retryable?: boolean | undefined;
44
+ /** The wait before a retry, in milliseconds, as on an error of a handler (`Retry-After` on HTTP). */
45
+ readonly retryAfterMs?: number | undefined;
46
+ }
47
+
48
+ /** The call of an error that leaves the server. */
49
+ export interface ErrorCall {
50
+ /** The path of the procedure. Empty when the face failed before it knew the call. */
51
+ readonly path: ProcedurePath;
52
+ /** The face that took the request (`http`, `websocket`, `port`, `mcp`), when the face tells it. */
53
+ readonly transport?: string | undefined;
54
+ }
55
+
56
+ /** The error policy of a server: the option `errors` (roadmap CLI-80). */
57
+ export interface ServerErrorPolicy {
58
+ /**
59
+ * Which errors go to the caller as they are:
60
+ * - `"all"` (the default): each error, with its code and its message, as before.
61
+ * - `"coded"`: only an error with a known code (`isKnownErrorCode`: `NOT_FOUND`, `FORBIDDEN`,
62
+ * `VALIDATION_ERROR`, `CONFLICT` and so on, or a code of `codes`), with its code, its
63
+ * message, its retryable flag and its metadata. Each other error goes to the caller as
64
+ * `INTERNAL_ERROR` with the message `"internal error"` and its real retryable flag, with
65
+ * only the metadata keys of `keepMetadata`. The codes `INTERNAL_ERROR`, `HANDLER_ERROR`, `UNKNOWN`,
66
+ * `OUTPUT_VALIDATION_ERROR` and `NO_OUTPUT` tell of a failure of the server, not of a
67
+ * refusal, so their message is hidden too. The server gives the real error to its `onError`
68
+ * logger one time, or to `console.error` without a logger.
69
+ */
70
+ readonly expose?: "all" | "coded" | undefined;
71
+ /**
72
+ * The codes of the application that also go to the caller as they are under `"coded"`, for
73
+ * example `["QUOTA_EXCEEDED"]`.
74
+ */
75
+ readonly codes?: readonly string[] | undefined;
76
+ /**
77
+ * The metadata keys that a hidden error response or a `map` result keeps from the response: an
78
+ * allowlist. Each other key goes, also a key that the server does not know (`issues`, a key of
79
+ * the application). The default is `DEFAULT_KEPT_METADATA`: the trace keys `traceparent` and
80
+ * `tracestate`.
81
+ */
82
+ readonly keepMetadata?: readonly string[] | undefined;
83
+ /**
84
+ * This function runs first, for each error that leaves the server. Its result goes to the
85
+ * caller as it is, also with a code of the application, and with the metadata of an error of a
86
+ * handler (`retryAfterMs`, the conflicts of a `VERSION_CONFLICT` error). `undefined` gives the
87
+ * error to `expose`. A function that throws gives `INTERNAL_ERROR`.
88
+ */
89
+ readonly map?: ((error: unknown, call: ErrorCall) => ExposedError | undefined) | undefined;
90
+ }
91
+
92
+ /** The message of a hidden error. */
93
+ export const HIDDEN_ERROR_MESSAGE = "internal error";
94
+
95
+ /** The codes that tell of a failure of the server, not of a refusal: `"coded"` hides their message. */
96
+ const FAILURE_CODES: ReadonlySet<string> = new Set(["INTERNAL_ERROR", "HANDLER_ERROR", "UNKNOWN", "OUTPUT_VALIDATION_ERROR", "NO_OUTPUT"]);
97
+
98
+ /** The errors that a logger got: each path logs an error one time. */
99
+ const LOGGED = new WeakSet<object>();
100
+
101
+ /**
102
+ * This function records that the logger of the server got this error, so the error policy does
103
+ * not log it again.
104
+ *
105
+ * @internal
106
+ */
107
+ export function markLogged(error: unknown): void {
108
+ if (typeof error === "object" && error !== null) LOGGED.add(error);
109
+ }
110
+
111
+ /** The logger of the hidden errors: the hook `onError` of the server, or `console.error`. */
112
+ export type HiddenErrorLog = (error: unknown, request: ServerRequest<unknown> | undefined) => void;
113
+
114
+ /** This function gives an error to the logger, when the logger did not get it before. */
115
+ function logOnce(log: HiddenErrorLog, error: unknown, request: ServerRequest<unknown> | undefined): void {
116
+ if (typeof error === "object" && error !== null && LOGGED.has(error)) return;
117
+ markLogged(error);
118
+ log(error, request);
119
+ }
120
+
121
+ /** True when the policy can change a response: `"coded"`, or a `map` function. */
122
+ export function policyActs(policy: ServerErrorPolicy | undefined): policy is ServerErrorPolicy {
123
+ return policy !== undefined && (policy.expose === "coded" || policy.map !== undefined);
124
+ }
125
+
126
+ /**
127
+ * The call of a request, for `map`.
128
+ *
129
+ * @internal
130
+ */
131
+ export function callOf(request: ServerRequest<unknown> | undefined): ErrorCall {
132
+ if (request === undefined) return { path: [] };
133
+ let path: ProcedurePath;
134
+ try {
135
+ path = methodToPath(request.method);
136
+ } catch {
137
+ path = [];
138
+ }
139
+ return request.transport !== undefined ? { path, transport: request.transport } : { path };
140
+ }
141
+
142
+ /** The decision of the policy for one error. */
143
+ type Decision =
144
+ | { readonly kind: "keep" }
145
+ | { readonly kind: "replace"; readonly error: ExposedError }
146
+ | { readonly kind: "hide" };
147
+
148
+ /**
149
+ * The decision of the policy for one error. `code` is the code that the error has on the wire. A
150
+ * `map` result is final. `"coded"` keeps an error with a code of `codes`, or with a known code
151
+ * that is not a failure code.
152
+ */
153
+ function decide(policy: ServerErrorPolicy, error: unknown, code: string, call: ErrorCall, logMapError: (error: unknown) => void): Decision {
154
+ if (policy.map) {
155
+ let mapped: ExposedError | undefined;
156
+ try {
157
+ mapped = policy.map(error, call);
158
+ } catch (mapError) {
159
+ logMapError(mapError);
160
+ return { kind: "hide" };
161
+ }
162
+ if (mapped !== undefined) return { kind: "replace", error: mapped };
163
+ }
164
+ if (policy.expose !== "coded") return { kind: "keep" };
165
+ if (policy.codes?.includes(code) === true) return { kind: "keep" };
166
+ if (isKnownErrorCode(code) && !FAILURE_CODES.has(code)) return { kind: "keep" };
167
+ return { kind: "hide" };
168
+ }
169
+
170
+ /** A `map` result as an error: a result that is not an `Error` becomes one, with its fields. */
171
+ function errorOfResult(result: ExposedError): Error {
172
+ return result instanceof Error ? result : Object.assign(new Error(result.message), result);
173
+ }
174
+
175
+ /** The metadata keys that a hidden error response keeps by default: the trace keys (CLI-80). */
176
+ export const DEFAULT_KEPT_METADATA: readonly string[] = ["traceparent", "tracestate"];
177
+
178
+ /** The metadata of a response with only the keys of the allowlist `keepMetadata` of the policy. */
179
+ function keptMetadata(policy: ServerErrorPolicy, metadata: ServerResponse<unknown>["metadata"]): ServerResponse<unknown>["metadata"] {
180
+ const keys = policy.keepMetadata ?? DEFAULT_KEPT_METADATA;
181
+ const kept: Record<string, unknown> = {};
182
+ for (const [key, value] of Object.entries(metadata ?? {})) if (keys.includes(key)) kept[key] = value;
183
+ return kept as ServerResponse<unknown>["metadata"];
184
+ }
185
+
186
+ /**
187
+ * This function applies the error policy to a response that leaves the server: to an error
188
+ * status, and to the error that ends a stream.
189
+ *
190
+ * @internal
191
+ */
192
+ export function exposedResponse<T>(policy: ServerErrorPolicy, request: ServerRequest<unknown>, response: ServerResponse<T>, log: HiddenErrorLog): ServerResponse<T> {
193
+ const status = response.status;
194
+ if (status.type === "error") {
195
+ // A response that the server made without an error (a refusal of the token, a duplicate
196
+ // stream), or a status without the link, gives `map` a ServerError of its status
197
+ const link = realErrorOf(response, request);
198
+ const real = link.found ? link.error : new ServerError(status.message, status.code, status.retryable);
199
+ const decision = decide(policy, real, status.code, callOf(request), (mapError) => logOnce(log, mapError, request));
200
+ if (decision.kind === "keep") return response;
201
+ const { stream: _stream, payload: _payload, ...rest } = response;
202
+ // Only the keys of the allowlist stay (the trace keys): an unknown key can tell of the error
203
+ const kept = keptMetadata(policy, response.metadata);
204
+ if (decision.kind === "replace") {
205
+ // The usual error response of the result: its wait and its conflicts go out too
206
+ const built = errorResponse(response.id, errorOfResult(decision.error));
207
+ return { ...rest, status: built.status, metadata: { ...kept, ...built.metadata } };
208
+ }
209
+ logOnce(log, real, request);
210
+ return {
211
+ ...rest,
212
+ // The retryable flag tells nothing private: a transient failure stays retryable
213
+ status: { type: "error", code: "INTERNAL_ERROR", message: HIDDEN_ERROR_MESSAGE, retryable: status.retryable },
214
+ metadata: kept,
215
+ };
216
+ }
217
+ if (response.stream === undefined) return response;
218
+ return { ...response, stream: exposedStream(policy, request, response.stream, log) };
219
+ }
220
+
221
+ /**
222
+ * The items of a stream, with the error policy for the error that ends it. A kept error goes on
223
+ * unchanged. Another error goes on as an error of its exposed code and message.
224
+ */
225
+ function exposedStream<T>(policy: ServerErrorPolicy, request: ServerRequest<unknown>, items: AsyncIterable<T>, log: HiddenErrorLog): AsyncGenerator<T, void, undefined> {
226
+ const exposed = (async function* () {
227
+ try {
228
+ yield* items;
229
+ } catch (error) {
230
+ const decision = decide(policy, error, errorCodeOf(error), callOf(request), (mapError) => logOnce(log, mapError, request));
231
+ if (decision.kind === "keep") throw error;
232
+ if (decision.kind === "hide") {
233
+ logOnce(log, error, request);
234
+ // The real retryable flag of the error goes on. A last-resort catch that gets this error
235
+ // does not log it again.
236
+ const hidden = new ServerError(HIDDEN_ERROR_MESSAGE, "INTERNAL_ERROR", (error as { retryable?: unknown } | null)?.retryable === true);
237
+ markLogged(hidden);
238
+ throw hidden;
239
+ }
240
+ // Without a flag, the face gives the flag of the code (before the first item) or false
241
+ throw errorOfResult(decision.error);
242
+ }
243
+ })();
244
+ return closableUnread(exposed, items);
245
+ }
246
+
247
+
248
+ /**
249
+ * The error that a last-resort catch sends under a policy: when `handle()` throws, or when a face
250
+ * fails after it. `raw` is the error that the face sends without a policy. `map` runs first. Under
251
+ * `"coded"`, the code of `raw` decides, so `INTERNAL_ERROR` gives `"internal error"`, and the
252
+ * logger gets the real error one time.
253
+ */
254
+ export function exposeError(policy: ServerErrorPolicy | undefined, error: unknown, call: ErrorCall, log: (error: unknown) => void, raw: ExposedError = rawErrorOf(error)): ExposedError {
255
+ if (!policyActs(policy)) return raw;
256
+ const once: HiddenErrorLog = (logged) => log(logged);
257
+ const decision = decide(policy, error, raw.code, call, (mapError) => logOnce(once, mapError, undefined));
258
+ if (decision.kind === "keep") return raw;
259
+ if (decision.kind === "replace") return decision.error;
260
+ logOnce(once, error, undefined);
261
+ return { code: "INTERNAL_ERROR", message: HIDDEN_ERROR_MESSAGE, retryable: raw.retryable === true };
262
+ }
263
+
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The error response of the server, and the link from an error status to its real error (roadmap
3
+ * CLI-80).
4
+ *
5
+ * The error policy of a server (`error-policy.ts`) reads the real error of a response: the error
6
+ * object that a handler threw, of its own class. Two links lead to it, and neither is a field of
7
+ * the response, so the wire, a log of the response and a deep comparison never see them:
8
+ * - The status object of the error response. A copy of the response (`{ ...response, id }`) keeps
9
+ * the same status.
10
+ * - The request object of the call, with the code and the message of the status. A copy of the
11
+ * status (`{ ...response.status }`, for example in a middleware or a wrapper) keeps the code
12
+ * and the message, so the link holds. A status with another code or message is another error,
13
+ * and has no link.
14
+ * - Without a link (a middleware copied both the request and the status, or made a new error),
15
+ * the policy gives `map` a `ServerError` of the code, the message and the retryable flag of the
16
+ * status.
17
+ */
18
+
19
+ import { InvocationError } from "../procedures/invoke.js";
20
+ import { VERSION_CONFLICTS_METADATA_KEY, versionConflictsOf } from "../procedures/collection/precondition.js";
21
+ import { errorCodeOf, retryableOf } from "../protocol.js";
22
+ import type { ServerResponse } from "./types.js";
23
+
24
+ /** The real error of each error status that the server made from an error. */
25
+ const ERRORS_OF_STATUSES = new WeakMap<object, unknown>();
26
+
27
+ /** The last real error of each request, with the code and the message of its status. */
28
+ const ERRORS_OF_REQUESTS = new WeakMap<object, { readonly code: string; readonly message: string; readonly error: unknown }>();
29
+
30
+ /**
31
+ * This function records the real error of an error response, for the error policy: by its status
32
+ * object, and by the request object of the call. The response does not change.
33
+ *
34
+ * @internal
35
+ */
36
+ export function withRealError<T extends ServerResponse<unknown>>(response: T, error: unknown, request?: object): T {
37
+ ERRORS_OF_STATUSES.set(response.status, error);
38
+ const status = response.status;
39
+ if (request !== undefined && status.type === "error") ERRORS_OF_REQUESTS.set(request, { code: String(status.code), message: status.message, error });
40
+ return response;
41
+ }
42
+
43
+ /**
44
+ * The real error of an error status of a request, or `{ found: false }` when no link leads to it.
45
+ *
46
+ * @internal
47
+ */
48
+ export function realErrorOf(response: ServerResponse<unknown>, request?: object): { readonly found: true; readonly error: unknown } | { readonly found: false } {
49
+ const status = response.status;
50
+ if (ERRORS_OF_STATUSES.has(status)) return { found: true, error: ERRORS_OF_STATUSES.get(status) };
51
+ const recorded = request !== undefined ? ERRORS_OF_REQUESTS.get(request) : undefined;
52
+ if (recorded !== undefined && status.type === "error" && recorded.code === String(status.code) && recorded.message === status.message) return { found: true, error: recorded.error };
53
+ return { found: false };
54
+ }
55
+
56
+ /**
57
+ * The error response of a failed invocation. An invocation error keeps its code
58
+ * (VALIDATION_ERROR, NOT_EXPOSED, ABORTED and so on). An error of the handler keeps its own code
59
+ * when it is a string, as for a stream (`WIRE-PROTOCOL.md`). Without a code, it is HANDLER_ERROR.
60
+ * An error without a retryable flag gets the flag of its code in `ERROR_CODES`, or false (CLI-48).
61
+ * An in-band end before the first item of a stream gets the same default. After the first item,
62
+ * an in-band end without a flag gets false (`retryableOf`, CLI-58, CLI-68, `WIRE-PROTOCOL.md`,
63
+ * section "Error codes"). `request` is the request of the call: the link of the error policy to the
64
+ * real error (CLI-80).
65
+ */
66
+ export function errorResponse(id: string, error: unknown, request?: object): ServerResponse<unknown> {
67
+ // A refusal of the capability says why (CAPABILITIES-2026-10)
68
+ const reason = error instanceof InvocationError ? error.reason : undefined;
69
+ // An error can ask the client to wait before a retry: retryAfterMs, in milliseconds (R12)
70
+ const retryAfterMs = (error as { retryAfterMs?: unknown } | undefined)?.retryAfterMs;
71
+ const metadata: Record<string, unknown> = reason ? { reason } : {};
72
+ if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) metadata["retry-after"] = retryAfterMs;
73
+ // A VERSION_CONFLICT error tells the current item of each conflict (roadmap CLI-16)
74
+ const conflicts = versionConflictsOf(error);
75
+ if (conflicts !== undefined) metadata[VERSION_CONFLICTS_METADATA_KEY] = conflicts;
76
+ const code = errorCodeOf(error);
77
+ // The error policy of the server (CLI-80) reads the real error of the response
78
+ return withRealError({
79
+ id,
80
+ status: {
81
+ type: "error",
82
+ code,
83
+ message: error instanceof Error ? error.message : String(error),
84
+ // An error that says it is retryable stays retryable: before, every error lost the flag (deep
85
+ // dive TRN-7). Without a flag, the default of the code: UNAVAILABLE stays retryable, and
86
+ // keeps HTTP 503 (CLI-48). No item went out before this response.
87
+ retryable: retryableOf(error, 0),
88
+ },
89
+ metadata,
90
+ }, error, request);
91
+ }
@@ -40,6 +40,8 @@
40
40
 
41
41
  export { Server } from "./server.js";
42
42
  export type { ServerOptions } from "./server.js";
43
+ export type { ServerErrorPolicy, ExposedError, ErrorCall } from "./error-policy.js";
44
+ export { exposeError, lastResortError, HIDDEN_ERROR_MESSAGE, DEFAULT_KEPT_METADATA } from "./error-policy.js";
43
45
 
44
46
  export { ProcedureServer, createProcedureServer, STREAM_METADATA_KEY, isUnboundedStream } from "./procedure-server.js";
45
47
  export type { ProcedureServerOptions, RegisterProcedureOptions, StorageConfig, StopOptions, StopParts, StorageHandle, UseStorageOptions } from "./procedure-server.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The last-resort catch of a face (roadmap CLI-80): when `handle()` throws, or the face fails after
3
+ * it. This module has no runtime import, so a face in a browser bundle (for example
4
+ * `PortTransport`) does not load the error policy and its imports. The policy itself runs in the
5
+ * server, through `RequestHandler.exposeError`.
6
+ */
7
+
8
+ import type { ExposedError } from "./error-policy.js";
9
+ import type { RequestHandler, ServerRequest } from "./types.js";
10
+
11
+ /** The error that a last-resort catch sends without a policy: `INTERNAL_ERROR` with the message of the error. */
12
+ export function rawErrorOf(error: unknown): ExposedError {
13
+ return { code: "INTERNAL_ERROR", message: error instanceof Error ? error.message || "Internal server error" : String(error), retryable: false };
14
+ }
15
+
16
+ /**
17
+ * The error that the last-resort catch of a face sends: the error policy of the handler
18
+ * (`RequestHandler.exposeError`, which `Server` has), or `raw` for a handler without one. `raw`
19
+ * is the error that the face sends without a policy.
20
+ */
21
+ export function lastResortError(handler: RequestHandler, error: unknown, request: ServerRequest<unknown> | undefined, raw: ExposedError = rawErrorOf(error)): ExposedError {
22
+ return handler.exposeError ? handler.exposeError(error, request, raw) : raw;
23
+ }
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  import { Server, type ServerOptions } from "./server.js";
9
+ import { errorResponse } from "./error-response.js";
10
+
11
+ export { errorResponse };
9
12
  import type { Admission, AmbientCredentials, Credentials, Method, Principal, PrincipalOf, RequestIdentity, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
10
13
  import { methodToPath, pathToMethod } from "./method.js";
11
14
  import { isLocalCredentials } from "./request-auth.js";
@@ -21,14 +24,13 @@ import { createCollectionProcedures } from "../procedures/collection/procedures.
21
24
  import { InvocationError, closableUnread, invokeProcedure, type AdmitItem, type Interceptor, type Invocation } from "../procedures/invoke.js";
22
25
  import type { Capability, ClearanceOrder, ProcedureContext, ResourcesOf } from "../procedures/types.js";
23
26
  import type { CollectionPartitions } from "../procedures/collection/partition.js";
24
- import { VERSION_CONFLICTS_METADATA_KEY, versionConflictsOf } from "../procedures/collection/precondition.js";
25
27
  import { TIMEOUT_METADATA_KEY, effectsOf, joinNarrowing, narrowCapability } from "../procedures/capability.js";
26
28
  import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptions } from "../procedures/idempotency.js";
27
29
  import { optionalMs, type Duration } from "../duration.js";
28
30
  import type { CollectionStorage } from "../procedures/collection/storage.js";
29
31
  import { TIMER_DEFAULTS } from "../timers.js";
30
32
  import { realClock, type Clock } from "../clock.js";
31
- import { errorCodeOf, retryableOf } from "../protocol.js";
33
+ import { retryableOf } from "../protocol.js";
32
34
  import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
33
35
  import { getGlobalEventBus } from "../events/bus.js";
34
36
  import type { CollectionProceduresOptions } from "../procedures/collection/procedures.js";
@@ -382,41 +384,6 @@ function withRequestTimeout(
382
384
  return narrowCapability(capability, { timeoutMs }, order);
383
385
  }
384
386
 
385
- /**
386
- * The error response of a failed invocation. An invocation error keeps its code
387
- * (VALIDATION_ERROR, NOT_EXPOSED, ABORTED and so on). An error of the handler keeps its own code
388
- * when it is a string, as for a stream (`WIRE-PROTOCOL.md`). Without a code, it is HANDLER_ERROR.
389
- * An error without a retryable flag gets the flag of its code in `ERROR_CODES`, or false (CLI-48).
390
- * An in-band end before the first item of a stream gets the same default. After the first item,
391
- * an in-band end without a flag gets false (`retryableOf`, CLI-58, CLI-68, `WIRE-PROTOCOL.md`,
392
- * section "Error codes").
393
- */
394
- export function errorResponse(id: string, error: unknown): ServerResponse<unknown> {
395
- // A refusal of the capability says why (CAPABILITIES-2026-10)
396
- const reason = error instanceof InvocationError ? error.reason : undefined;
397
- // An error can ask the client to wait before a retry: retryAfterMs, in milliseconds (R12)
398
- const retryAfterMs = (error as { retryAfterMs?: unknown } | undefined)?.retryAfterMs;
399
- const metadata: Record<string, unknown> = reason ? { reason } : {};
400
- if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) metadata["retry-after"] = retryAfterMs;
401
- // A VERSION_CONFLICT error tells the current item of each conflict (roadmap CLI-16)
402
- const conflicts = versionConflictsOf(error);
403
- if (conflicts !== undefined) metadata[VERSION_CONFLICTS_METADATA_KEY] = conflicts;
404
- const code = errorCodeOf(error);
405
- return {
406
- id,
407
- status: {
408
- type: "error",
409
- code,
410
- message: error instanceof Error ? error.message : String(error),
411
- // An error that says it is retryable stays retryable: before, every error lost the flag (deep
412
- // dive TRN-7). Without a flag, the default of the code: UNAVAILABLE stays retryable, and
413
- // keeps HTTP 503 (CLI-48). No item went out before this response.
414
- retryable: retryableOf(error, 0),
415
- },
416
- metadata,
417
- };
418
- }
419
-
420
387
  // =============================================================================
421
388
  // Procedure Server
422
389
  // =============================================================================
@@ -977,7 +944,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
977
944
  return refused ? rateLimitedResponse(request.id, refused) : undefined;
978
945
  } catch (error) {
979
946
  const message = `The limits of the server cannot admit the call: ${error instanceof Error ? error.message : String(error)}`;
980
- return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }));
947
+ return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }), request);
981
948
  }
982
949
  }
983
950
 
@@ -1083,7 +1050,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1083
1050
  );
1084
1051
  }
1085
1052
  const response = await untilAborted(result, request.signal);
1086
- if (response === undefined) return errorResponse(request.id, new InvocationError("ABORTED", "The request was aborted", procedure.path));
1053
+ if (response === undefined) return errorResponse(request.id, new InvocationError("ABORTED", "The request was aborted", procedure.path), request);
1087
1054
  if (response.stream) {
1088
1055
  return {
1089
1056
  id: request.id,
@@ -1138,7 +1105,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1138
1105
  capability = this.capabilityOf(request);
1139
1106
  } catch (error) {
1140
1107
  const message = `The server cannot narrow the capability of the request: ${error instanceof Error ? error.message : String(error)}`;
1141
- return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }));
1108
+ return errorResponse(request.id, Object.assign(new Error(message), { code: "INTERNAL_ERROR" }), request);
1142
1109
  }
1143
1110
  const output = await invokeProcedure(procedure, request.payload, {
1144
1111
  registry: this.procedureRegistry,
@@ -1175,7 +1142,7 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1175
1142
  }
1176
1143
  return { id: request.id, status: { type: "success", code: 200 }, payload: output.value, metadata: {} };
1177
1144
  } catch (error) {
1178
- return errorResponse(request.id, error);
1145
+ return errorResponse(request.id, error, request);
1179
1146
  } finally {
1180
1147
  if (!streams) release();
1181
1148
  }