@cratis/arc 21.3.1 → 21.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cjs/queries/QueryFor.js +50 -11
  2. package/dist/cjs/queries/QueryFor.js.map +1 -1
  3. package/dist/cjs/queries/QueryHttpRequest.js +0 -0
  4. package/dist/cjs/queries/QueryHttpRequest.js.map +1 -1
  5. package/dist/cjs/queries/isAbortError.js +18 -0
  6. package/dist/cjs/queries/isAbortError.js.map +1 -0
  7. package/dist/esm/queries/QueryFor.d.ts.map +1 -1
  8. package/dist/esm/queries/QueryFor.js +50 -11
  9. package/dist/esm/queries/QueryFor.js.map +1 -1
  10. package/dist/esm/queries/QueryHttpRequest.d.ts.map +1 -1
  11. package/dist/esm/queries/QueryHttpRequest.js +0 -0
  12. package/dist/esm/queries/QueryHttpRequest.js.map +1 -1
  13. package/dist/esm/queries/for_QueryFor/when_performing/with_a_nullish_transport_rejection.d.ts +2 -0
  14. package/dist/esm/queries/for_QueryFor/when_performing/with_a_nullish_transport_rejection.d.ts.map +1 -0
  15. package/dist/esm/queries/for_QueryFor/when_performing/with_a_nullish_transport_rejection.js +22 -0
  16. package/dist/esm/queries/for_QueryFor/when_performing/with_a_nullish_transport_rejection.js.map +1 -0
  17. package/dist/esm/queries/for_QueryFor/when_performing/with_an_aborted_request.d.ts +2 -0
  18. package/dist/esm/queries/for_QueryFor/when_performing/with_an_aborted_request.d.ts.map +1 -0
  19. package/dist/esm/queries/for_QueryFor/when_performing/with_an_aborted_request.js +21 -0
  20. package/dist/esm/queries/for_QueryFor/when_performing/with_an_aborted_request.js.map +1 -0
  21. package/dist/esm/queries/for_QueryFor/when_performing/with_fetch_error.js +7 -18
  22. package/dist/esm/queries/for_QueryFor/when_performing/with_fetch_error.js.map +1 -1
  23. package/dist/esm/queries/isAbortError.d.ts +2 -0
  24. package/dist/esm/queries/isAbortError.d.ts.map +1 -0
  25. package/dist/esm/queries/isAbortError.js +16 -0
  26. package/dist/esm/queries/isAbortError.js.map +1 -0
  27. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +1 -1
  29. package/queries/QueryFor.ts +51 -11
  30. package/queries/QueryHttpRequest.ts +0 -0
  31. package/queries/for_QueryFor/when_performing/with_a_nullish_transport_rejection.ts +40 -0
  32. package/queries/for_QueryFor/when_performing/with_an_aborted_request.ts +34 -0
  33. package/queries/for_QueryFor/when_performing/with_fetch_error.ts +13 -20
  34. package/queries/isAbortError.ts +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/arc",
3
- "version": "21.3.1",
3
+ "version": "21.5.0",
4
4
  "description": "",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@ import { ParameterDescriptor } from '../reflection/ParameterDescriptor';
14
14
  import { ParametersHelper } from '../reflection/ParametersHelper';
15
15
  import { QueryHttpMethod } from './QueryHttpMethod';
16
16
  import { executeQueryHttpRequest } from './QueryHttpRequest';
17
+ import { isAbortError } from './isAbortError';
17
18
 
18
19
  /**
19
20
  * Represents an implementation of {@link IQueryFor}.
@@ -117,17 +118,56 @@ export abstract class QueryFor<TDataType, TParameters = object> implements IQuer
117
118
  headers[Globals.microserviceHttpHeader] = this._microservice;
118
119
  }
119
120
 
120
- const response = await executeQueryHttpRequest(this._httpMethod, {
121
- route: this.route,
122
- apiBasePath: this._apiBasePath,
123
- origin: this._origin,
124
- args: (args as object) ?? {},
125
- parameterValues,
126
- paging: this.paging,
127
- sorting: this.sorting,
128
- headers,
129
- signal: this.abortController.signal
130
- });
121
+ let response: Response;
122
+
123
+ try {
124
+ response = await executeQueryHttpRequest(this._httpMethod, {
125
+ route: this.route,
126
+ apiBasePath: this._apiBasePath,
127
+ origin: this._origin,
128
+ args: (args as object) ?? {},
129
+ parameterValues,
130
+ paging: this.paging,
131
+ sorting: this.sorting,
132
+ headers,
133
+ signal: this.abortController.signal
134
+ });
135
+ } catch (error) {
136
+ // An abort is not a failure - it is this query superseding its own in-flight request above.
137
+ // Rethrowing lets the caller discard the superseded request so it cannot settle over the
138
+ // newer one that now owns the result.
139
+ if (isAbortError(error)) {
140
+ throw error;
141
+ }
142
+
143
+ // A dead network, a CORS rejection or a DNS failure never reaches the server, so it is
144
+ // neither an authorization nor a validation outcome - it is reported as an exception, with
145
+ // the default value as data, exactly as every other unsuccessful result from here does.
146
+ // Destructuring a nullish rejection value throws, which would make the `String(error)`
147
+ // fallback written for exactly that case unreachable - so it is coerced to an object first.
148
+ const { message } = (error ?? {}) as { message?: string };
149
+ const failure = {
150
+ ...QueryResult.noSuccess,
151
+ data: this.defaultValue,
152
+ isSuccess: false,
153
+ isAuthorized: true,
154
+ isValid: true,
155
+ hasExceptions: true,
156
+ exceptionMessages: [message ?? String(error)],
157
+ // Left empty on purpose. Every result the server returns has its stack trace blanked
158
+ // unless exception detail is explicitly exposed, and consumers are told to forward
159
+ // this field to a logger - so filling it in here would make a failure that never
160
+ // reached the server the one case that escapes that policy. The message is what
161
+ // diagnoses a transport failure; the browser's own frames add nothing.
162
+ exceptionStackTrace: ''
163
+ };
164
+
165
+ // `hasData` is a prototype getter, and a spread copies own properties only - so the literal
166
+ // above would hand every consumer of this public `IQueryResult` member `undefined` instead
167
+ // of a boolean. Restoring the prototype makes it a real {@link QueryResult} again while
168
+ // leaving every field value exactly as it is.
169
+ return Object.setPrototypeOf(failure, QueryResult.prototype) as QueryResult<TDataType>;
170
+ }
131
171
 
132
172
  try {
133
173
  const result = await response.json();
Binary file
@@ -0,0 +1,40 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { a_query_for } from '../given/a_query_for';
5
+ import { given } from '../../../given';
6
+
7
+ import * as sinon from 'sinon';
8
+ import { createFetchHelper } from '../../../helpers/fetchHelper';
9
+ import { QueryResult } from '../../QueryResult';
10
+
11
+ describe('with a nullish transport rejection', given(a_query_for, context => {
12
+ let outcome: { resolved: boolean; result?: QueryResult<string> };
13
+ let fetchStub: sinon.SinonStub;
14
+ let fetchHelper: { stubFetch: () => sinon.SinonStub; restore: () => void };
15
+
16
+ beforeEach(async () => {
17
+ fetchHelper = createFetchHelper();
18
+ fetchStub = fetchHelper.stubFetch();
19
+
20
+ // Nothing guarantees a rejection value is an object - `Promise.reject()` and a thrown `undefined`
21
+ // both reach here, and an interceptor or a polyfilled fetch can produce either.
22
+ fetchStub.callsFake(() => Promise.reject(undefined));
23
+
24
+ context.query.setOrigin('https://api.example.com');
25
+
26
+ outcome = await context.query.perform({ id: 'test-id' }).then(
27
+ result => ({ resolved: true, result }),
28
+ () => ({ resolved: false }));
29
+ });
30
+
31
+ afterEach(() => {
32
+ fetchHelper.restore();
33
+ });
34
+
35
+ it('should resolve rather than reject', () => outcome.resolved.should.be.true);
36
+
37
+ it('should report that it has exceptions', () => outcome.result!.hasExceptions.should.be.true);
38
+
39
+ it('should describe the rejection it could not read a message from', () => outcome.result!.exceptionMessages.should.deep.equal(['undefined']));
40
+ }));
@@ -0,0 +1,34 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { a_query_for } from '../given/a_query_for';
5
+ import { given } from '../../../given';
6
+
7
+ import * as sinon from 'sinon';
8
+ import { createFetchHelper } from '../../../helpers/fetchHelper';
9
+
10
+ describe('with an aborted request', given(a_query_for, context => {
11
+ let rejection: unknown;
12
+ let fetchStub: sinon.SinonStub;
13
+ let fetchHelper: { stubFetch: () => sinon.SinonStub; restore: () => void };
14
+
15
+ beforeEach(async () => {
16
+ fetchHelper = createFetchHelper();
17
+ fetchStub = fetchHelper.stubFetch();
18
+ fetchStub.rejects(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }));
19
+
20
+ context.query.setOrigin('https://api.example.com');
21
+
22
+ // An abort only ever happens because a newer request superseded this one, so the outcome is
23
+ // recorded rather than swallowed - the assertions below are on what actually came back.
24
+ rejection = await context.query.perform({ id: 'test-id' }).then(() => undefined, error => error);
25
+ });
26
+
27
+ afterEach(() => {
28
+ fetchHelper.restore();
29
+ });
30
+
31
+ it('should reject so the superseded request cannot settle a result', () => (rejection !== undefined).should.be.true);
32
+
33
+ it('should reject with the abort error', () => (rejection as Error).name.should.equal('AbortError'));
34
+ }));
@@ -14,37 +14,30 @@ describe('with fetch error', given(a_query_for, context => {
14
14
  let fetchHelper: { stubFetch: () => sinon.SinonStub; restore: () => void };
15
15
 
16
16
  beforeEach(async () => {
17
- // Setup fetch mock to reject
18
17
  fetchHelper = createFetchHelper();
19
18
  fetchStub = fetchHelper.stubFetch();
20
19
  fetchStub.rejects(new Error('Network error'));
21
20
 
22
21
  context.query.setOrigin('https://api.example.com');
23
22
 
24
- try {
25
- // Call perform with valid arguments - should handle the error and return no success
26
- result = await context.query.perform({ id: 'test-id' });
27
- } catch {
28
- // If QueryFor doesn't handle the error, we'll create a default response
29
- const noSuccess = { ...QueryResult.noSuccess, data: context.query.defaultValue } as QueryResult<string>;
30
- result = noSuccess;
31
- }
23
+ result = await context.query.perform({ id: 'test-id' });
32
24
  });
33
25
 
34
26
  afterEach(() => {
35
27
  fetchHelper.restore();
36
28
  });
37
29
 
38
- it('should return no success result', () => {
39
- result.isSuccess.should.be.false;
40
- });
30
+ it('should resolve rather than reject', () => (result !== undefined).should.be.true);
41
31
 
42
- it('should return default value', () => {
43
- result.data.should.equal('');
44
- });
32
+ it('should return an unsuccessful result', () => result.isSuccess.should.be.false);
45
33
 
46
- it('should return result without data', () => {
47
- result.data.should.equal('');
48
- result.isSuccess.should.be.false;
49
- });
50
- }));
34
+ it('should report that it has exceptions', () => result.hasExceptions.should.be.true);
35
+
36
+ it('should carry the transport error message', () => result.exceptionMessages.should.deep.equal(['Network error']));
37
+
38
+ it('should return the default value as data', () => result.data.should.equal(''));
39
+
40
+ // `hasData` is part of the public `IQueryResult` contract, and a consumer awaiting `perform()`
41
+ // directly reads it off this object rather than off the `QueryResultWithState` the hooks build.
42
+ it('should expose hasData as a boolean', () => (typeof result.hasData).should.equal('boolean'));
43
+ }));
@@ -0,0 +1,15 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /**
5
+ * Determines whether an error is the rejection produced by aborting a request through an
6
+ * {@link AbortSignal} - as opposed to a genuine transport failure.
7
+ *
8
+ * Deliberately kept out of the `queries` barrel: it is an internal detail shared by the query
9
+ * transport and {@link QueryFor}, not public surface for consumers to depend on.
10
+ * @param error The error to inspect.
11
+ * @returns True if the error represents an aborted request, false otherwise.
12
+ */
13
+ export function isAbortError(error: unknown): boolean {
14
+ return (error as { name?: string })?.name === 'AbortError';
15
+ }