@faststore/core 4.2.0 → 4.3.0-dev.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.
@@ -1,9 +1,9 @@
1
1
 
2
- > @faststore/core@4.1.1 generate /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.3.0-dev.0 generate /home/runner/work/faststore/faststore/packages/core
3
3
  > pnpm run gen-types && pnpm run cache-graphql
4
4
 
5
5
 
6
- > @faststore/core@4.1.1 gen-types /home/runner/work/faststore/faststore/packages/core
6
+ > @faststore/core@4.3.0-dev.0 gen-types /home/runner/work/faststore/faststore/packages/core
7
7
  > node ../cli/bin/run generate-types .
8
8
 
9
9
  [STARTED] Parse Configuration
@@ -19,7 +19,7 @@
19
19
  [COMPLETED] Generate to /home/runner/work/faststore/faststore/packages/core/@generated/
20
20
  [COMPLETED] Generate outputs
21
21
 
22
- > @faststore/core@4.1.1 cache-graphql /home/runner/work/faststore/faststore/packages/core
22
+ > @faststore/core@4.3.0-dev.0 cache-graphql /home/runner/work/faststore/faststore/packages/core
23
23
  > node ../cli/bin/run cache-graphql --config=./discovery.config.default.js --queries=./@generated/persisted-documents.json
24
24
 
25
25
  [Info] - Config file location: /home/runner/work/faststore/faststore/packages/core/discovery.config.default.js
@@ -1,12 +1,10 @@
1
1
 
2
- > @faststore/core@4.1.1 test /home/runner/work/faststore/faststore/packages/core
2
+ > @faststore/core@4.3.0-dev.0 test /home/runner/work/faststore/faststore/packages/core
3
3
  > vitest run
4
4
 
5
5
 
6
6
   RUN  v4.0.7 /home/runner/work/faststore/faststore/packages/core
7
7
 
8
- ✓  browser  test/sdk/session/installLocaleCorrector.browser.test.ts (7 tests) 54ms
9
- ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 33ms
10
8
  stderr | test/sdk/localization/store-url.browser.test.ts
11
9
  Error in optimistic validation: TypeError: Cannot read properties of undefined (reading 'read')
12
10
  at validateCart (/home/runner/work/faststore/faststore/packages/core/src/sdk/cart/index.ts:119:27)
@@ -14,28 +12,79 @@
14
12
  at a (/home/runner/work/faststore/faststore/packages/sdk/dist/es/index.mjs:353:23)
15
13
   at processTicksAndRejections (node:internal/process/task_queues:103:5)
16
14
 
17
- ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 87ms
18
- ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 16ms
19
- ✓  node  test/utils/match-url.test.ts (10 tests) 23ms
20
- ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 15ms
21
- ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 24ms
22
- ✓  node  test/sdk/localization/useBindingSelector.test.tsx (12 tests) 24ms
23
- ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 32ms
15
+ ✓  browser  test/sdk/session/getInitialSession.browser.test.ts (6 tests) 42ms
16
+ ✓  browser  test/sdk/session/installLocaleCorrector.browser.test.ts (7 tests) 62ms
17
+ ✓  node  test/utils/localization/bindingPaths.test.ts (71 tests) 74ms
18
+ ✓  browser  test/sdk/localization/store-url.browser.test.ts (3 tests) 29ms
19
+ ✓  node  test/utils/match-url.test.ts (10 tests) 38ms
20
+ ✓  node  test/sdk/localization/bindingSelector.test.ts (18 tests) 23ms
21
+ ✓  browser  test/utils/isLocalHost.browser.test.ts (3 tests) 15ms
22
+ ✓  node  test/sdk/localization/useBindingSelector.test.tsx (12 tests) 20ms
23
+ ✓  node  test/utils/cookieCacheBusting.test.ts (10 tests) 44ms
24
+ ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 224ms
24
25
  ✓  node  test/utils/multipleTemplates.test.ts (8 tests) 15ms
25
- ✓  node  test/sdk/search/useSearchHistory.test.ts (13 tests) 263ms
26
- ✓  node  test/utils/clearCookies.test.ts (20 tests) 92ms
27
- ✓  node  test/server/cms/global.test.ts (3 tests) 31ms
28
- ✓  node  test/server/content/service.test.ts (5 tests) 11ms
29
- ✓  node  test/sdk/localization/store-url.test.ts (1 test) 6ms
30
- ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 19ms
31
- ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 17ms
32
- ✓  node  test/pages/api/preview.test.ts (2 tests) 34ms
33
- ✓  node  test/utils/isLocalHost.test.ts (7 tests) 12ms
34
- ✓  node  test/server/cms/index.test.ts (2 tests) 14ms
35
- ✓  node  test/server/index.test.ts (7 tests) 627ms
36
-
37
-  Test Files  21 passed (21)
38
-  Tests  226 passed (226)
39
-  Start at  21:37:17
40
-  Duration  16.80s (transform 6.12s, setup 0ms, collect 18.21s, tests 1.45s, environment 14.95s, prepare 923ms)
26
+ ✓  node  test/utils/clearCookies.test.ts (20 tests) 147ms
27
+ ✓  node  test/server/content/service.test.ts (5 tests) 12ms
28
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates the upstream 400 from a wrapped BadRequestError instead of 500
29
+ Graphql execution returned with error: [
30
+ {
31
+ message: 'O campo CEP (88) é inválido',
32
+ status: 400,
33
+ type: 'BadRequestError'
34
+ }
35
+ ]
36
+
37
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates UnauthorizedError status
38
+ Graphql execution returned with error: [ { message: 'nope', status: 401, type: 'UnauthorizedError' } ]
39
+
40
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates ForbiddenError status
41
+ Graphql execution returned with error: [ { message: 'nope', status: 403, type: 'ForbiddenError' } ]
42
+
43
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates NotFoundError status
44
+ Graphql execution returned with error: [ { message: 'nope', status: 404, type: 'NotFoundError' } ]
45
+
46
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > propagates an unmapped upstream status (UnknownError 429)
47
+ Graphql execution returned with error: [ { message: 'slow down', status: 429, type: 'UnknownError' } ]
48
+
49
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > recovers status when the error itself is a FastStoreError (not wrapped)
50
+ Graphql execution returned with error: [ { message: 'bad input', status: 400, type: 'BadRequestError' } ]
51
+
52
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > falls back to a body-less 500 for non-FastStore errors
53
+ Graphql execution returned with error: [
54
+ {
55
+ message: 'Sorry, something went wrong.',
56
+ status: undefined,
57
+ type: undefined
58
+ }
59
+ ]
60
+
61
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > exposes type, status and message in the body outside production
62
+ Graphql execution returned with error: [ { message: 'invalid CEP', status: 400, type: 'BadRequestError' } ]
63
+
64
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > omits the free-text message from the body in production
65
+ Graphql execution returned with error: [ { message: 'invalid CEP', status: 400, type: 'BadRequestError' } ]
66
+
67
+ stderr | test/pages/api/graphql.test.ts > /api/graphql error status propagation > uses the first recoverable FastStoreError when several errors exist
68
+ Graphql execution returned with error: [
69
+ { message: 'plain error', status: undefined, type: undefined },
70
+ { message: 'missing', status: 404, type: 'NotFoundError' },
71
+ { message: 'bad', status: 400, type: 'BadRequestError' }
72
+ ]
73
+
74
+ ✓  node  test/pages/api/graphql.test.ts (10 tests) 72ms
75
+ ✓  node  test/server/cms/global.test.ts (3 tests) 19ms
76
+ ✓  node  test/utils/getRequestHostname.test.ts (12 tests) 23ms
77
+ ✓  node  test/sdk/localization/store-url.test.ts (1 test) 12ms
78
+ ✓  node  test/utils/validateSessionRefreshToken.test.ts (6 tests) 14ms
79
+ ✓  node  test/pages/api/preview.test.ts (2 tests) 18ms
80
+ ✓  node  test/utils/isLocalHost.test.ts (7 tests) 8ms
81
+ ✓  node  test/server/cms/index.test.ts (2 tests) 4ms
82
+ ✓  node  test/server/index.test.ts (7 tests) 1333ms
83
+ ✓ should return a valid merged GraphQL schema  658ms
84
+ ✓ should exist with its plugins  390ms
85
+
86
+  Test Files  22 passed (22)
87
+  Tests  236 passed (236)
88
+  Start at  14:05:33
89
+  Duration  15.91s (transform 5.65s, setup 0ms, collect 17.89s, tests 2.25s, environment 14.22s, prepare 794ms)
41
90
 
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [4.3.0-dev.1](https://github.com/vtex/faststore/compare/v4.3.0-dev.0...v4.3.0-dev.1) (2026-06-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **core:** propagate upstream error status instead of always 500 ([#3379](https://github.com/vtex/faststore/issues/3379)) ([c03d3df](https://github.com/vtex/faststore/commit/c03d3dff7f5920e834a318417a4a02cc653453cf))
11
+
12
+ # [4.3.0-dev.0](https://github.com/vtex/faststore/compare/v4.2.0...v4.3.0-dev.0) (2026-06-01)
13
+
14
+ **Note:** Version bump only for package @faststore/core
15
+
6
16
  # [4.2.0](https://github.com/vtex/faststore/compare/v4.1.2...v4.2.0) (2026-06-01)
7
17
 
8
18
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/core",
3
- "version": "4.2.0",
3
+ "version": "4.3.0-dev.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -70,11 +70,11 @@
70
70
  "style-loader": "^3.3.1",
71
71
  "swr": "^2.2.5",
72
72
  "use-sync-external-store": "^1.6.0",
73
- "@faststore/lighthouse": "4.1.1",
74
- "@faststore/diagnostics": "4.1.1",
75
- "@faststore/api": "4.1.1",
76
- "@faststore/sdk": "4.1.1",
77
- "@faststore/ui": "4.2.0"
73
+ "@faststore/diagnostics": "4.3.0-dev.1",
74
+ "@faststore/api": "4.3.0-dev.1",
75
+ "@faststore/sdk": "4.3.0-dev.1",
76
+ "@faststore/lighthouse": "4.3.0-dev.1",
77
+ "@faststore/ui": "4.3.0-dev.1"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@cypress/code-coverage": "^3.12.1",
@@ -18,6 +18,21 @@ const DEFAULT_MAX_AGE = 5 * 60 // 5 minutes
18
18
  const DEFAULT_STALE_WHILE_REVALIDATE = 60 * 60 // 1 hour
19
19
  const ALLOWED_HOST_SUFFIXES = ['localhost', '.vtex.app', '.localhost']
20
20
 
21
+ /**
22
+ * Recovers a FastStoreError from an execution error, whether it is the error
23
+ * itself or wrapped as the `originalError` of a masked GraphQLError.
24
+ */
25
+ const recoverFastStoreError = (graphqlError: unknown) => {
26
+ if (isFastStoreError(graphqlError)) {
27
+ return graphqlError
28
+ }
29
+
30
+ const originalError = (graphqlError as { originalError?: unknown })
31
+ ?.originalError
32
+
33
+ return isFastStoreError(originalError) ? originalError : undefined
34
+ }
35
+
21
36
  // Example: "Set-Cookie: key=value; Domain=example.com; Path=/"
22
37
  const MATCH_DOMAIN_REGEXP = /(?:^|;\s*)(?:domain=)([^;]+)/i
23
38
 
@@ -186,13 +201,53 @@ const handler: NextApiHandler = async (request, response) => {
186
201
  { headers: request.headers }
187
202
  )
188
203
 
189
- const hasErrors = Array.isArray(errors)
204
+ const hasErrors = Array.isArray(errors) && errors.length > 0
190
205
 
191
206
  if (hasErrors) {
192
- const error = errors.find(isFastStoreError)
193
- console.error('Graphql execution returned with error: ', error)
207
+ // After error masking, entries are GraphQLError instances whose
208
+ // `name` is "GraphQLError" the original FastStoreError (carrying the
209
+ // upstream status) is nested in `originalError`. Recover it so the BFF
210
+ // propagates the real status instead of collapsing everything to 500.
211
+ const fastStoreError = errors.map(recoverFastStoreError).find(Boolean)
212
+
213
+ console.error(
214
+ 'Graphql execution returned with error: ',
215
+ errors.map((graphqlError) => {
216
+ const fsError = recoverFastStoreError(graphqlError)
217
+
218
+ return {
219
+ message: (graphqlError as { message?: string })?.message,
220
+ status: fsError?.extensions.status,
221
+ type: fsError?.extensions.type,
222
+ }
223
+ })
224
+ )
225
+
226
+ const status = fastStoreError?.extensions.status ?? 500
227
+
228
+ // No recoverable FastStoreError: keep the masked, body-less 500.
229
+ if (!fastStoreError) {
230
+ response.status(status).end()
231
+ return
232
+ }
233
+
234
+ // Only FastStoreError-derived details are exposed. `type`/`status` are a
235
+ // closed enum (safe everywhere); the free-text `message` may echo raw
236
+ // upstream text, so it is restricted to non-production responses. The
237
+ // full message is still available server-side via the log above.
238
+ const responseError = {
239
+ extensions: {
240
+ type: fastStoreError.extensions.type,
241
+ status: fastStoreError.extensions.status,
242
+ },
243
+ ...(process.env.NODE_ENV !== 'production'
244
+ ? { message: fastStoreError.message }
245
+ : {}),
246
+ }
194
247
 
195
- response.status(error?.extensions.status ?? 500).end()
248
+ response.status(status)
249
+ response.setHeader('content-type', 'application/json')
250
+ response.send(JSON.stringify({ errors: [responseError] }))
196
251
  return
197
252
  }
198
253
 
@@ -0,0 +1,167 @@
1
+ import {
2
+ BadRequestError,
3
+ FastStoreError,
4
+ ForbiddenError,
5
+ NotFoundError,
6
+ UnauthorizedError,
7
+ } from '@faststore/api'
8
+ import { GraphQLError } from 'graphql'
9
+ import type { NextApiRequest, NextApiResponse } from 'next'
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+
12
+ import handler from '../../../src/pages/api/graphql'
13
+ import { execute } from '../../../src/server'
14
+
15
+ vi.mock('../../../src/server', () => ({
16
+ execute: vi.fn(),
17
+ }))
18
+
19
+ const mockedExecute = vi.mocked(execute)
20
+
21
+ const createResponse = () => {
22
+ const res = {
23
+ status: vi.fn().mockReturnThis(),
24
+ setHeader: vi.fn().mockReturnThis(),
25
+ send: vi.fn().mockReturnThis(),
26
+ end: vi.fn().mockReturnThis(),
27
+ }
28
+
29
+ return res as unknown as NextApiResponse & typeof res
30
+ }
31
+
32
+ const createRequest = (operationName = 'ClientShippingSimulationQuery') =>
33
+ ({
34
+ method: 'GET',
35
+ headers: { host: 'localhost:3000' },
36
+ query: {
37
+ operationName,
38
+ operationHash: 'hash',
39
+ variables: '{}',
40
+ },
41
+ }) as unknown as NextApiRequest
42
+
43
+ const mockExecuteWithErrors = (errors: unknown[]) => {
44
+ mockedExecute.mockResolvedValue({
45
+ data: null,
46
+ errors,
47
+ extensions: { cookies: new Map(), cacheControl: undefined },
48
+ } as never)
49
+ }
50
+
51
+ // Reproduces what `useMaskedErrors` produces: a GraphQLError whose
52
+ // `originalError` is the FastStoreError thrown by a resolver/`fetchAPI`.
53
+ const maskedError = (original: FastStoreError) =>
54
+ new GraphQLError(original.message, { originalError: original })
55
+
56
+ describe('/api/graphql error status propagation', () => {
57
+ beforeEach(() => {
58
+ vi.clearAllMocks()
59
+ })
60
+
61
+ afterEach(() => {
62
+ vi.unstubAllEnvs()
63
+ })
64
+
65
+ it('propagates the upstream 400 from a wrapped BadRequestError instead of 500', async () => {
66
+ mockExecuteWithErrors([
67
+ maskedError(new BadRequestError('O campo CEP (88) é inválido')),
68
+ ])
69
+
70
+ const res = createResponse()
71
+ await handler(createRequest(), res)
72
+
73
+ expect(res.status).toHaveBeenCalledWith(400)
74
+ expect(res.end).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it.each([
78
+ ['UnauthorizedError', () => new UnauthorizedError('nope'), 401],
79
+ ['ForbiddenError', () => new ForbiddenError('nope'), 403],
80
+ ['NotFoundError', () => new NotFoundError('nope'), 404],
81
+ ])('propagates %s status', async (_name, makeError, expectedStatus) => {
82
+ mockExecuteWithErrors([maskedError(makeError())])
83
+
84
+ const res = createResponse()
85
+ await handler(createRequest(), res)
86
+
87
+ expect(res.status).toHaveBeenCalledWith(expectedStatus)
88
+ })
89
+
90
+ it('propagates an unmapped upstream status (UnknownError 429)', async () => {
91
+ mockExecuteWithErrors([
92
+ maskedError(
93
+ new FastStoreError({ status: 429, type: 'UnknownError' }, 'slow down')
94
+ ),
95
+ ])
96
+
97
+ const res = createResponse()
98
+ await handler(createRequest(), res)
99
+
100
+ expect(res.status).toHaveBeenCalledWith(429)
101
+ })
102
+
103
+ it('recovers status when the error itself is a FastStoreError (not wrapped)', async () => {
104
+ mockExecuteWithErrors([new BadRequestError('bad input')])
105
+
106
+ const res = createResponse()
107
+ await handler(createRequest(), res)
108
+
109
+ expect(res.status).toHaveBeenCalledWith(400)
110
+ })
111
+
112
+ it('falls back to a body-less 500 for non-FastStore errors', async () => {
113
+ mockExecuteWithErrors([new GraphQLError('Sorry, something went wrong.')])
114
+
115
+ const res = createResponse()
116
+ await handler(createRequest(), res)
117
+
118
+ expect(res.status).toHaveBeenCalledWith(500)
119
+ expect(res.end).toHaveBeenCalled()
120
+ expect(res.send).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('exposes type, status and message in the body outside production', async () => {
124
+ vi.stubEnv('NODE_ENV', 'development')
125
+ mockExecuteWithErrors([maskedError(new BadRequestError('invalid CEP'))])
126
+
127
+ const res = createResponse()
128
+ await handler(createRequest(), res)
129
+
130
+ const body = JSON.parse(
131
+ (res.send as ReturnType<typeof vi.fn>).mock.calls[0][0]
132
+ )
133
+ expect(body.errors[0]).toEqual({
134
+ extensions: { type: 'BadRequestError', status: 400 },
135
+ message: 'invalid CEP',
136
+ })
137
+ })
138
+
139
+ it('omits the free-text message from the body in production', async () => {
140
+ vi.stubEnv('NODE_ENV', 'production')
141
+ mockExecuteWithErrors([maskedError(new BadRequestError('invalid CEP'))])
142
+
143
+ const res = createResponse()
144
+ await handler(createRequest(), res)
145
+
146
+ const body = JSON.parse(
147
+ (res.send as ReturnType<typeof vi.fn>).mock.calls[0][0]
148
+ )
149
+ expect(body.errors[0]).toEqual({
150
+ extensions: { type: 'BadRequestError', status: 400 },
151
+ })
152
+ expect(body.errors[0].message).toBeUndefined()
153
+ })
154
+
155
+ it('uses the first recoverable FastStoreError when several errors exist', async () => {
156
+ mockExecuteWithErrors([
157
+ new GraphQLError('plain error'),
158
+ maskedError(new NotFoundError('missing')),
159
+ maskedError(new BadRequestError('bad')),
160
+ ])
161
+
162
+ const res = createResponse()
163
+ await handler(createRequest(), res)
164
+
165
+ expect(res.status).toHaveBeenCalledWith(404)
166
+ })
167
+ })