lambder 9.0.3 → 10.0.1

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 (72) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +25 -11
  3. package/dist/api/LambderApiDefinition.d.ts +9 -0
  4. package/dist/api/LambderApiEnvelope.d.ts +22 -10
  5. package/dist/api/LambderApiEnvelope.js +57 -22
  6. package/dist/api/LambderApiGuards.d.ts +77 -22
  7. package/dist/api/LambderApiGuards.js +13 -1
  8. package/dist/api/LambderApiIdempotency.js +4 -4
  9. package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
  10. package/dist/api/LambderApiOutputValidationError.js +14 -6
  11. package/dist/api/LambderApiPipeline.d.ts +2 -1
  12. package/dist/api/LambderApiPipeline.js +10 -2
  13. package/dist/api/LambderApiRateLimits.d.ts +15 -11
  14. package/dist/api/LambderApiRateLimits.js +31 -7
  15. package/dist/api/LambderApiRefusals.d.ts +204 -0
  16. package/dist/api/LambderApiRefusals.js +223 -0
  17. package/dist/api/LambderApiSignature.d.ts +13 -4
  18. package/dist/api/LambderApiSignature.js +21 -4
  19. package/dist/build/writeApiOptions.js +7 -5
  20. package/dist/client/LambderCaller.d.ts +28 -17
  21. package/dist/client/LambderCaller.js +19 -15
  22. package/dist/client.d.ts +5 -4
  23. package/dist/client.js +2 -1
  24. package/dist/core/Lambder.d.ts +121 -23
  25. package/dist/core/Lambder.js +105 -51
  26. package/dist/core/LambderContext.d.ts +1 -1
  27. package/dist/core/LambderContext.js +5 -0
  28. package/dist/core/LambderCreateOptions.d.ts +58 -15
  29. package/dist/core/LambderCreateOptions.js +10 -0
  30. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  31. package/dist/core/LambderPolicyBuilders.js +10 -8
  32. package/dist/core/LambderResolver.d.ts +1 -1
  33. package/dist/core/LambderResolver.js +1 -1
  34. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  35. package/dist/core/LambderResponseBuilder.js +12 -11
  36. package/dist/index.d.ts +12 -8
  37. package/dist/index.js +6 -2
  38. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  39. package/dist/invoke/LambderInvokeCaller.js +13 -12
  40. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  41. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  42. package/dist/mock/LambderMockApp.d.ts +86 -24
  43. package/dist/mock/LambderMockApp.js +108 -16
  44. package/dist/mock/LambderMockCallRecorder.js +6 -3
  45. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  46. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  47. package/dist/mock/LambderMockFailureInjector.js +18 -4
  48. package/dist/mock/LambderMockTypes.d.ts +34 -11
  49. package/dist/mock.d.ts +4 -3
  50. package/dist/mock.js +4 -3
  51. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  52. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  53. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  54. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  55. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  56. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  57. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  58. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  59. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  60. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  61. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  62. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  63. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  64. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  65. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  66. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  67. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  68. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  69. package/dist/testing/LambderTestApp.d.ts +1 -1
  70. package/dist/testing.d.ts +1 -1
  71. package/dist/testing.js +1 -1
  72. package/package.json +1 -1
@@ -1,13 +1,13 @@
1
1
  import type { LambderHttpStatusCode } from "./LambderHttpStatus.js";
2
2
  export type LambderApiRefusalOptions = {
3
3
  /**
4
- * User-facing failure detail placed on the API envelope's `errorMessage`
4
+ * User-facing failure detail placed on the API envelope's `refusal`
5
5
  * field: a refusal message (`{ type: "warning", content: "..." }`, with
6
6
  * an app's own `code`) or a plain string, which becomes an "error"
7
7
  * message with that content. Defaults to the error message, so a bare
8
8
  * `throw new LambderApiRefusal("...")` is still visible to the client.
9
9
  */
10
- errorMessage?: LambderAppRefusalMessage | string;
10
+ refusal?: LambderUncheckedRefusalMessage | string;
11
11
  /** Sets the envelope's `notAuthorized` flag (routed to the caller's notAuthorizedHandler). */
12
12
  notAuthorized?: boolean;
13
13
  /** Sets the envelope's `sessionExpired` flag (the caller clears session cookies and calls sessionExpiredHandler). */
@@ -27,7 +27,7 @@ export type LambderApiRefusalOptions = {
27
27
  * A typed refusal: "this request is denied/invalid" as opposed to "the server
28
28
  * crashed". Throw it from anywhere in an API call's call stack (handlers,
29
29
  * hooks, guards or nested helpers) and the render pipeline maps it onto the
30
- * structured API envelope (its errorMessage, notAuthorized and
30
+ * structured API envelope (its refusal, notAuthorized and
31
31
  * sessionExpired) instead of routing it through setGlobalErrorHandler, so refusals never reach crash
32
32
  * logging and clients receive a parseable response.
33
33
  *
@@ -45,8 +45,8 @@ export declare class LambderApiRefusal extends Error {
45
45
  * across them while this marker does not. The pipeline checks the brand.
46
46
  */
47
47
  readonly isLambderApiRefusal = true;
48
- /** What the envelope's errorMessage carries: always a message object, a plain string given to the options made into one. */
49
- readonly errorMessage: LambderAppRefusalMessage;
48
+ /** What the envelope's refusal carries: always a message object, a plain string given to the options made into one. */
49
+ readonly refusal: LambderUncheckedRefusalMessage;
50
50
  readonly notAuthorized?: boolean;
51
51
  readonly sessionExpired?: boolean;
52
52
  readonly statusCode?: LambderHttpStatusCode;
@@ -55,44 +55,70 @@ export declare class LambderApiRefusal extends Error {
55
55
  }
56
56
  /** Brand-based type guard (see LambderApiRefusal.isLambderApiRefusal). */
57
57
  export declare const isLambderApiRefusal: (err: unknown) => err is LambderApiRefusal;
58
- /**
59
- * The standard shape refusals carry on the envelope's errorMessage field.
60
- * `code` is the refusal's machine-readable identity: clients branch and
61
- * translate on it and never string-match `content`, which stays the
62
- * human-readable fallback for codes a client does not know yet. The
63
- * framework's own refusals carry a LambderRefusalCode; apps put their own
64
- * typed vocabulary in `code`. The caller's errorMessageHandler receives the
65
- * object as-is, typed as LambderAppRefusalMessage; a server that wrote a
66
- * plain string reaches it as `{ type: "error", content }` (refusalMessageOf).
67
- */
68
- export type LambderRefusalMessage<TAppCode extends string = never> = {
58
+ /** What every refusal message carries, whatever its code. */
59
+ type LambderRefusalMessageFields = {
60
+ /** Rendering intent for the client: how loud to be about it. */
69
61
  type: "warning" | "error" | "info";
62
+ /** Optional heading shown above the content. */
63
+ title?: string;
70
64
  /**
71
- * Machine-readable identity of the refusal: a LambderRefusalCode, plus
72
- * whatever vocabulary the reader names in TAppCode.
73
- *
74
- * Parameterized rather than widened with `string & {}`: a union with
75
- * `string` in it does not narrow, so a `switch(message.code)` could not
76
- * end in a `default: never` exhaustiveness check, which is what the codes
77
- * exist for. A client that reads its own vocabulary declares it
78
- * (`LambderRefusalMessage<"app/not-verified" | ...>`) and gets a checked
79
- * switch; a value an app WRITES takes LambderAppRefusalMessage, where any
80
- * code is welcome.
65
+ * The human-readable text: what a client shows when it has nothing better.
66
+ * A client that translates reads the code and the data instead, and falls
67
+ * back to this for a code it does not know.
81
68
  */
82
- code?: LambderRefusalCode | TAppCode;
83
- title?: string;
84
69
  content: string;
85
70
  };
86
71
  /**
87
- * The refusal shape an app authors: any code, with the framework's own still
88
- * autocompleting. Every option that takes a message from an app is typed as
89
- * this (a rate-limit policy's errorMessage, the mock's failure injection);
90
- * LambderRefusalMessage itself defaults to the framework's codes alone, so a
91
- * reader's switch over it is exhaustive.
72
+ * A refusal no app declared: one the framework wrote itself (a
73
+ * LambderRefusalCode), or an app's `refuse("...")` without a code. It never
74
+ * carries data.
92
75
  */
93
- export type LambderAppRefusalMessage = LambderRefusalMessage<string & {}>;
76
+ export type LambderPlainRefusalMessage = LambderRefusalMessageFields & {
77
+ code?: LambderRefusalCode;
78
+ data?: undefined;
79
+ };
94
80
  /**
95
- * An errorMessage as the one shape a reader handles: a plain string becomes
81
+ * One declared code's message: the code, and its data when the code declares
82
+ * data. TDeclaration is the code's value in a contract entry's `refusals`:
83
+ * `{ data: D }` or `{}`.
84
+ */
85
+ type LambderDeclaredRefusalMessage<TCode extends string, TDeclaration> = LambderRefusalMessageFields & {
86
+ code: TCode;
87
+ } & (TDeclaration extends {
88
+ data: infer TData;
89
+ } ? {
90
+ data: TData;
91
+ } : {
92
+ data?: undefined;
93
+ });
94
+ /**
95
+ * The refusal message a reader receives, discriminated by `code`.
96
+ *
97
+ * TRefusals maps each code the endpoint declares to its contract value
98
+ * (`{ data: D }` or `{}`; see LambderContractRefusalsOf). Every declared code
99
+ * is one arm, carrying its own data type, and the framework's codes and the
100
+ * uncoded refusal are one more arm with no data. A `switch (message.code)`
101
+ * therefore narrows `data` in each case, and a `default: never` holds: the
102
+ * server refuses to send a code the endpoint did not declare (see
103
+ * LambderApiRefusalValidationError), so no other code can arrive.
104
+ *
105
+ * Left without an argument it is the framework's own vocabulary alone.
106
+ */
107
+ export type LambderRefusalMessage<TRefusals = {}> = 0 extends 1 & TRefusals ? LambderUncheckedRefusalMessage : LambderPlainRefusalMessage | {
108
+ [TCode in keyof TRefusals & string]: LambderDeclaredRefusalMessage<TCode, TRefusals[TCode]>;
109
+ }[keyof TRefusals & string];
110
+ /**
111
+ * The refusal shape an app writes, and the widest a reader can be handed:
112
+ * any code (the framework's still autocomplete) and any data. What
113
+ * LambderApiRefusal carries and refusalMessageOf reads; a reader that knows
114
+ * the endpoint reads the narrower LambderRefusalMessage instead.
115
+ */
116
+ export type LambderUncheckedRefusalMessage = LambderRefusalMessageFields & {
117
+ code?: LambderRefusalCode | (string & {});
118
+ data?: unknown;
119
+ };
120
+ /**
121
+ * An refusal as the one shape a reader handles: a plain string becomes
96
122
  * an "error" message with that content, and a value that is not a message
97
123
  * at all becomes one describing it. The envelope writer applies it, so a
98
124
  * Lambder server only ever sends the object. A reader applies it too,
@@ -100,7 +126,7 @@ export type LambderAppRefusalMessage = LambderRefusalMessage<string & {}>;
100
126
  * mock answer (an MSW handler, a test double) or a proxy that wrote its own
101
127
  * body can put anything there.
102
128
  */
103
- export declare const refusalMessageOf: (message: unknown) => LambderAppRefusalMessage;
129
+ export declare const refusalMessageOf: (message: unknown) => LambderUncheckedRefusalMessage;
104
130
  /**
105
131
  * Codes the framework stamps on the refusals it authors itself, under the
106
132
  * reserved `lambder/` prefix so app codes never collide. Compare against
@@ -108,7 +134,7 @@ export declare const refusalMessageOf: (message: unknown) => LambderAppRefusalMe
108
134
  * than retyping the strings.
109
135
  */
110
136
  export declare const LAMBDER_REFUSAL_CODES: {
111
- /** A rate-limit policy refused (429). A policy's own errorMessage inherits this unless it sets a code. */
137
+ /** A rate-limit policy refused (429). A policy's own message carries it. */
112
138
  readonly rateLimited: "lambder/rate-limited";
113
139
  /** The original of an idempotent request is still processing (409). */
114
140
  readonly duplicateInFlight: "lambder/duplicate-in-flight";
@@ -130,18 +156,31 @@ export declare const LAMBDER_REFUSAL_CODES: {
130
156
  readonly uploadTooLarge: "lambder/upload-too-large";
131
157
  };
132
158
  export type LambderRefusalCode = (typeof LAMBDER_REFUSAL_CODES)[keyof typeof LAMBDER_REFUSAL_CODES];
159
+ /** Whether a code is one the framework stamps on its own refusals. */
160
+ export declare const isLambderRefusalCode: (code: unknown) => code is LambderRefusalCode;
133
161
  export type LambderRefuseOptions = {
134
- /** Rendering intent for the client's errorMessageHandler. Default: "warning". */
162
+ /** Rendering intent for the client's refusalHandler. Default: "warning". */
135
163
  type?: LambderRefusalMessage["type"];
136
- /** Machine-readable identity of the refusal, for clients to branch and translate on. */
164
+ /**
165
+ * Machine-readable identity of the refusal, for clients to branch and
166
+ * translate on: a code the endpoint declares in its `refusals` option (or
167
+ * through one of its guards). A code it does not declare is a crash, not
168
+ * a refusal; see LambderApiRefusalValidationError.
169
+ */
137
170
  code?: string;
171
+ /**
172
+ * The code's data, when the code declares data: parsed through the
173
+ * declared schema before it is sent, as an output is. A refusal without
174
+ * a declared code carries none.
175
+ */
176
+ data?: unknown;
138
177
  /** Optional heading shown above the content. */
139
178
  title?: string;
140
- /** Sets the envelope's notAuthorized flag (routed to the caller's notAuthorizedHandler). */
179
+ /** Sets the envelope's notAuthorized flag (routed to the caller's notAuthorizedHandler). A declared code's flag is its declaration's, and a refusal with such a code that sets it here is a crash. */
141
180
  notAuthorized?: boolean;
142
- /** Sets the envelope's sessionExpired flag. */
181
+ /** Sets the envelope's sessionExpired flag. Not for a refusal with a declared code, whose flags are its declaration's. */
143
182
  sessionExpired?: boolean;
144
- /** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). */
183
+ /** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). A declared code's status is its declaration's, and a refusal with such a code that sets one here is a crash. */
145
184
  statusCode?: LambderHttpStatusCode;
146
185
  /** Extra response headers on the refusal (e.g. Retry-After). */
147
186
  headers?: Record<string, string>;
@@ -159,5 +198,51 @@ export type LambderRefuseOptions = {
159
198
  * The const carries the annotation so TypeScript applies never-return
160
199
  * control-flow narrowing at call sites (`if (!row) refuse(...)` implies
161
200
  * `row` is defined afterwards).
201
+ *
202
+ * Its `code` and `data` are checked when the refusal is rendered, against the
203
+ * endpoint it answers. Inside an API handler, `ctx.refuse` takes the same
204
+ * arguments typed to that endpoint's declared codes (LambderDeclaredRefuse).
162
205
  */
163
206
  export declare const refuse: (content: string, options?: LambderRefuseOptions) => never;
207
+ /** refuse()'s options other than the refusal's identity. */
208
+ type LambderRefuseFlagOptions = Omit<LambderRefuseOptions, "code" | "data">;
209
+ /** What a raise site of a declared code still chooses: the code's declaration owns its status and its flags. */
210
+ type LambderDeclaredCodeOptions = Omit<LambderRefuseFlagOptions, "statusCode" | "notAuthorized" | "sessionExpired">;
211
+ /**
212
+ * refuse()'s options where the endpoint's declarations are known: `code` is
213
+ * one of TRefusals' codes, `data` is required when that code declares data
214
+ * and absent otherwise, and the status and flags are the code's
215
+ * declaration's, so they cannot be set here. TRefusals maps each code to
216
+ * `{ data: D }` or `{}`, D being what the handler hands over (the schema's
217
+ * input form on the server, the wire form in the mock). A discriminated
218
+ * union rather than a generic over the code, so a code widened to a union
219
+ * cannot loosen `data`. With TCodeRequired (declareRefusals's requireCodes)
220
+ * the uncoded arm is gone: every refusal names a code.
221
+ */
222
+ export type LambderDeclaredRefuseOptions<TRefusals, TCodeRequired extends boolean = false> = (TCodeRequired extends true ? never : LambderRefuseFlagOptions & {
223
+ code?: undefined;
224
+ data?: undefined;
225
+ }) | {
226
+ [TCode in keyof TRefusals & string]: LambderDeclaredCodeOptions & {
227
+ code: TCode;
228
+ } & (TRefusals[TCode] extends {
229
+ data: infer TData;
230
+ } ? {
231
+ data: TData;
232
+ } : {
233
+ data?: undefined;
234
+ });
235
+ }[keyof TRefusals & string];
236
+ /**
237
+ * The refuse an API handler's context carries, typed to the endpoint's
238
+ * declared codes (a guard's to the guard's, the init's to the whole
239
+ * vocabulary). It throws what refuse() throws. Where the app requires codes,
240
+ * the options are not optional: a refusal is a code.
241
+ *
242
+ * TypeScript narrows after a never-returning call only when every name in it
243
+ * carries an explicit annotation, which a handler's contextually typed `ctx`
244
+ * does not. Where the code after it relies on the refusal, write
245
+ * `return ctx.refuse(...)`; the free refuse() narrows on its own.
246
+ */
247
+ export type LambderDeclaredRefuse<TRefusals, TCodeRequired extends boolean = false> = TCodeRequired extends true ? (content: string, options: LambderDeclaredRefuseOptions<TRefusals, true>) => never : (content: string, options?: LambderDeclaredRefuseOptions<TRefusals, false>) => never;
248
+ export {};
@@ -2,7 +2,7 @@
2
2
  * A typed refusal: "this request is denied/invalid" as opposed to "the server
3
3
  * crashed". Throw it from anywhere in an API call's call stack (handlers,
4
4
  * hooks, guards or nested helpers) and the render pipeline maps it onto the
5
- * structured API envelope (its errorMessage, notAuthorized and
5
+ * structured API envelope (its refusal, notAuthorized and
6
6
  * sessionExpired) instead of routing it through setGlobalErrorHandler, so refusals never reach crash
7
7
  * logging and clients receive a parseable response.
8
8
  *
@@ -20,8 +20,8 @@ export class LambderApiRefusal extends Error {
20
20
  * across them while this marker does not. The pipeline checks the brand.
21
21
  */
22
22
  isLambderApiRefusal = true;
23
- /** What the envelope's errorMessage carries: always a message object, a plain string given to the options made into one. */
24
- errorMessage;
23
+ /** What the envelope's refusal carries: always a message object, a plain string given to the options made into one. */
24
+ refusal;
25
25
  notAuthorized;
26
26
  sessionExpired;
27
27
  statusCode;
@@ -29,7 +29,7 @@ export class LambderApiRefusal extends Error {
29
29
  constructor(message, options = {}) {
30
30
  super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
31
31
  this.name = "LambderApiRefusal";
32
- this.errorMessage = refusalMessageOf(options.errorMessage ?? message);
32
+ this.refusal = refusalMessageOf(options.refusal ?? message);
33
33
  this.notAuthorized = options.notAuthorized;
34
34
  this.sessionExpired = options.sessionExpired;
35
35
  this.statusCode = options.statusCode;
@@ -40,7 +40,7 @@ export class LambderApiRefusal extends Error {
40
40
  export const isLambderApiRefusal = (err) => err instanceof Error && err.isLambderApiRefusal === true;
41
41
  const REFUSAL_MESSAGE_TYPES = ["warning", "error", "info"];
42
42
  /**
43
- * An errorMessage as the one shape a reader handles: a plain string becomes
43
+ * An refusal as the one shape a reader handles: a plain string becomes
44
44
  * an "error" message with that content, and a value that is not a message
45
45
  * at all becomes one describing it. The envelope writer applies it, so a
46
46
  * Lambder server only ever sends the object. A reader applies it too,
@@ -71,7 +71,7 @@ export const refusalMessageOf = (message) => {
71
71
  * than retyping the strings.
72
72
  */
73
73
  export const LAMBDER_REFUSAL_CODES = {
74
- /** A rate-limit policy refused (429). A policy's own errorMessage inherits this unless it sets a code. */
74
+ /** A rate-limit policy refused (429). A policy's own message carries it. */
75
75
  rateLimited: "lambder/rate-limited",
76
76
  /** The original of an idempotent request is still processing (409). */
77
77
  duplicateInFlight: "lambder/duplicate-in-flight",
@@ -92,6 +92,9 @@ export const LAMBDER_REFUSAL_CODES = {
92
92
  /** An upload bucket would not sign a ticket for a file larger than the rule accepts. */
93
93
  uploadTooLarge: "lambder/upload-too-large",
94
94
  };
95
+ const FRAMEWORK_REFUSAL_CODES = new Set(Object.values(LAMBDER_REFUSAL_CODES));
96
+ /** Whether a code is one the framework stamps on its own refusals. */
97
+ export const isLambderRefusalCode = (code) => typeof code === "string" && FRAMEWORK_REFUSAL_CODES.has(code);
95
98
  /**
96
99
  * Refuse the current API call: a routine business "no" (not found, invalid
97
100
  * input, not allowed) with a user-facing message. Throws a LambderApiRefusal
@@ -103,14 +106,19 @@ export const LAMBDER_REFUSAL_CODES = {
103
106
  * The const carries the annotation so TypeScript applies never-return
104
107
  * control-flow narrowing at call sites (`if (!row) refuse(...)` implies
105
108
  * `row` is defined afterwards).
109
+ *
110
+ * Its `code` and `data` are checked when the refusal is rendered, against the
111
+ * endpoint it answers. Inside an API handler, `ctx.refuse` takes the same
112
+ * arguments typed to that endpoint's declared codes (LambderDeclaredRefuse).
106
113
  */
107
114
  export const refuse = (content, options = {}) => {
108
115
  throw new LambderApiRefusal(content, {
109
- errorMessage: {
116
+ refusal: {
110
117
  type: options.type ?? "warning",
111
118
  ...(options.code !== undefined ? { code: options.code } : {}),
112
119
  ...(options.title !== undefined ? { title: options.title } : {}),
113
120
  content,
121
+ ...(options.data !== undefined ? { data: options.data } : {}),
114
122
  },
115
123
  notAuthorized: options.notAuthorized,
116
124
  sessionExpired: options.sessionExpired,
@@ -6,7 +6,7 @@
6
6
  * one, a developer holding a debug cookie) can get the error's name, message,
7
7
  * stack, cause chain and where it happened, to store in its own error log
8
8
  * and find the right CloudWatch stream. The envelope carries it in `crash`
9
- * beside errorMessage; LambderInvokeCaller rebuilds an Error from it as the
9
+ * beside refusal; LambderInvokeCaller rebuilds an Error from it as the
10
10
  * `cause` of the error it throws, so a reporter that walks causes sees the
11
11
  * callee's stack unaided.
12
12
  *
@@ -6,7 +6,7 @@
6
6
  * one, a developer holding a debug cookie) can get the error's name, message,
7
7
  * stack, cause chain and where it happened, to store in its own error log
8
8
  * and find the right CloudWatch stream. The envelope carries it in `crash`
9
- * beside errorMessage; LambderInvokeCaller rebuilds an Error from it as the
9
+ * beside refusal; LambderInvokeCaller rebuilds an Error from it as the
10
10
  * `cause` of the error it throws, so a reporter that walks causes sees the
11
11
  * callee's stack unaided.
12
12
  *
@@ -9,3 +9,9 @@
9
9
  * to typecheck a status union.
10
10
  */
11
11
  export type LambderHttpStatusCode = 100 | 101 | 200 | 201 | 202 | 203 | 204 | 206 | 300 | 301 | 302 | 303 | 304 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 408 | 409 | 410 | 412 | 413 | 415 | 416 | 418 | 422 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504;
12
+ /**
13
+ * The status a refusal with a declared code may leave with: not a 5xx, which a
14
+ * reader files as a server failure, not 422, which is input validation's, and
15
+ * not a 1xx.
16
+ */
17
+ export type LambderRefusalStatusCode = Exclude<LambderHttpStatusCode, 100 | 101 | 422 | 500 | 501 | 502 | 503 | 504>;
@@ -7,7 +7,8 @@ export type LambderIdempotentAttemptOutcome = {
7
7
  ok: boolean;
8
8
  reason?: string;
9
9
  status?: number;
10
- errorMessage?: {
10
+ /** The refusal's message, when the answer carried one: only its code is read. */
11
+ refusal?: {
11
12
  code?: string;
12
13
  };
13
14
  };
@@ -43,7 +44,7 @@ export declare const createIdempotencyKey: () => string;
43
44
  * - A success settles it, and so does a key refused as reused for another
44
45
  * request, since that key can never carry this one.
45
46
  * - A refusal of this request (a rejected input, not authorized, an
46
- * errorMessage) settles it, unless another attempt under the same key is
47
+ * refusal) settles it, unless another attempt under the same key is
47
48
  * still in flight or went unanswered. That attempt may run or have run the
48
49
  * operation, and guards, validation and rate limits refuse before the
49
50
  * replay record is claimed, so the refusal of a retry or a double-tap says
@@ -5,7 +5,7 @@ export const IDEMPOTENT_ATTEMPT_NOT_SENT = { ok: false, reason: "notSent" };
5
5
  * Refusals that answer this request itself, so the same request would get
6
6
  * the same answer again and what the person sends next is a new operation.
7
7
  */
8
- const REFUSAL_REASONS = new Set(["validation", "notAuthorized", "errorMessage"]);
8
+ const REFUSAL_REASONS = new Set(["validation", "notAuthorized", "refusal"]);
9
9
  /** Answers that never reached the operation: the same request may pass later. */
10
10
  const UNTRIED_REASONS = new Set(["sessionExpired", "versionExpired", "payloadTooLarge", "notSent"]);
11
11
  /**
@@ -45,7 +45,7 @@ let beginAttemptOf;
45
45
  * - A success settles it, and so does a key refused as reused for another
46
46
  * request, since that key can never carry this one.
47
47
  * - A refusal of this request (a rejected input, not authorized, an
48
- * errorMessage) settles it, unless another attempt under the same key is
48
+ * refusal) settles it, unless another attempt under the same key is
49
49
  * still in flight or went unanswered. That attempt may run or have run the
50
50
  * operation, and guards, validation and rate limits refuse before the
51
51
  * replay record is claimed, so the refusal of a retry or a double-tap says
@@ -98,13 +98,13 @@ export class LambderIdempotencyKeyScope {
98
98
  if (key !== this.#key)
99
99
  return;
100
100
  this.#inFlight -= 1;
101
- const code = outcome.errorMessage?.code;
101
+ const code = outcome.refusal?.code;
102
102
  if (outcome.ok || code === LAMBDER_REFUSAL_CODES.idempotencyKeyReused) {
103
103
  this.rotate();
104
104
  return;
105
105
  }
106
- // A rate limit refuses before the claim, whatever code a policy's
107
- // own message carries, so its status is what names it.
106
+ // A rate limit refuses before the claim, and its status names it
107
+ // as surely as its code.
108
108
  if (UNTRIED_REASONS.has(outcome.reason ?? "") || outcome.status === 429 || code === LAMBDER_REFUSAL_CODES.rateLimited)
109
109
  return;
110
110
  if (REFUSAL_REASONS.has(outcome.reason ?? "") && code !== LAMBDER_REFUSAL_CODES.duplicateInFlight) {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Whether a value is what an API answers with or a refusal carries as data:
3
+ * an array, or an object that goes through JSON as an object. A Date or a
4
+ * class with a toJSON of its own is written as whatever toJSON returns (a
5
+ * string, for a Date), so it does not count.
6
+ *
7
+ * The rule is what makes `caller.api()` truthy exactly on success: an
8
+ * answer is never null, false, 0 or "", so a falsy result can only mean the
9
+ * call failed. The server checks a handler's parsed output with it, and a
10
+ * reader checks what it received, so a body no Lambder handler wrote (a
11
+ * hand-built one, a proxy's) cannot pass for a success.
12
+ */
13
+ export declare const isObjectPayload: (value: unknown) => value is object;
14
+ /** What a value that is not an object payload is, for an error message: its kind, never the value itself. */
15
+ export declare const describePayloadKind: (value: unknown) => string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Whether a value is what an API answers with or a refusal carries as data:
3
+ * an array, or an object that goes through JSON as an object. A Date or a
4
+ * class with a toJSON of its own is written as whatever toJSON returns (a
5
+ * string, for a Date), so it does not count.
6
+ *
7
+ * The rule is what makes `caller.api()` truthy exactly on success: an
8
+ * answer is never null, false, 0 or "", so a falsy result can only mean the
9
+ * call failed. The server checks a handler's parsed output with it, and a
10
+ * reader checks what it received, so a body no Lambder handler wrote (a
11
+ * hand-built one, a proxy's) cannot pass for a success.
12
+ */
13
+ export const isObjectPayload = (value) => Array.isArray(value)
14
+ || (value !== null && typeof value === "object" && typeof value.toJSON !== "function");
15
+ /** What a value that is not an object payload is, for an error message: its kind, never the value itself. */
16
+ export const describePayloadKind = (value) => {
17
+ if (value === null)
18
+ return "null";
19
+ if (value === undefined)
20
+ return "nothing";
21
+ if (typeof value === "object")
22
+ return "an object that JSON writes as something else (a Date, or a class with a toJSON of its own)";
23
+ return `a ${typeof value}`;
24
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Two assertions over a call's outcome, for tests.
2
+ * Three assertions over a call's outcome, for tests.
3
3
  *
4
4
  * An outcome is a discriminated union, so a test that expects a refusal must
5
5
  * narrow before it can read what the refusal carries: check `ok`, branch on
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * These narrow through an `asserts` signature, so the lines after one read
12
12
  * the arm it proved, and they throw a plain Error naming what the outcome
13
- * was. No test runner is imported: the same two functions serve vitest, jest
13
+ * was. No test runner is imported: the same functions serve vitest, jest
14
14
  * and node:test, from `lambder/testing` over a real server and from
15
15
  * `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
16
16
  * vocabulary they read.
@@ -44,10 +44,34 @@ type LambderFailureWithReason<TOutcome, TReason> = TOutcome extends {
44
44
  } ? [TReason & TArmReason] extends [never] ? never : TOutcome & {
45
45
  reason: TReason & TArmReason;
46
46
  } : never;
47
+ /** The refusal message a failure of this outcome union carries: the endpoint's, with its declared codes. */
48
+ type LambderRefusalMessageOf<TOutcome> = TOutcome extends {
49
+ ok: false;
50
+ refusal?: infer TMessage;
51
+ } ? TMessage : never;
52
+ /** Every code a failure of this outcome union can be refused with: the endpoint's declared codes and the framework's. */
53
+ type LambderRefusalCodeOf<TOutcome> = LambderRefusalMessageOf<TOutcome> extends infer TMessage ? TMessage extends {
54
+ code?: infer TCode;
55
+ } ? Exclude<TCode, undefined> & string : never : never;
56
+ /**
57
+ * The arm of a refusal message that carries code C: the declared code's own
58
+ * arm, with its data. A framework code has no arm of its own (it shares the
59
+ * uncoded one), and neither does any code of a message typed as any code, so
60
+ * those are the message with the code pinned.
61
+ */
62
+ type LambderRefusalWithCode<TMessage, TCode> = [
63
+ Extract<TMessage, {
64
+ code: TCode;
65
+ }>
66
+ ] extends [never] ? TMessage & {
67
+ code: TCode;
68
+ } : Extract<TMessage, {
69
+ code: TCode;
70
+ }>;
47
71
  /** What else a failure is expected to carry, beside its reason. */
48
- export type LambderExpectedFailure = {
49
- /** The refusal's machine-readable code (`errorMessage.code`), e.g. a LAMBDER_REFUSAL_CODES value or the app's own. */
50
- code?: string;
72
+ export type LambderExpectedFailure<TCode extends string = string> = {
73
+ /** The refusal's machine-readable code (`refusal.code`): one the endpoint declares, or a LAMBDER_REFUSAL_CODES value. */
74
+ code?: TCode;
51
75
  /** The HTTP status the answer came with. */
52
76
  status?: number;
53
77
  };
@@ -72,8 +96,29 @@ export declare function assertApiSuccess<TOutcome extends LambderOutcomeShape>(o
72
96
  *
73
97
  * ```typescript
74
98
  * assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
75
- * assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
99
+ * assertApiFailure(await guest.apiOutcome("signup", form), "refusal", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
100
+ * ```
101
+ */
102
+ export declare function assertApiFailure<TOutcome extends LambderOutcomeShape, TReason extends LambderFailureReasonOf<TOutcome> = LambderFailureReasonOf<TOutcome>>(outcome: TOutcome, reason?: TReason, expected?: LambderExpectedFailure<LambderRefusalCodeOf<TOutcome>>): asserts outcome is LambderFailureWithReason<TOutcome, TReason>;
103
+ /**
104
+ * Asserts that a call was refused with the given code, whichever reason it
105
+ * arrived under (a refusal flagged notAuthorized carries its code too), and
106
+ * narrows the outcome's refusal to that code's message, so its `data`
107
+ * reads with the type the code declares.
108
+ *
109
+ * ```typescript
110
+ * const outcome = await visitor.apiOutcome("order.pay", { orderId });
111
+ * assertApiRefusal(outcome, "wallet-short");
112
+ * expect(outcome.refusal.data.available).toBe(1250);
76
113
  * ```
114
+ *
115
+ * The code is checked against the outcome's own codes, so one the endpoint
116
+ * does not declare is a compile error rather than an assertion that can
117
+ * never pass.
77
118
  */
78
- export declare function assertApiFailure<TOutcome extends LambderOutcomeShape, TReason extends LambderFailureReasonOf<TOutcome> = LambderFailureReasonOf<TOutcome>>(outcome: TOutcome, reason?: TReason, expected?: LambderExpectedFailure): asserts outcome is LambderFailureWithReason<TOutcome, TReason>;
119
+ export declare function assertApiRefusal<TOutcome extends LambderOutcomeShape, const TCode extends LambderRefusalCodeOf<TOutcome>>(outcome: TOutcome, code: TCode): asserts outcome is Extract<TOutcome, {
120
+ ok: false;
121
+ }> & {
122
+ refusal: LambderRefusalWithCode<LambderRefusalMessageOf<TOutcome>, TCode>;
123
+ };
79
124
  export {};
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Two assertions over a call's outcome, for tests.
2
+ * Three assertions over a call's outcome, for tests.
3
3
  *
4
4
  * An outcome is a discriminated union, so a test that expects a refusal must
5
5
  * narrow before it can read what the refusal carries: check `ok`, branch on
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * These narrow through an `asserts` signature, so the lines after one read
12
12
  * the arm it proved, and they throw a plain Error naming what the outcome
13
- * was. No test runner is imported: the same two functions serve vitest, jest
13
+ * was. No test runner is imported: the same functions serve vitest, jest
14
14
  * and node:test, from `lambder/testing` over a real server and from
15
15
  * `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
16
16
  * vocabulary they read.
@@ -49,8 +49,8 @@ const describeOutcome = (outcome) => {
49
49
  const details = [];
50
50
  if (failure.status !== undefined)
51
51
  details.push(`status ${failure.status}`);
52
- if (failure.errorMessage !== undefined)
53
- details.push(`errorMessage ${describeValue(failure.errorMessage)}`);
52
+ if (failure.refusal !== undefined)
53
+ details.push(`refusal ${describeValue(failure.refusal)}`);
54
54
  if (failure.zodError !== undefined)
55
55
  details.push(`zodError ${describeValue(failure.zodError.message)}`);
56
56
  // The error's own message, and its cause when it has one: an in-process
@@ -84,7 +84,7 @@ export function assertApiSuccess(outcome) {
84
84
  *
85
85
  * ```typescript
86
86
  * assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
87
- * assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
87
+ * assertApiFailure(await guest.apiOutcome("signup", form), "refusal", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
88
88
  * ```
89
89
  */
90
90
  export function assertApiFailure(outcome, reason, expected = {}) {
@@ -101,12 +101,38 @@ export function assertApiFailure(outcome, reason, expected = {}) {
101
101
  if (reason !== undefined && outcome.reason !== reason)
102
102
  return refuse();
103
103
  if (expected.code !== undefined) {
104
- // Only the structured errorMessage carries a code; a plain string has none to match.
105
- const errorMessage = outcome.errorMessage;
106
- const code = errorMessage && typeof errorMessage === "object" ? errorMessage.code : undefined;
104
+ // Only the structured refusal carries a code; a plain string has none to match.
105
+ const refusal = outcome.refusal;
106
+ const code = refusal && typeof refusal === "object" ? refusal.code : undefined;
107
107
  if (code !== expected.code)
108
108
  return refuse();
109
109
  }
110
110
  if (expected.status !== undefined && outcome.status !== expected.status)
111
111
  return refuse();
112
112
  }
113
+ /**
114
+ * Asserts that a call was refused with the given code, whichever reason it
115
+ * arrived under (a refusal flagged notAuthorized carries its code too), and
116
+ * narrows the outcome's refusal to that code's message, so its `data`
117
+ * reads with the type the code declares.
118
+ *
119
+ * ```typescript
120
+ * const outcome = await visitor.apiOutcome("order.pay", { orderId });
121
+ * assertApiRefusal(outcome, "wallet-short");
122
+ * expect(outcome.refusal.data.available).toBe(1250);
123
+ * ```
124
+ *
125
+ * The code is checked against the outcome's own codes, so one the endpoint
126
+ * does not declare is a compile error rather than an assertion that can
127
+ * never pass.
128
+ */
129
+ // `const`: the constraint depends on TOutcome, and without it the code
130
+ // argument widens to that whole constraint instead of staying the literal
131
+ // passed, which would narrow to every declared code at once.
132
+ export function assertApiRefusal(outcome, code) {
133
+ const refusal = outcome.ok ? undefined : outcome.refusal;
134
+ const received = refusal && typeof refusal === "object" ? refusal.code : undefined;
135
+ if (received !== code) {
136
+ throw new Error(`Expected the call to be refused with code "${code}", but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
137
+ }
138
+ }
@@ -51,7 +51,7 @@ export type LambderTestAppOptions = {
51
51
  * (`lambderTestApp<SessionData, ApiContractType>(lambder)`), so the tests type
52
52
  * their calls against plain members rather than the chained intersection.
53
53
  */
54
- export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any> & {
54
+ export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionData, any, any, any, any, any, any, any, any, any> & {
55
55
  readonly ApiContract: TContract;
56
56
  };
57
57
  /**
package/dist/testing.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  export { lambderTestApp } from "./testing/LambderTestApp.js";
18
18
  export type { LambderTestApp, LambderTestAppOptions, LambderTestedInstance } from "./testing/LambderTestApp.js";
19
19
  export type { LambderTestVisitor, LambderTestVisitorOptions, LambderTestRequestInit } from "./testing/LambderTestVisitor.js";
20
- export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
20
+ export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
21
21
  export type { LambderExpectedFailure } from "./shared/wire/LambderOutcomeAssertions.js";
22
22
  export { lambderSessionStoreConformance } from "./testing/lambderSessionStoreConformance.js";
23
23
  export type { LambderSessionStoreConformanceOptions } from "./testing/lambderSessionStoreConformance.js";
package/dist/testing.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * writes itself.
16
16
  */
17
17
  export { lambderTestApp } from "./testing/LambderTestApp.js";
18
- export { assertApiSuccess, assertApiFailure } from "./shared/wire/LambderOutcomeAssertions.js";
18
+ export { assertApiSuccess, assertApiFailure, assertApiRefusal } from "./shared/wire/LambderOutcomeAssertions.js";
19
19
  // The rules each store interface promises, as cases an app registers with its
20
20
  // own runner's `it` and `expect` to hold a store it writes over its own
21
21
  // database to the same rules Lambder's memory and DynamoDB stores meet.