lambder 9.0.2 → 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 (84) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/README.md +29 -14
  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/LambderUploadRunner.d.ts +5 -5
  23. package/dist/client/LambderUploadRunner.js +25 -16
  24. package/dist/client.d.ts +6 -5
  25. package/dist/client.js +2 -1
  26. package/dist/core/Lambder.d.ts +121 -23
  27. package/dist/core/Lambder.js +105 -51
  28. package/dist/core/LambderContext.d.ts +1 -1
  29. package/dist/core/LambderContext.js +5 -0
  30. package/dist/core/LambderCreateOptions.d.ts +58 -15
  31. package/dist/core/LambderCreateOptions.js +10 -0
  32. package/dist/core/LambderPolicyBuilders.d.ts +11 -8
  33. package/dist/core/LambderPolicyBuilders.js +10 -8
  34. package/dist/core/LambderResolver.d.ts +1 -1
  35. package/dist/core/LambderResolver.js +1 -1
  36. package/dist/core/LambderResponseBuilder.d.ts +11 -9
  37. package/dist/core/LambderResponseBuilder.js +12 -11
  38. package/dist/index.d.ts +13 -9
  39. package/dist/index.js +6 -2
  40. package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
  41. package/dist/invoke/LambderInvokeCaller.js +13 -12
  42. package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
  43. package/dist/invoke/LambderInvokeOutcome.js +4 -4
  44. package/dist/mock/LambderMockApp.d.ts +86 -24
  45. package/dist/mock/LambderMockApp.js +108 -16
  46. package/dist/mock/LambderMockCallRecorder.js +6 -3
  47. package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
  48. package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
  49. package/dist/mock/LambderMockFailureInjector.js +18 -4
  50. package/dist/mock/LambderMockTypes.d.ts +34 -11
  51. package/dist/mock.d.ts +4 -3
  52. package/dist/mock.js +4 -3
  53. package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
  54. package/dist/shared/contracts/LambderUploadBucket.js +2 -2
  55. package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
  56. package/dist/shared/wire/LambderApiContract.d.ts +96 -31
  57. package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
  58. package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
  59. package/dist/shared/wire/LambderApiOptionValues.js +3 -3
  60. package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
  61. package/dist/shared/wire/LambderApiOutcome.js +28 -11
  62. package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
  63. package/dist/shared/wire/LambderApiRefusal.js +15 -7
  64. package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
  65. package/dist/shared/wire/LambderCrashDetail.js +1 -1
  66. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
  67. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
  68. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
  69. package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
  70. package/dist/shared/wire/LambderObjectPayload.js +24 -0
  71. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
  72. package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
  73. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
  74. package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
  75. package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
  76. package/dist/shared/wire/LambderUploadSchemas.js +18 -7
  77. package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
  78. package/dist/stores/LambderMemoryUploadBucket.js +60 -12
  79. package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
  80. package/dist/stores/LambderS3UploadBucket.js +51 -4
  81. package/dist/testing/LambderTestApp.d.ts +1 -1
  82. package/dist/testing.d.ts +1 -1
  83. package/dist/testing.js +1 -1
  84. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  * Contracts are built via method chaining and inferred using typeof lambder.ApiContract
5
5
  */
6
6
  import type { LambderCrashDetail } from "./LambderCrashDetail.js";
7
- import type { LambderAppRefusalMessage } from "./LambderApiRefusal.js";
7
+ import type { LambderUncheckedRefusalMessage, LambderPlainRefusalMessage, LambderRefusalMessage } from "./LambderApiRefusal.js";
8
8
  import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
9
9
  /** Whether an endpoint runs without a session or requires one (addApi versus addSessionApi). */
10
10
  export type LambderApiMode = "public" | "session";
@@ -30,26 +30,27 @@ export type LambderApiContractShape = Record<string, {
30
30
  rateLimit?: LambderRateLimitOptionValue;
31
31
  /** Present when the API declares idempotency: the `idempotency` option exactly as written. */
32
32
  idempotency?: LambderApiIdempotencyOption;
33
+ /** Present when the API can refuse with a declared code: each code mapped to `{ data }` or `{}`. See LambderContractRefusalsOf. */
34
+ refusals?: Record<string, {
35
+ data?: unknown;
36
+ }>;
33
37
  }>;
34
38
  /**
35
- * Envelope flags and channels beside (or instead of) the payload: what a
36
- * refusal, the pipeline's own answers, a hook or an error handler set. An API
37
- * handler sets none of them itself: it returns its output or refuses.
39
+ * What a refusal envelope may carry besides its null payload: the flags a
40
+ * caller routes on, the refusal's message, the call's logList, and a crash
41
+ * described for a caller allowed to see it. The one shape the envelope
42
+ * writer takes for every answer that is not a handler's output.
38
43
  */
39
- export type LambderApiResponseConfig = {
44
+ export type LambderRefusalEnvelopeFields = {
40
45
  versionExpired?: boolean;
41
46
  sessionExpired?: boolean;
42
47
  notAuthorized?: boolean;
43
48
  /**
44
- * A refusal message, or a plain string when writing (a refusal thrown
45
- * with refuse(), or an error handler's `res.api(null, { errorMessage })`): the envelope goes out with
46
- * the message object either way. Read off the wire it can still be a
47
- * string, or no message at all, wherever a Lambder server did not write
48
- * the body (a hand-built mock answer, a proxy); refusalMessageOf reads
49
- * whatever arrives as a message.
49
+ * The refusal's message, or a plain string: the envelope goes out with
50
+ * the message object either way (refusalMessageOf).
50
51
  */
51
- errorMessage?: LambderAppRefusalMessage | string;
52
- logList?: any[];
52
+ refusal?: LambderUncheckedRefusalMessage | string;
53
+ logList?: unknown[];
53
54
  /**
54
55
  * A crash described in full (name, message, stack, cause chain, where it
55
56
  * happened), for a caller that is allowed to see it: a global error
@@ -60,16 +61,54 @@ export type LambderApiResponseConfig = {
60
61
  crash?: LambderCrashDetail;
61
62
  };
62
63
  /**
63
- * The API wire envelope both sides speak: the server writes it around a
64
- * handler's output or a refusal, LambderCaller parses it. `apiVersion` is always there (null when the server set none):
65
- * it is how a reader tells a Lambder envelope from another JSON answer, such
66
- * as API Gateway's own `{ "message": ... }` errors, so an answer without it
67
- * reads as a server failure.
64
+ * What `res.apiRefusal` writes: an answer to an API call from outside its
65
+ * handler (a hook, a fallback, the input validation handler, the global
66
+ * error handler). It is always a refusal, never a success: it carries an
67
+ * refusal or one of the three flags, which is what a reader tells it
68
+ * from a handler's output by, so a caller's success is only ever the
69
+ * handler's parsed output.
70
+ *
71
+ * Its message carries a framework code or none, and never data: it answers
72
+ * outside any one endpoint's declared refusals, which are what a reader's
73
+ * types allow.
68
74
  */
69
- export type LambderApiEnvelopeBody<T> = LambderApiResponseConfig & {
75
+ export type LambderApiRefusalConfig = Omit<LambderRefusalEnvelopeFields, "refusal"> & {
76
+ refusal?: LambderPlainRefusalMessage | string;
77
+ } & ({
78
+ refusal: LambderPlainRefusalMessage | string;
79
+ } | {
80
+ versionExpired: true;
81
+ } | {
82
+ sessionExpired: true;
83
+ } | {
84
+ notAuthorized: true;
85
+ });
86
+ /** What every API envelope carries. `apiVersion` is always there (null when the server set none): it is how a reader tells a Lambder envelope from another JSON answer, such as API Gateway's own `{ "message": ... }` errors. */
87
+ type LambderApiEnvelopeFields = {
70
88
  apiVersion: string | null;
71
- payload?: T | null;
89
+ logList?: unknown[];
90
+ };
91
+ /** A handler's answer on the wire: its output, parsed through the API's output schema, and nothing else. */
92
+ export type LambderApiSuccessEnvelope<T> = LambderApiEnvelopeFields & {
93
+ payload: T;
72
94
  };
95
+ /**
96
+ * Every other answer on the wire: a null payload beside the refusal's
97
+ * message and flags. The flags are present only when set. `refusal` is
98
+ * as it came off the wire, which a reader normalizes (refusalMessageOf),
99
+ * since a body no Lambder server wrote (a hand-built mock answer, a proxy)
100
+ * can put anything there.
101
+ */
102
+ export type LambderApiRefusalEnvelope = LambderApiEnvelopeFields & {
103
+ payload: null;
104
+ versionExpired?: true;
105
+ sessionExpired?: true;
106
+ notAuthorized?: true;
107
+ refusal?: LambderUncheckedRefusalMessage | string;
108
+ crash?: LambderCrashDetail;
109
+ };
110
+ /** The API wire envelope both sides speak: the server writes it, LambderCaller and LambderInvokeCaller read it. */
111
+ export type LambderApiEnvelopeBody<T> = LambderApiSuccessEnvelope<T> | LambderApiRefusalEnvelope;
73
112
  /** A value that is already JSON, recursive structures such as z.json() included. */
74
113
  type LambderJsonValue = string | number | boolean | null | LambderJsonValue[] | {
75
114
  [key: string]: LambderJsonValue;
@@ -109,15 +148,6 @@ export type LambderJsonOf<T> = unknown extends T ? T : T extends LambderJsonValu
109
148
  } : T extends ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> ? {} : T extends object ? (Partial<Pick<T, LambderJsonOmissibleKeys<T>>> & Omit<T, LambderJsonOmissibleKeys<T>>) extends infer TKeyed ? {
110
149
  [K in keyof TKeyed as K extends string | number ? (string extends K ? K : number extends K ? K : [LambderJsonOf<TKeyed[K]>] extends [never] ? never : K) : never]: LambderJsonOf<TKeyed[K]>;
111
150
  } : never : never;
112
- /**
113
- * What an API's output reaches the client as: LambderJsonOf of the schema's
114
- * output, except at the top, where an envelope carries no payload at all
115
- * rather than a JSON `undefined`. A void or undefined output keeps its type,
116
- * so a handler and a mock handler answer nothing, and an output that may be
117
- * undefined keeps that member, which LambderJsonOf drops as it would a
118
- * member of an object.
119
- */
120
- export type LambderJsonOutputOf<T> = [T] extends [void] ? T : (undefined extends T ? undefined : never) | LambderJsonOf<T>;
121
151
  /**
122
152
  * One contract entry as addApi/addSessionApi record it: the payload types,
123
153
  * the mode, and every declarative option exactly as written. Options that
@@ -129,12 +159,19 @@ export type LambderJsonOutputOf<T> = [T] extends [void] ? T : (undefined extends
129
159
  * what it receives (the output schema's z.output as JSON, see
130
160
  * LambderJsonOf). The handler's own types are the other side of each, and
131
161
  * are not recorded here.
162
+ *
163
+ * `Refusals` is the one member not recorded as written: it is every code the
164
+ * endpoint can refuse with, its own `refusals` option and those of the guards
165
+ * it declares together, each mapped to `{ data }` (the data as JSON) or `{}`.
166
+ * A reader needs the whole set, and the guards' part is not in the entry.
132
167
  */
133
- export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInputs = never, Guards = never, RateLimit = never, Idempotency = never> = {
168
+ export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInputs = never, Guards = never, RateLimit = never, Idempotency = never, Refusals = never> = {
134
169
  input: In;
135
170
  output: Out;
136
171
  mode: Mode;
137
- } & ([GuardInputs] extends [never] ? {} : {
172
+ } & ([Refusals] extends [never] ? {} : {
173
+ refusals: Refusals;
174
+ }) & ([GuardInputs] extends [never] ? {} : {
138
175
  guardInputs: GuardInputs;
139
176
  }) & ([Guards] extends [never] ? {} : {
140
177
  guards: Guards;
@@ -220,4 +257,32 @@ export type LambderContractRateLimitNames<C, M extends LambderApiMode = LambderA
220
257
  export type LambderContractIdempotencyOf<C, K extends keyof C> = C[K] extends {
221
258
  idempotency: infer I;
222
259
  } ? I : never;
260
+ /**
261
+ * The codes endpoint K can refuse with, each mapped to `{ data }` or `{}`;
262
+ * `{}` when it declares none.
263
+ */
264
+ export type LambderContractRefusalsOf<C, K extends keyof C> = C[K] extends {
265
+ refusals: infer R;
266
+ } ? R : {};
267
+ /**
268
+ * The refusal message a call to endpoint K can come back with: one arm per
269
+ * code it declares, plus the framework's and the uncoded refusal. An untyped
270
+ * contract (one with an index signature) is read as any code.
271
+ */
272
+ export type LambderContractRefusalMessage<C, K extends keyof C> = 0 extends 1 & C ? LambderUncheckedRefusalMessage : string extends keyof C ? LambderUncheckedRefusalMessage : LambderRefusalMessage<LambderContractRefusalsOf<C, K>>;
273
+ type LambderContractRefusalMaps<C> = {
274
+ [K in keyof C]: LambderContractRefusalsOf<C, K>;
275
+ }[keyof C];
276
+ type LambderContractRefusalCodes<C> = LambderContractRefusalMaps<C> extends infer TMap ? TMap extends unknown ? keyof TMap & string : never : never;
277
+ /**
278
+ * Every code any endpoint of the contract declares, mapped to its `{ data }`
279
+ * or `{}`. Built code by code, not by joining the endpoints' own maps, so a
280
+ * code many endpoints declare is one arm rather than one per endpoint (the
281
+ * vocabulary gives a code one data shape wherever it is used).
282
+ */
283
+ export type LambderContractRefusals<C> = {
284
+ [TCode in LambderContractRefusalCodes<C>]: Extract<LambderContractRefusalMaps<C>, Record<TCode, unknown>>[TCode];
285
+ };
286
+ /** The refusal message any call of the contract can come back with: what a handler for every endpoint at once (a caller's refusalHandler) is handed. */
287
+ export type LambderContractAnyRefusalMessage<C> = 0 extends 1 & C ? LambderUncheckedRefusalMessage : string extends keyof C ? LambderUncheckedRefusalMessage : LambderRefusalMessage<LambderContractRefusals<C>>;
223
288
  export {};
@@ -16,8 +16,7 @@
16
16
  * declared here too and re-exported by the engines that read them.
17
17
  */
18
18
  import type { LambderApiMode, LambderGuardNamesIn } from "./LambderApiContract.js";
19
- import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
20
- import type { LambderAppRefusalMessage } from "./LambderApiRefusal.js";
19
+ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitMessage, LambderRateLimitOptionValue, LambderRefusalsOptionValue } from "./LambderApiOptionValues.js";
21
20
  import type { LambderRateLimitPolicy } from "../contracts/LambderRateLimiter.js";
22
21
  /**
23
22
  * When a guard runs, relative to the API's input validation.
@@ -64,17 +63,19 @@ export type LambderRateLimitBudget = "perApi" | "perPolicy";
64
63
  */
65
64
  export type LambderRateLimitChargeAt = "beforeGuards" | "afterGuards";
66
65
  /**
67
- * One API as the generated module records it: its mode and its three
68
- * declarative options exactly as written at registration. A guard's
69
- * parameter is written as the JSON it is (a permission string, a list of
70
- * them, a reason), which is what makes the module plain data: a parameter
71
- * that is code or a class instance fails the write and names the API.
66
+ * One API as the generated module records it: its mode and its declarative
67
+ * options exactly as written at registration. A guard's parameter is written
68
+ * as the JSON it is (a permission string, a list of them, a reason), which is
69
+ * what makes the module plain data: a parameter that is code or a class
70
+ * instance fails the write and names the API. `refusals` is the API's own
71
+ * option; the codes its guards add are on their declarations.
72
72
  */
73
73
  export type LambderApiOptionEntry = {
74
74
  mode: LambderApiMode;
75
75
  guards?: LambderGuardsOptionValue;
76
76
  rateLimit?: LambderRateLimitOptionValue;
77
77
  idempotency?: LambderApiIdempotencyOption;
78
+ refusals?: LambderRefusalsOptionValue;
78
79
  };
79
80
  /**
80
81
  * One rate-limit policy as the generated module records it: its windows,
@@ -87,21 +88,27 @@ export type LambderRateLimitPolicyEntry = LambderRateLimitPolicy & {
87
88
  per?: "ip" | "session" | "custom";
88
89
  budget?: LambderRateLimitBudget;
89
90
  chargeAt?: LambderRateLimitChargeAt;
90
- errorMessage?: LambderAppRefusalMessage;
91
+ refusal?: LambderRateLimitMessage;
91
92
  };
92
93
  /**
93
94
  * One guard as the generated module records it: how it is fed (a slice of
94
95
  * the API's own payload, a value the client sends separately, or nothing),
95
- * whether it needs a session, and when it runs. The schema behind an input
96
- * mode is never written; the mode alone is what a mock guard standing in for
97
- * it has to match.
96
+ * whether it needs a session, when it runs, and the refusal codes it
97
+ * declares, which join those of every API declaring it. The schema behind an
98
+ * input mode is never written; the mode alone is what a mock guard standing
99
+ * in for it has to match.
98
100
  */
99
101
  export type LambderGuardDeclarationEntry = {
100
102
  input: "apiInput" | "guardInput" | "none";
101
103
  session: boolean;
102
104
  runAt: LambderGuardRunAt;
105
+ refusals?: readonly string[];
103
106
  };
104
- /** Everything `lambder.apiOptionEntries()` reports, and the three tables the generated module exports. */
107
+ /**
108
+ * Everything `lambder.apiOptionEntries()` reports, and the three tables the
109
+ * generated module exports. The refusal vocabulary is not among them: it is
110
+ * shared code, which the mock declares as the server does.
111
+ */
105
112
  export type LambderApiOptionEntries = {
106
113
  apis: Record<string, LambderApiOptionEntry>;
107
114
  rateLimitPolicies: Record<string, LambderRateLimitPolicyEntry>;
@@ -1,29 +1,38 @@
1
1
  /**
2
- * The runtime shapes of the three per-API policy options (guards, rate limit,
3
- * idempotency), declared below both the contract that records them and the
4
- * engines that enforce them so neither has to import the other.
2
+ * The runtime shapes of the per-API declarative options (guards, rate limit,
3
+ * idempotency, refusals), declared below both the contract that records them
4
+ * and the engines that enforce them so neither has to import the other.
5
5
  *
6
6
  * A contract type carries these options exactly as an API wrote them, and the
7
7
  * engines in `api/` read the same shapes back. Declaring them here is what
8
8
  * keeps `shared/` at the bottom of the stack: without it the contract would
9
9
  * name an `api/` type and `shared/` would depend on a layer above it.
10
10
  */
11
- import type { LambderAppRefusalMessage } from "./LambderApiRefusal.js";
11
+ import type { LambderUncheckedRefusalMessage } from "./LambderApiRefusal.js";
12
12
  import type { LambderRateLimitPolicy } from "../contracts/LambderRateLimiter.js";
13
13
  /** The guards option's runtime shape: a name, ordered names, or a name-to-param map. */
14
14
  export type LambderGuardsOptionValue = string | readonly string[] | Readonly<Record<string, unknown>>;
15
+ /**
16
+ * The message a rate-limit refusal carries, as a policy or an API writes it:
17
+ * its type, title and content. Its code is always `lambder/rate-limited` and
18
+ * it carries no data, so a rate limit is never a code an endpoint has to
19
+ * declare.
20
+ */
21
+ export type LambderRateLimitMessage = Omit<LambderUncheckedRefusalMessage, "code" | "data">;
15
22
  /**
16
23
  * What an API may override on a policy it references, in the map form of the
17
24
  * rateLimit option. Windows merge over the policy's own (a tighter burst keeps
18
25
  * the policy's daily cap) and are only overridable on "perApi" budgets: a
19
- * shared counter has one set of numbers. errorMessage is per-API text, so it
26
+ * shared counter has one set of numbers. refusal is per-API text, so it
20
27
  * is overridable on either budget.
21
28
  */
22
29
  export type LambderRateLimitOverride = LambderRateLimitPolicy & {
23
- errorMessage?: LambderAppRefusalMessage;
30
+ refusal?: LambderRateLimitMessage;
24
31
  };
25
32
  /** The rateLimit option's runtime shape: a name, ordered names, or a name-to-override map (LambderRateLimitOption narrows the names and overrides per policy). */
26
33
  export type LambderRateLimitOptionValue = string | readonly string[] | Readonly<Record<string, true | LambderRateLimitOverride | undefined>>;
34
+ /** The refusals option's runtime shape: one code of the app's vocabulary, or a non-empty list of them. */
35
+ export type LambderRefusalsOptionValue = string | readonly string[];
27
36
  /** The per-endpoint idempotency declaration: on, or on with its own replay TTL. */
28
37
  export type LambderApiIdempotencyOption = boolean | {
29
38
  /** Seconds this API's stored answer replays for; overrides defaultTtlSeconds. */
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The runtime shapes of the three per-API policy options (guards, rate limit,
3
- * idempotency), declared below both the contract that records them and the
4
- * engines that enforce them so neither has to import the other.
2
+ * The runtime shapes of the per-API declarative options (guards, rate limit,
3
+ * idempotency, refusals), declared below both the contract that records them
4
+ * and the engines that enforce them so neither has to import the other.
5
5
  *
6
6
  * A contract type carries these options exactly as an API wrote them, and the
7
7
  * engines in `api/` read the same shapes back. Declaring them here is what
@@ -10,8 +10,8 @@
10
10
  * Pure and dependency-free, so the browser entry can include it.
11
11
  */
12
12
  import type { z } from "zod";
13
- import type { LambderApiEnvelopeBody } from "./LambderApiContract.js";
14
- import { type LambderAppRefusalMessage } from "./LambderApiRefusal.js";
13
+ import type { LambderApiRefusalEnvelope, LambderApiSuccessEnvelope } from "./LambderApiContract.js";
14
+ import { type LambderUncheckedRefusalMessage } from "./LambderApiRefusal.js";
15
15
  /**
16
16
  * The 422 body's `zodError` as it survives JSON: a ZodError's name and
17
17
  * message, and its issues spelled out. Not a ZodError instance (it has no
@@ -22,22 +22,33 @@ export type LambderValidationError = {
22
22
  message: string;
23
23
  issues: z.core.$ZodIssue[];
24
24
  };
25
- export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
26
- /** A call that produced an answer the server means as a result. */
25
+ export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'refusal' | 'unknown';
26
+ /**
27
+ * The endpoint's handler answered: `payload` is its output, parsed through
28
+ * the API's output schema on the server, exactly the contract's type. Only a
29
+ * handler's answer reads as a success: everything else the server writes
30
+ * into an API call is a refusal (see LambderApiRefusalConfig), and a reader
31
+ * refuses a success whose payload is not an object (see isObjectPayload).
32
+ */
27
33
  export type LambderApiSuccessOutcome<T> = {
28
34
  ok: true;
29
- payload: T | null | undefined;
30
- response: LambderApiEnvelopeBody<T>;
35
+ payload: T;
36
+ response: LambderApiSuccessEnvelope<T>;
31
37
  /** The answer's logList, when it carried one. See LambderApiFailureFields.logList: the field is on every arm so a caller surfaces logs once. */
32
38
  logList?: unknown[];
33
39
  };
34
- /** What every failure carries, whatever went wrong. */
35
- type LambderApiFailureFields = {
40
+ /**
41
+ * What every failure carries, whatever went wrong. TMessage is the refusal
42
+ * message the endpoint can answer with (LambderContractRefusalMessage), on
43
+ * every arm: a refusal flagged notAuthorized arrives as that reason and still
44
+ * carries its message.
45
+ */
46
+ type LambderApiFailureFields<TMessage extends LambderUncheckedRefusalMessage> = {
36
47
  ok: false;
37
48
  /** HTTP status, when a response was received. */
38
49
  status?: number;
39
- /** Envelope errorMessage, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
40
- errorMessage?: LambderAppRefusalMessage;
50
+ /** The envelope's refusal, when the server provided one: always the message object, a plain string having been read as one (refusalMessageOf). */
51
+ refusal?: TMessage;
41
52
  /** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it, and so may a 503). */
42
53
  retryAfterSeconds?: number;
43
54
  /**
@@ -57,36 +68,37 @@ type LambderApiFailureFields = {
57
68
  * A 5xx also carries `response` when the server answered with Lambder's own
58
69
  * envelope, which is how a crash detail and a logList arrive with it.
59
70
  */
60
- export type LambderApiCallFailure<T> = LambderApiFailureFields & {
71
+ export type LambderApiCallFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
61
72
  reason: 'network' | 'timeout' | 'server' | 'unknown';
62
73
  error: Error;
63
- response?: LambderApiEnvelopeBody<T>;
74
+ response?: LambderApiRefusalEnvelope;
64
75
  };
65
76
  /** HTTP 422: the server rejected the input against the API's schema. Always carries the issues. */
66
- export type LambderApiValidationFailure = LambderApiFailureFields & {
77
+ export type LambderApiValidationFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
67
78
  reason: 'validation';
68
79
  zodError: LambderValidationError;
69
80
  };
70
- /** The server answered, and the envelope itself says the call is refused. Always carries that envelope, and an `errorMessage` refusal always carries its message. */
71
- export type LambderApiEnvelopeFailure<T> = LambderApiFailureFields & {
72
- response: LambderApiEnvelopeBody<T>;
81
+ /** The server answered, and the envelope itself says the call is refused. Always carries that envelope, and a `refusal` failure always carries the refusal. */
82
+ export type LambderApiEnvelopeFailure<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiFailureFields<TMessage> & {
83
+ response: LambderApiRefusalEnvelope;
73
84
  } & ({
74
85
  reason: 'versionExpired' | 'sessionExpired' | 'notAuthorized';
75
86
  } | {
76
- reason: 'errorMessage';
77
- errorMessage: LambderAppRefusalMessage;
87
+ reason: 'refusal';
88
+ refusal: TMessage;
78
89
  });
79
90
  /**
80
- * Discriminated result of an API call: `ok: true` carries the payload, every
81
- * failure carries a machine-readable reason, so "the server returned null"
82
- * and "the request failed" are never conflated.
91
+ * Discriminated result of an API call: `ok: true` carries the handler's
92
+ * output, every failure carries a machine-readable reason, so "the server
93
+ * answered" and "the request failed" are never conflated.
83
94
  *
84
95
  * The failure side is discriminated by `reason`, so narrowing to a reason
85
96
  * narrows to what it carries: `zodError` after `reason === 'validation'`,
86
97
  * `response` after an envelope reason, `error` after the rest, with no
87
- * non-null assertion needed.
98
+ * non-null assertion needed. TMessage is the endpoint's refusal message
99
+ * (LambderContractRefusalMessage), which `refusal.code` narrows.
88
100
  */
89
- export type LambderApiOutcome<T> = LambderApiSuccessOutcome<T> | LambderApiCallFailure<T> | LambderApiValidationFailure | LambderApiEnvelopeFailure<T>;
101
+ export type LambderApiOutcome<T, TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiSuccessOutcome<T> | LambderApiCallFailure<TMessage> | LambderApiValidationFailure<TMessage> | LambderApiEnvelopeFailure<TMessage>;
90
102
  /**
91
103
  * What reading one HTTP answer can produce: LambderApiOutcome minus the
92
104
  * three reasons no answer carries (`network` and `timeout` are the caller's
@@ -94,9 +106,9 @@ export type LambderApiOutcome<T> = LambderApiSuccessOutcome<T> | LambderApiCallF
94
106
  * has handled `server` and `validation` holds a success or an envelope
95
107
  * refusal, both of which carry the envelope.
96
108
  */
97
- export type LambderApiAnswerOutcome<T> = LambderApiSuccessOutcome<T> | (LambderApiCallFailure<T> & {
109
+ export type LambderApiAnswerOutcome<T, TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = LambderApiSuccessOutcome<T> | (LambderApiCallFailure<TMessage> & {
98
110
  reason: 'server';
99
- }) | LambderApiValidationFailure | LambderApiEnvelopeFailure<T>;
111
+ }) | LambderApiValidationFailure<TMessage> | LambderApiEnvelopeFailure<TMessage>;
100
112
  /**
101
113
  * What the mapping needs from an HTTP answer, whichever transport produced it.
102
114
  *
@@ -130,11 +142,21 @@ export type LambderApiHttpAnswer = {
130
142
  /**
131
143
  * Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
132
144
  * the envelope when the server sent one (Lambder's own 500 body carries
133
- * errorMessage, and a global error handler may add crash and logList); a
145
+ * refusal, and a global error handler may add crash and logList); a
134
146
  * 422 is a validation failure only with Lambder's validation body; anything
135
147
  * else must be a JSON envelope, whose flags are honoured in a fixed order.
136
148
  * A failure read off any answer but a 422 carries the answer's Retry-After
137
149
  * as retryAfterSeconds.
150
+ *
151
+ * A success is a 2xx envelope with no flag and no refusal whose payload
152
+ * is an object or an array: what only a handler's parsed output is. Anything
153
+ * else that says nothing is wrong (a null or primitive payload a hand-built
154
+ * body, a proxy or an old stored answer carries) is a server failure, so a
155
+ * success's payload is always the contract's output and never falsy.
156
+ *
157
+ * TMessage is the endpoint's refusal message type: the reader cannot check
158
+ * a code against declarations it does not have, and relies on the server,
159
+ * which never sends a code the endpoint did not declare.
138
160
  */
139
- export declare const resolveApiOutcome: <T>(answer: LambderApiHttpAnswer) => Promise<LambderApiAnswerOutcome<T>>;
161
+ export declare const resolveApiOutcome: <T, TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage>(answer: LambderApiHttpAnswer) => Promise<LambderApiAnswerOutcome<T, TMessage>>;
140
162
  export {};
@@ -10,6 +10,7 @@
10
10
  * Pure and dependency-free, so the browser entry can include it.
11
11
  */
12
12
  import { refusalMessageOf } from "./LambderApiRefusal.js";
13
+ import { isObjectPayload } from "./LambderObjectPayload.js";
13
14
  /**
14
15
  * Whether a parsed body is Lambder's envelope, which always carries
15
16
  * apiVersion (null when the server set none). An object without it is
@@ -21,15 +22,25 @@ const isApiEnvelope = (value) => value !== null && typeof value === "object" &&
21
22
  /**
22
23
  * Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
23
24
  * the envelope when the server sent one (Lambder's own 500 body carries
24
- * errorMessage, and a global error handler may add crash and logList); a
25
+ * refusal, and a global error handler may add crash and logList); a
25
26
  * 422 is a validation failure only with Lambder's validation body; anything
26
27
  * else must be a JSON envelope, whose flags are honoured in a fixed order.
27
28
  * A failure read off any answer but a 422 carries the answer's Retry-After
28
29
  * as retryAfterSeconds.
30
+ *
31
+ * A success is a 2xx envelope with no flag and no refusal whose payload
32
+ * is an object or an array: what only a handler's parsed output is. Anything
33
+ * else that says nothing is wrong (a null or primitive payload a hand-built
34
+ * body, a proxy or an old stored answer carries) is a server failure, so a
35
+ * success's payload is always the contract's output and never falsy.
36
+ *
37
+ * TMessage is the endpoint's refusal message type: the reader cannot check
38
+ * a code against declarations it does not have, and relies on the server,
39
+ * which never sends a code the endpoint did not declare.
29
40
  */
30
41
  export const resolveApiOutcome = async (answer) => {
31
42
  const status = answer.status;
32
- const errorMessageOf = (envelope) => envelope?.errorMessage !== undefined ? { errorMessage: refusalMessageOf(envelope.errorMessage) } : {};
43
+ const refusalOf = (envelope) => envelope?.refusal !== undefined ? { refusal: refusalMessageOf(envelope.refusal) } : {};
33
44
  // Retry-After (delta-seconds) rides every refusal that knows its reset
34
45
  // time, e.g. a rate limit, and a 503 that says when to come back; absent
35
46
  // or unreadable is undefined.
@@ -53,7 +64,7 @@ export const resolveApiOutcome = async (answer) => {
53
64
  catch { /* body unavailable */ }
54
65
  return {
55
66
  ok: false, reason: 'server', status,
56
- ...errorMessageOf(envelope),
67
+ ...refusalOf(envelope),
57
68
  logList: envelope?.logList,
58
69
  ...(envelope ? { response: envelope } : {}),
59
70
  error: new Error("Request failed: " + status + " - " + (answer.statusText ?? "")),
@@ -91,21 +102,27 @@ export const resolveApiOutcome = async (answer) => {
91
102
  const gatewayMessage = typeof data.message === "string" ? `: ${data.message}` : "";
92
103
  return { ok: false, reason: 'server', status, error: new Error(`Request failed: ${status} - the answer is not a Lambder envelope${gatewayMessage}`), ...retryAfter };
93
104
  }
105
+ const asRefusal = data;
94
106
  if (data.versionExpired)
95
- return { ok: false, reason: 'versionExpired', status, ...errorMessageOf(data), response: data, logList: data.logList, ...retryAfter };
107
+ return { ok: false, reason: 'versionExpired', status, ...refusalOf(data), response: asRefusal, logList: data.logList, ...retryAfter };
96
108
  if (data.sessionExpired)
97
- return { ok: false, reason: 'sessionExpired', status, ...errorMessageOf(data), response: data, logList: data.logList, ...retryAfter };
109
+ return { ok: false, reason: 'sessionExpired', status, ...refusalOf(data), response: asRefusal, logList: data.logList, ...retryAfter };
98
110
  if (data.notAuthorized)
99
- return { ok: false, reason: 'notAuthorized', status, ...errorMessageOf(data), response: data, logList: data.logList, ...retryAfter };
100
- // Presence, not truthiness: the writer keeps an errorMessage an app set
111
+ return { ok: false, reason: 'notAuthorized', status, ...refusalOf(data), response: asRefusal, logList: data.logList, ...retryAfter };
112
+ // Presence, not truthiness: the writer keeps a refusal an app set
101
113
  // to the empty string, and a refusal that says nothing is still a
102
114
  // refusal, not a success.
103
- if (data.errorMessage !== undefined)
104
- return { ok: false, reason: 'errorMessage', status, errorMessage: refusalMessageOf(data.errorMessage), response: data, logList: data.logList, ...retryAfter };
115
+ if (data.refusal !== undefined)
116
+ return { ok: false, reason: 'refusal', status, refusal: refusalMessageOf(data.refusal), response: asRefusal, logList: data.logList, ...retryAfter };
105
117
  // An envelope that says nothing is wrong is still not a success when the
106
- // status says otherwise.
118
+ // status says otherwise, nor when its payload is not what a handler
119
+ // answers. Neither is a refusal either, so neither carries the body as
120
+ // one: the error says what it was.
107
121
  if (status < 200 || status >= 300) {
108
- return { ok: false, reason: 'server', status, response: data, logList: data.logList, error: new Error(`Request failed: ${status} - ${answer.statusText ?? ""}`), ...retryAfter };
122
+ return { ok: false, reason: 'server', status, logList: data.logList, error: new Error(`Request failed: ${status} - ${answer.statusText ?? ""}`), ...retryAfter };
123
+ }
124
+ if (!isObjectPayload(data.payload)) {
125
+ return { ok: false, reason: 'server', status, logList: data.logList, error: new Error("Request failed: the answer's payload is not an object or an array, so no handler of this API wrote it") };
109
126
  }
110
127
  return { ok: true, payload: data.payload, response: data, logList: data.logList };
111
128
  };