@useairfoil/producer-shopify 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +201 -0
- package/README.md +85 -248
- package/dist/index.d.mts +6993 -0
- package/dist/index.mjs +3 -0
- package/dist/main.d.mts +1 -0
- package/dist/main.mjs +700 -0
- package/dist/main.mjs.map +1 -0
- package/dist/manifest.d.mts +32 -0
- package/dist/manifest.mjs +51 -0
- package/dist/manifest.mjs.map +1 -0
- package/dist/{chunk-Bp6m_JJh.js → rolldown-runtime-D7D4PA-g.mjs} +4 -4
- package/dist/src-YBU_KSf5.mjs +2472 -0
- package/dist/src-YBU_KSf5.mjs.map +1 -0
- package/package.json +27 -27
- package/src/{auth.ts → api/auth.ts} +15 -35
- package/src/api/client.ts +429 -0
- package/src/api/graphql.ts +221 -0
- package/src/api/http.ts +25 -0
- package/src/connector.ts +266 -163
- package/src/create-table.ts +10 -0
- package/src/demo/customers.ts +412 -0
- package/src/demo/index.ts +31 -0
- package/src/demo/setup.ts +63 -0
- package/src/demo/shared.ts +114 -0
- package/src/index.ts +35 -21
- package/src/main.ts +14 -8
- package/src/manifest.ts +4 -55
- package/src/resources/carts/row.ts +82 -0
- package/src/resources/carts/webhook.ts +98 -0
- package/src/resources/customers/graphql.ts +175 -0
- package/src/resources/customers/row.ts +140 -0
- package/src/resources/customers/webhook.ts +30 -0
- package/src/resources/orders/graphql.ts +419 -0
- package/src/resources/orders/row.ts +306 -0
- package/src/resources/orders/webhook.ts +248 -0
- package/src/resources/products/graphql.ts +142 -0
- package/src/resources/products/row.ts +122 -0
- package/src/resources/products/webhook.ts +130 -0
- package/src/resources/refunds/graphql.ts +196 -0
- package/src/resources/refunds/row.ts +137 -0
- package/src/resources/refunds/webhook.ts +123 -0
- package/src/resources/shared.ts +300 -0
- package/src/sandbox.ts +3 -3
- package/src/start.ts +4 -6
- package/src/tables.ts +15 -0
- package/dist/index.d.ts +0 -965
- package/dist/index.js +0 -4
- package/dist/main.d.ts +0 -1
- package/dist/main.js +0 -49
- package/dist/main.js.map +0 -1
- package/dist/manifest-BFfx2LkS.js +0 -91
- package/dist/manifest-BFfx2LkS.js.map +0 -1
- package/dist/manifest-CPOEExQU.d.ts +0 -34
- package/dist/manifest.d.ts +0 -2
- package/dist/manifest.js +0 -3
- package/dist/src-DqmiLrYS.js +0 -984
- package/dist/src-DqmiLrYS.js.map +0 -1
- package/src/api.ts +0 -636
- package/src/schemas.ts +0 -319
- package/src/throttle.ts +0 -89
- package/src/transient-retry.ts +0 -74
package/src/api.ts
DELETED
|
@@ -1,636 +0,0 @@
|
|
|
1
|
-
import { ConnectorError, Metrics, Telemetry } from "@useairfoil/connector-kit";
|
|
2
|
-
import {
|
|
3
|
-
Config,
|
|
4
|
-
Context,
|
|
5
|
-
Duration,
|
|
6
|
-
Effect,
|
|
7
|
-
Layer,
|
|
8
|
-
Option,
|
|
9
|
-
Redacted,
|
|
10
|
-
Schedule,
|
|
11
|
-
Schema,
|
|
12
|
-
Stream,
|
|
13
|
-
} from "effect";
|
|
14
|
-
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
15
|
-
|
|
16
|
-
import type { PageInfo, Product } from "./schemas";
|
|
17
|
-
|
|
18
|
-
import * as ShopifyAuth from "./auth";
|
|
19
|
-
import { manifest, type ShopifyConfig } from "./manifest";
|
|
20
|
-
import {
|
|
21
|
-
PageInfoSchema,
|
|
22
|
-
ProductOptionSchema,
|
|
23
|
-
ProductStatusSchema,
|
|
24
|
-
ProductVariantInventoryPolicySchema,
|
|
25
|
-
} from "./schemas";
|
|
26
|
-
import * as Throttle from "./throttle";
|
|
27
|
-
import * as TransientRetry from "./transient-retry";
|
|
28
|
-
|
|
29
|
-
export type ShopifyProductPage = {
|
|
30
|
-
readonly items: ReadonlyArray<Product>;
|
|
31
|
-
readonly endCursor: string | null;
|
|
32
|
-
readonly hasMore: boolean;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export type ShopifyApiClientService = {
|
|
36
|
-
readonly checkConnection: Effect.Effect<void, ConnectorError>;
|
|
37
|
-
readonly checkProductsAccess: Effect.Effect<void, ConnectorError>;
|
|
38
|
-
readonly fetchGraphQL: <A>(options: {
|
|
39
|
-
readonly operationName: string;
|
|
40
|
-
readonly query: string;
|
|
41
|
-
readonly variables?: Record<string, unknown>;
|
|
42
|
-
readonly schema: Schema.Decoder<A>;
|
|
43
|
-
}) => Effect.Effect<A, ConnectorError>;
|
|
44
|
-
readonly fetchProducts: (options: {
|
|
45
|
-
readonly first: number;
|
|
46
|
-
readonly after?: string;
|
|
47
|
-
}) => Effect.Effect<ShopifyProductPage, ConnectorError>;
|
|
48
|
-
/**
|
|
49
|
-
* Fetches the complete product with every variant page.
|
|
50
|
-
* Webhook payloads include only the first 100 variants.
|
|
51
|
-
*/
|
|
52
|
-
readonly fetchProductById: (id: string) => Effect.Effect<Product, ConnectorError>;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export class ShopifyApiClient extends Context.Service<ShopifyApiClient, ShopifyApiClientService>()(
|
|
56
|
-
"@useairfoil/producer-shopify/ShopifyApiClient",
|
|
57
|
-
) {}
|
|
58
|
-
|
|
59
|
-
const ProductsQuery = `#graphql
|
|
60
|
-
query AirfoilProducts($first: Int!, $after: String) {
|
|
61
|
-
products(first: $first, after: $after, sortKey: UPDATED_AT, reverse: true) {
|
|
62
|
-
nodes {
|
|
63
|
-
id
|
|
64
|
-
legacyResourceId
|
|
65
|
-
title
|
|
66
|
-
handle
|
|
67
|
-
descriptionHtml
|
|
68
|
-
productType
|
|
69
|
-
vendor
|
|
70
|
-
status
|
|
71
|
-
tags
|
|
72
|
-
createdAt
|
|
73
|
-
updatedAt
|
|
74
|
-
publishedAt
|
|
75
|
-
templateSuffix
|
|
76
|
-
featuredMedia {
|
|
77
|
-
... on MediaImage {
|
|
78
|
-
image {
|
|
79
|
-
url
|
|
80
|
-
altText
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
options(first: 100) {
|
|
85
|
-
id
|
|
86
|
-
name
|
|
87
|
-
position
|
|
88
|
-
values
|
|
89
|
-
}
|
|
90
|
-
variants(first: 25) {
|
|
91
|
-
nodes {
|
|
92
|
-
id
|
|
93
|
-
legacyResourceId
|
|
94
|
-
title
|
|
95
|
-
sku
|
|
96
|
-
barcode
|
|
97
|
-
price
|
|
98
|
-
compareAtPrice
|
|
99
|
-
inventoryPolicy
|
|
100
|
-
taxable
|
|
101
|
-
createdAt
|
|
102
|
-
updatedAt
|
|
103
|
-
}
|
|
104
|
-
pageInfo {
|
|
105
|
-
hasNextPage
|
|
106
|
-
endCursor
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
pageInfo {
|
|
111
|
-
hasNextPage
|
|
112
|
-
endCursor
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
`;
|
|
117
|
-
|
|
118
|
-
const ProductByIdQuery = `#graphql
|
|
119
|
-
query AirfoilProductById($id: ID!) {
|
|
120
|
-
product(id: $id) {
|
|
121
|
-
id
|
|
122
|
-
legacyResourceId
|
|
123
|
-
title
|
|
124
|
-
handle
|
|
125
|
-
descriptionHtml
|
|
126
|
-
productType
|
|
127
|
-
vendor
|
|
128
|
-
status
|
|
129
|
-
tags
|
|
130
|
-
createdAt
|
|
131
|
-
updatedAt
|
|
132
|
-
publishedAt
|
|
133
|
-
templateSuffix
|
|
134
|
-
featuredMedia {
|
|
135
|
-
... on MediaImage {
|
|
136
|
-
image {
|
|
137
|
-
url
|
|
138
|
-
altText
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
options(first: 100) {
|
|
143
|
-
id
|
|
144
|
-
name
|
|
145
|
-
position
|
|
146
|
-
values
|
|
147
|
-
}
|
|
148
|
-
variants(first: 25) {
|
|
149
|
-
nodes {
|
|
150
|
-
id
|
|
151
|
-
legacyResourceId
|
|
152
|
-
title
|
|
153
|
-
sku
|
|
154
|
-
barcode
|
|
155
|
-
price
|
|
156
|
-
compareAtPrice
|
|
157
|
-
inventoryPolicy
|
|
158
|
-
taxable
|
|
159
|
-
createdAt
|
|
160
|
-
updatedAt
|
|
161
|
-
}
|
|
162
|
-
pageInfo {
|
|
163
|
-
hasNextPage
|
|
164
|
-
endCursor
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
`;
|
|
170
|
-
|
|
171
|
-
const ProductVariantsQuery = `#graphql
|
|
172
|
-
query AirfoilProductVariants($id: ID!, $first: Int!, $after: String) {
|
|
173
|
-
product(id: $id) {
|
|
174
|
-
variants(first: $first, after: $after) {
|
|
175
|
-
nodes {
|
|
176
|
-
id
|
|
177
|
-
legacyResourceId
|
|
178
|
-
title
|
|
179
|
-
sku
|
|
180
|
-
barcode
|
|
181
|
-
price
|
|
182
|
-
compareAtPrice
|
|
183
|
-
inventoryPolicy
|
|
184
|
-
taxable
|
|
185
|
-
createdAt
|
|
186
|
-
updatedAt
|
|
187
|
-
}
|
|
188
|
-
pageInfo {
|
|
189
|
-
hasNextPage
|
|
190
|
-
endCursor
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
`;
|
|
196
|
-
|
|
197
|
-
const ProductsAccessQuery = `#graphql
|
|
198
|
-
query AirfoilProductsAccess {
|
|
199
|
-
products(first: 1) {
|
|
200
|
-
nodes {
|
|
201
|
-
id
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
`;
|
|
206
|
-
|
|
207
|
-
const ShopIdentityQuery = `#graphql
|
|
208
|
-
query AirfoilShopIdentity {
|
|
209
|
-
shop {
|
|
210
|
-
id
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
`;
|
|
214
|
-
|
|
215
|
-
const ShopIdentitySchema = Schema.Struct({
|
|
216
|
-
shop: Schema.Struct({ id: Schema.String }),
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
const ProductsAccessSchema = Schema.Struct({
|
|
220
|
-
products: Schema.Struct({
|
|
221
|
-
nodes: Schema.Array(Schema.Struct({ id: Schema.String })),
|
|
222
|
-
}),
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
const LegacyResourceIdSchema = Schema.Union([Schema.String, Schema.Number]);
|
|
226
|
-
|
|
227
|
-
const GraphQLProductVariantNodeSchema = Schema.Struct({
|
|
228
|
-
id: Schema.String,
|
|
229
|
-
legacyResourceId: LegacyResourceIdSchema,
|
|
230
|
-
title: Schema.String,
|
|
231
|
-
sku: Schema.NullOr(Schema.String),
|
|
232
|
-
barcode: Schema.NullOr(Schema.String),
|
|
233
|
-
price: Schema.String,
|
|
234
|
-
compareAtPrice: Schema.NullOr(Schema.String),
|
|
235
|
-
inventoryPolicy: ProductVariantInventoryPolicySchema,
|
|
236
|
-
taxable: Schema.Boolean,
|
|
237
|
-
createdAt: Schema.String,
|
|
238
|
-
updatedAt: Schema.String,
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const GraphQLProductNodeSchema = Schema.Struct({
|
|
242
|
-
id: Schema.String,
|
|
243
|
-
legacyResourceId: LegacyResourceIdSchema,
|
|
244
|
-
title: Schema.String,
|
|
245
|
-
handle: Schema.String,
|
|
246
|
-
descriptionHtml: Schema.String,
|
|
247
|
-
productType: Schema.String,
|
|
248
|
-
vendor: Schema.String,
|
|
249
|
-
status: ProductStatusSchema,
|
|
250
|
-
tags: Schema.Array(Schema.String),
|
|
251
|
-
createdAt: Schema.String,
|
|
252
|
-
updatedAt: Schema.String,
|
|
253
|
-
publishedAt: Schema.NullOr(Schema.String),
|
|
254
|
-
templateSuffix: Schema.NullOr(Schema.String),
|
|
255
|
-
featuredMedia: Schema.NullOr(Schema.Any),
|
|
256
|
-
options: Schema.Array(ProductOptionSchema),
|
|
257
|
-
variants: Schema.Struct({
|
|
258
|
-
nodes: Schema.Array(GraphQLProductVariantNodeSchema),
|
|
259
|
-
pageInfo: PageInfoSchema,
|
|
260
|
-
}),
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
const GraphQLProductsDataSchema = Schema.Struct({
|
|
264
|
-
products: Schema.Struct({
|
|
265
|
-
nodes: Schema.Array(GraphQLProductNodeSchema),
|
|
266
|
-
pageInfo: PageInfoSchema,
|
|
267
|
-
}),
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
const GraphQLProductByIdDataSchema = Schema.Struct({
|
|
271
|
-
product: Schema.NullOr(GraphQLProductNodeSchema),
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const GraphQLProductVariantsDataSchema = Schema.Struct({
|
|
275
|
-
product: Schema.NullOr(
|
|
276
|
-
Schema.Struct({
|
|
277
|
-
variants: Schema.Struct({
|
|
278
|
-
nodes: Schema.Array(GraphQLProductVariantNodeSchema),
|
|
279
|
-
pageInfo: PageInfoSchema,
|
|
280
|
-
}),
|
|
281
|
-
}),
|
|
282
|
-
),
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
type GraphQLProductsData = Schema.Schema.Type<typeof GraphQLProductsDataSchema>;
|
|
286
|
-
type GraphQLProductNode = Schema.Schema.Type<typeof GraphQLProductNodeSchema>;
|
|
287
|
-
type GraphQLProductVariantNode = Schema.Schema.Type<typeof GraphQLProductVariantNodeSchema>;
|
|
288
|
-
|
|
289
|
-
const normalizeProductNode = (
|
|
290
|
-
node: GraphQLProductNode,
|
|
291
|
-
variants: ReadonlyArray<GraphQLProductVariantNode>,
|
|
292
|
-
): Product => ({
|
|
293
|
-
id: node.id,
|
|
294
|
-
legacyResourceId: String(node.legacyResourceId),
|
|
295
|
-
title: node.title,
|
|
296
|
-
handle: node.handle,
|
|
297
|
-
descriptionHtml: node.descriptionHtml,
|
|
298
|
-
productType: node.productType,
|
|
299
|
-
vendor: node.vendor,
|
|
300
|
-
status: node.status,
|
|
301
|
-
tags: node.tags,
|
|
302
|
-
createdAt: node.createdAt,
|
|
303
|
-
updatedAt: node.updatedAt,
|
|
304
|
-
publishedAt: node.publishedAt,
|
|
305
|
-
templateSuffix: node.templateSuffix,
|
|
306
|
-
featuredMedia: node.featuredMedia,
|
|
307
|
-
options: node.options,
|
|
308
|
-
variants: variants.map((variant) => ({
|
|
309
|
-
...variant,
|
|
310
|
-
legacyResourceId: String(variant.legacyResourceId),
|
|
311
|
-
})),
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const graphqlEndpoint = (config: ShopifyConfig): string => {
|
|
315
|
-
const shopDomain = config.shopDomain.replace(/^https?:\/\//i, "").replace(/\/+$/g, "");
|
|
316
|
-
return `https://${shopDomain}/admin/api/${config.apiVersion}/graphql.json`;
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
const hasGraphqlErrors = (body: unknown): boolean => {
|
|
320
|
-
if (typeof body !== "object" || body === null) {
|
|
321
|
-
return false;
|
|
322
|
-
}
|
|
323
|
-
const errors = (body as { readonly errors?: unknown }).errors;
|
|
324
|
-
return Array.isArray(errors) && errors.length > 0;
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
const summarizeBody = (body: unknown): string => {
|
|
328
|
-
try {
|
|
329
|
-
return JSON.stringify(body);
|
|
330
|
-
} catch {
|
|
331
|
-
return String(body);
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
export const make = Effect.fnUntraced(function* (config: ShopifyConfig) {
|
|
336
|
-
const auth = yield* ShopifyAuth.ShopifyAuth;
|
|
337
|
-
const retryBaseDelay = Duration.millis(config.retryBaseDelayMs);
|
|
338
|
-
const graphqlRetryBaseDelay = Duration.millis(config.graphqlRetryBaseDelayMs);
|
|
339
|
-
const requestTimeout = Duration.seconds(config.requestTimeoutSeconds);
|
|
340
|
-
const transportRetrySchedule = Schedule.exponential(retryBaseDelay).pipe(
|
|
341
|
-
Schedule.jittered,
|
|
342
|
-
Schedule.upTo({ times: config.transportMaxRetries }),
|
|
343
|
-
Schedule.tap(() => Metrics.recordApiRetry({ connector: manifest.name, reason: "transport" })),
|
|
344
|
-
);
|
|
345
|
-
const client = (yield* HttpClient.HttpClient).pipe(
|
|
346
|
-
HttpClient.mapRequestEffect((request) =>
|
|
347
|
-
auth.get.pipe(
|
|
348
|
-
Effect.map((token) =>
|
|
349
|
-
HttpClientRequest.setHeader(request, "X-Shopify-Access-Token", Redacted.value(token)),
|
|
350
|
-
),
|
|
351
|
-
),
|
|
352
|
-
),
|
|
353
|
-
HttpClient.mapRequest(HttpClientRequest.acceptJson),
|
|
354
|
-
(c) =>
|
|
355
|
-
TransientRetry.withTransientRetry(c, {
|
|
356
|
-
maxRetries: config.responseMaxRetries,
|
|
357
|
-
baseDelay: retryBaseDelay,
|
|
358
|
-
retryAfterFallback: Duration.seconds(config.retryAfterFallbackSeconds),
|
|
359
|
-
}),
|
|
360
|
-
// HTTP responses are handled above, so this retries transport errors only.
|
|
361
|
-
HttpClient.retryTransient({
|
|
362
|
-
retryOn: "errors-only",
|
|
363
|
-
schedule: transportRetrySchedule,
|
|
364
|
-
}),
|
|
365
|
-
);
|
|
366
|
-
const endpoint = graphqlEndpoint(config);
|
|
367
|
-
|
|
368
|
-
const fetchGraphQL = <A>(options: {
|
|
369
|
-
readonly operationName: string;
|
|
370
|
-
readonly query: string;
|
|
371
|
-
readonly variables?: Record<string, unknown>;
|
|
372
|
-
readonly schema: Schema.Decoder<A>;
|
|
373
|
-
}): Effect.Effect<A, ConnectorError> => {
|
|
374
|
-
const attempt = (remainingRetries: number): Effect.Effect<A, ConnectorError> =>
|
|
375
|
-
Effect.gen(function* () {
|
|
376
|
-
const request = yield* HttpClientRequest.post(endpoint).pipe(
|
|
377
|
-
HttpClientRequest.bodyJson({
|
|
378
|
-
query: options.query,
|
|
379
|
-
variables: options.variables ?? {},
|
|
380
|
-
}),
|
|
381
|
-
Effect.mapError(
|
|
382
|
-
(cause) =>
|
|
383
|
-
new ConnectorError({ message: "Failed to encode Shopify GraphQL request", cause }),
|
|
384
|
-
),
|
|
385
|
-
);
|
|
386
|
-
|
|
387
|
-
const { body, status } = yield* Effect.scoped(
|
|
388
|
-
client.execute(request).pipe(
|
|
389
|
-
Effect.tapError((error) => Telemetry.annotateError("api_http", error)),
|
|
390
|
-
Effect.mapError((error) => {
|
|
391
|
-
if (error._tag === "ShopifyAuthError") {
|
|
392
|
-
return new ConnectorError({ message: error.message, cause: error });
|
|
393
|
-
}
|
|
394
|
-
return new ConnectorError({
|
|
395
|
-
message: "Shopify GraphQL request failed",
|
|
396
|
-
cause: error,
|
|
397
|
-
});
|
|
398
|
-
}),
|
|
399
|
-
Effect.flatMap((response) =>
|
|
400
|
-
response.json.pipe(
|
|
401
|
-
Effect.tapError((error) => Telemetry.annotateError("api_json", error)),
|
|
402
|
-
Effect.mapError(
|
|
403
|
-
(error) =>
|
|
404
|
-
new ConnectorError({
|
|
405
|
-
message: "Shopify GraphQL returned invalid JSON",
|
|
406
|
-
cause: error,
|
|
407
|
-
}),
|
|
408
|
-
),
|
|
409
|
-
Effect.map((body) => ({
|
|
410
|
-
body,
|
|
411
|
-
status: response.status,
|
|
412
|
-
})),
|
|
413
|
-
),
|
|
414
|
-
),
|
|
415
|
-
),
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
if (status < 200 || status >= 300) {
|
|
419
|
-
const error = { status, body, operationName: options.operationName };
|
|
420
|
-
yield* Effect.logWarning("Shopify GraphQL returned non-2xx status").pipe(
|
|
421
|
-
Effect.annotateLogs({
|
|
422
|
-
operationName: options.operationName,
|
|
423
|
-
status,
|
|
424
|
-
body: summarizeBody(body),
|
|
425
|
-
}),
|
|
426
|
-
);
|
|
427
|
-
yield* Telemetry.annotateError("api_status", error);
|
|
428
|
-
return yield* Effect.fail(
|
|
429
|
-
new ConnectorError({
|
|
430
|
-
message: "Shopify GraphQL returned non-2xx status",
|
|
431
|
-
cause: error,
|
|
432
|
-
}),
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (hasGraphqlErrors(body)) {
|
|
437
|
-
const retryableCode = Throttle.retryableErrorCode(body);
|
|
438
|
-
if (retryableCode !== undefined && remainingRetries > 0) {
|
|
439
|
-
const attemptIndex = config.graphqlMaxRetries - remainingRetries;
|
|
440
|
-
const wait = yield* Throttle.retryDelay(retryableCode, body, attemptIndex, {
|
|
441
|
-
baseDelay: graphqlRetryBaseDelay,
|
|
442
|
-
});
|
|
443
|
-
yield* Effect.logWarning(
|
|
444
|
-
"Shopify GraphQL request returned a retryable error, retrying",
|
|
445
|
-
).pipe(
|
|
446
|
-
Effect.annotateLogs({
|
|
447
|
-
operationName: options.operationName,
|
|
448
|
-
code: retryableCode,
|
|
449
|
-
remainingRetries,
|
|
450
|
-
waitMillis: Duration.toMillis(wait),
|
|
451
|
-
}),
|
|
452
|
-
);
|
|
453
|
-
yield* Metrics.recordApiRetry({
|
|
454
|
-
connector: manifest.name,
|
|
455
|
-
reason: retryableCode === "THROTTLED" ? "rate_limit" : "server_error",
|
|
456
|
-
});
|
|
457
|
-
yield* Effect.sleep(wait);
|
|
458
|
-
return yield* attempt(remainingRetries - 1);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
yield* Effect.logWarning("Shopify GraphQL returned errors").pipe(
|
|
462
|
-
Effect.annotateLogs({
|
|
463
|
-
operationName: options.operationName,
|
|
464
|
-
body: summarizeBody(body),
|
|
465
|
-
}),
|
|
466
|
-
);
|
|
467
|
-
yield* Telemetry.annotateError("api_graphql", body);
|
|
468
|
-
return yield* Effect.fail(
|
|
469
|
-
new ConnectorError({ message: "Shopify GraphQL returned errors", cause: body }),
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const data =
|
|
474
|
-
typeof body === "object" && body !== null ? (body as { data?: unknown }).data : undefined;
|
|
475
|
-
return yield* Schema.decodeUnknownEffect(options.schema)(data).pipe(
|
|
476
|
-
Effect.tapError((error) => Telemetry.annotateError("api_decode", error)),
|
|
477
|
-
Effect.mapError(
|
|
478
|
-
(error) =>
|
|
479
|
-
new ConnectorError({
|
|
480
|
-
message: "Shopify GraphQL response schema decode failed",
|
|
481
|
-
cause: error,
|
|
482
|
-
}),
|
|
483
|
-
),
|
|
484
|
-
);
|
|
485
|
-
}).pipe(
|
|
486
|
-
Effect.withSpan(Telemetry.SpanName.apiFetch, {
|
|
487
|
-
kind: "client",
|
|
488
|
-
attributes: { [Telemetry.Attr.apiPath]: `graphql:${options.operationName}` },
|
|
489
|
-
}),
|
|
490
|
-
);
|
|
491
|
-
|
|
492
|
-
return attempt(config.graphqlMaxRetries).pipe(
|
|
493
|
-
Effect.timeout(requestTimeout),
|
|
494
|
-
Effect.mapError((error) =>
|
|
495
|
-
error instanceof ConnectorError
|
|
496
|
-
? error
|
|
497
|
-
: new ConnectorError({ message: "Shopify GraphQL request timed out", cause: error }),
|
|
498
|
-
),
|
|
499
|
-
);
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
const nextPageCursor = (
|
|
503
|
-
pageInfo: PageInfo,
|
|
504
|
-
): Effect.Effect<Option.Option<string>, ConnectorError> => {
|
|
505
|
-
if (!pageInfo.hasNextPage) {
|
|
506
|
-
return Effect.succeed(Option.none());
|
|
507
|
-
}
|
|
508
|
-
if (pageInfo.endCursor === null) {
|
|
509
|
-
return Effect.fail(
|
|
510
|
-
new ConnectorError({
|
|
511
|
-
message: "Shopify GraphQL pageInfo.endCursor is required when hasNextPage is true",
|
|
512
|
-
}),
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
return Effect.succeed(Option.some(pageInfo.endCursor));
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
const fetchRemainingProductVariants = (
|
|
519
|
-
productId: string,
|
|
520
|
-
initialCursor: string,
|
|
521
|
-
): Effect.Effect<ReadonlyArray<GraphQLProductVariantNode>, ConnectorError> =>
|
|
522
|
-
Stream.paginate(initialCursor, (after) =>
|
|
523
|
-
fetchGraphQL({
|
|
524
|
-
operationName: "AirfoilProductVariants",
|
|
525
|
-
query: ProductVariantsQuery,
|
|
526
|
-
variables: { id: productId, first: 100, after },
|
|
527
|
-
schema: GraphQLProductVariantsDataSchema,
|
|
528
|
-
}).pipe(
|
|
529
|
-
Effect.flatMap((data) => {
|
|
530
|
-
if (data.product === null) {
|
|
531
|
-
return Effect.fail(
|
|
532
|
-
new ConnectorError({
|
|
533
|
-
message: "Shopify product disappeared while fetching variants",
|
|
534
|
-
}),
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
const variants = data.product.variants;
|
|
538
|
-
return nextPageCursor(variants.pageInfo).pipe(
|
|
539
|
-
Effect.map((cursor) => [variants.nodes, cursor] as const),
|
|
540
|
-
);
|
|
541
|
-
}),
|
|
542
|
-
),
|
|
543
|
-
).pipe(Stream.runCollect);
|
|
544
|
-
|
|
545
|
-
const loadProductVariants = (
|
|
546
|
-
node: GraphQLProductNode,
|
|
547
|
-
): Effect.Effect<ReadonlyArray<GraphQLProductVariantNode>, ConnectorError> =>
|
|
548
|
-
nextPageCursor(node.variants.pageInfo).pipe(
|
|
549
|
-
Effect.flatMap(
|
|
550
|
-
Option.match({
|
|
551
|
-
onNone: () => Effect.succeed(node.variants.nodes),
|
|
552
|
-
onSome: (cursor) =>
|
|
553
|
-
fetchRemainingProductVariants(node.id, cursor).pipe(
|
|
554
|
-
Effect.map((remaining) => [...node.variants.nodes, ...remaining]),
|
|
555
|
-
),
|
|
556
|
-
}),
|
|
557
|
-
),
|
|
558
|
-
);
|
|
559
|
-
|
|
560
|
-
const fetchProducts = (options: {
|
|
561
|
-
readonly first: number;
|
|
562
|
-
readonly after?: string;
|
|
563
|
-
}): Effect.Effect<ShopifyProductPage, ConnectorError> =>
|
|
564
|
-
fetchGraphQL({
|
|
565
|
-
operationName: "AirfoilProducts",
|
|
566
|
-
query: ProductsQuery,
|
|
567
|
-
variables: { first: options.first, after: options.after ?? null },
|
|
568
|
-
schema: GraphQLProductsDataSchema,
|
|
569
|
-
}).pipe(
|
|
570
|
-
Effect.flatMap((data: GraphQLProductsData) =>
|
|
571
|
-
nextPageCursor(data.products.pageInfo).pipe(
|
|
572
|
-
Effect.andThen(
|
|
573
|
-
Effect.forEach(data.products.nodes, (node) =>
|
|
574
|
-
loadProductVariants(node).pipe(
|
|
575
|
-
Effect.map((variants) => normalizeProductNode(node, variants)),
|
|
576
|
-
),
|
|
577
|
-
),
|
|
578
|
-
),
|
|
579
|
-
Effect.map((items) => ({
|
|
580
|
-
items,
|
|
581
|
-
endCursor: data.products.pageInfo.endCursor,
|
|
582
|
-
hasMore: data.products.pageInfo.hasNextPage,
|
|
583
|
-
})),
|
|
584
|
-
),
|
|
585
|
-
),
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
const fetchProductById = (id: string): Effect.Effect<Product, ConnectorError> =>
|
|
589
|
-
fetchGraphQL({
|
|
590
|
-
operationName: "AirfoilProductById",
|
|
591
|
-
query: ProductByIdQuery,
|
|
592
|
-
variables: { id },
|
|
593
|
-
schema: GraphQLProductByIdDataSchema,
|
|
594
|
-
}).pipe(
|
|
595
|
-
Effect.flatMap((data) => {
|
|
596
|
-
const node = data.product;
|
|
597
|
-
if (node === null) {
|
|
598
|
-
return Effect.fail(
|
|
599
|
-
new ConnectorError({
|
|
600
|
-
message: "Shopify product not found while refreshing from webhook",
|
|
601
|
-
}),
|
|
602
|
-
);
|
|
603
|
-
}
|
|
604
|
-
return loadProductVariants(node).pipe(
|
|
605
|
-
Effect.map((variants) => normalizeProductNode(node, variants)),
|
|
606
|
-
);
|
|
607
|
-
}),
|
|
608
|
-
);
|
|
609
|
-
|
|
610
|
-
const checkConnection = fetchGraphQL({
|
|
611
|
-
operationName: "AirfoilShopIdentity",
|
|
612
|
-
query: ShopIdentityQuery,
|
|
613
|
-
schema: ShopIdentitySchema,
|
|
614
|
-
}).pipe(Effect.asVoid);
|
|
615
|
-
|
|
616
|
-
const checkProductsAccess = fetchGraphQL({
|
|
617
|
-
operationName: "AirfoilProductsAccess",
|
|
618
|
-
query: ProductsAccessQuery,
|
|
619
|
-
schema: ProductsAccessSchema,
|
|
620
|
-
}).pipe(Effect.asVoid);
|
|
621
|
-
|
|
622
|
-
return { checkConnection, checkProductsAccess, fetchGraphQL, fetchProducts, fetchProductById };
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
export const layer = (
|
|
626
|
-
config: ShopifyConfig,
|
|
627
|
-
): Layer.Layer<ShopifyApiClient, ConnectorError, HttpClient.HttpClient | ShopifyAuth.ShopifyAuth> =>
|
|
628
|
-
Layer.effect(ShopifyApiClient)(make(config));
|
|
629
|
-
|
|
630
|
-
export const layerConfig = (
|
|
631
|
-
config: Config.Wrap<ShopifyConfig>,
|
|
632
|
-
): Layer.Layer<
|
|
633
|
-
ShopifyApiClient,
|
|
634
|
-
ConnectorError | Config.ConfigError,
|
|
635
|
-
HttpClient.HttpClient | ShopifyAuth.ShopifyAuth
|
|
636
|
-
> => Layer.effect(ShopifyApiClient)(Config.unwrap(config).pipe(Effect.flatMap(make)));
|