@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.
- package/CHANGELOG.md +67 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +17 -3
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/port/transport.d.ts.map +1 -1
- package/dist/adapters/port/transport.js +2 -0
- package/dist/adapters/port/transport.js.map +1 -1
- package/dist/adapters/websocket/server/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/server/transport.js +2 -0
- package/dist/adapters/websocket/server/transport.js.map +1 -1
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.js +14 -5
- package/dist/adapters/websocket/shared/rpc.js.map +1 -1
- package/dist/browser.d.ts +6 -4
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/errors/index.d.ts +1 -1
- package/dist/client/errors/index.d.ts.map +1 -1
- package/dist/client/errors/index.js +1 -1
- package/dist/client/errors/index.js.map +1 -1
- package/dist/client/errors/registry.d.ts +10 -0
- package/dist/client/errors/registry.d.ts.map +1 -1
- package/dist/client/errors/registry.js +12 -0
- package/dist/client/errors/registry.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +5 -1
- package/dist/events/bus.js.map +1 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/publisher.d.ts +18 -2
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +142 -25
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/recheck.d.ts +27 -5
- package/dist/events/recheck.d.ts.map +1 -1
- package/dist/events/recheck.js +11 -10
- package/dist/events/recheck.js.map +1 -1
- package/dist/procedures/collection/admission.d.ts +26 -7
- package/dist/procedures/collection/admission.d.ts.map +1 -1
- package/dist/procedures/collection/admission.js +39 -7
- package/dist/procedures/collection/admission.js.map +1 -1
- package/dist/procedures/collection/changes.d.ts +28 -4
- package/dist/procedures/collection/changes.d.ts.map +1 -1
- package/dist/procedures/collection/changes.js +50 -6
- package/dist/procedures/collection/changes.js.map +1 -1
- package/dist/procedures/collection/hint-filter.d.ts +143 -0
- package/dist/procedures/collection/hint-filter.d.ts.map +1 -0
- package/dist/procedures/collection/hint-filter.js +311 -0
- package/dist/procedures/collection/hint-filter.js.map +1 -0
- package/dist/procedures/collection/index.d.ts +2 -0
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +2 -0
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/procedures.d.ts +2 -2
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +6 -4
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/index.d.ts +2 -2
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +3 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/server/error-policy.d.ts +96 -0
- package/dist/server/error-policy.d.ts.map +1 -0
- package/dist/server/error-policy.js +196 -0
- package/dist/server/error-policy.js.map +1 -0
- package/dist/server/error-response.d.ts +30 -0
- package/dist/server/error-response.d.ts.map +1 -0
- package/dist/server/error-response.js +90 -0
- package/dist/server/error-response.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/last-resort.d.ts +17 -0
- package/dist/server/last-resort.d.ts.map +1 -0
- package/dist/server/last-resort.js +19 -0
- package/dist/server/last-resort.js.map +1 -0
- package/dist/server/procedure-server.d.ts +3 -11
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +7 -42
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/server.d.ts +43 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +39 -2
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +12 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/http/server/handler.ts +18 -3
- package/src/adapters/port/transport.ts +2 -0
- package/src/adapters/websocket/server/transport.ts +2 -0
- package/src/adapters/websocket/shared/rpc.ts +14 -5
- package/src/browser.ts +11 -0
- package/src/client/errors/index.ts +1 -0
- package/src/client/errors/registry.ts +13 -0
- package/src/client/index.ts +1 -0
- package/src/events/bus.ts +6 -1
- package/src/events/index.ts +1 -1
- package/src/events/publisher.ts +150 -26
- package/src/events/recheck.ts +38 -13
- package/src/procedures/collection/admission.ts +50 -9
- package/src/procedures/collection/changes.ts +68 -7
- package/src/procedures/collection/hint-filter.ts +376 -0
- package/src/procedures/collection/index.ts +3 -0
- package/src/procedures/collection/procedures.ts +8 -6
- package/src/procedures/index.ts +5 -0
- package/src/server/error-policy.ts +263 -0
- package/src/server/error-response.ts +91 -0
- package/src/server/index.ts +2 -0
- package/src/server/last-resort.ts +23 -0
- package/src/server/procedure-server.ts +8 -41
- package/src/server/server.ts +73 -3
- 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
|
|
129
|
-
* change event
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/procedures/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|