@useairfoil/producer-shopify 0.0.1 → 0.2.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.
- package/README.md +255 -0
- package/dist/chunk-Bp6m_JJh.js +13 -0
- package/dist/index.d.ts +965 -0
- package/dist/index.js +4 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +49 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest-BFfx2LkS.js +91 -0
- package/dist/manifest-BFfx2LkS.js.map +1 -0
- package/dist/manifest-CPOEExQU.d.ts +34 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +3 -0
- package/dist/src-DqmiLrYS.js +984 -0
- package/dist/src-DqmiLrYS.js.map +1 -0
- package/package.json +45 -7
- package/src/api.ts +636 -0
- package/src/auth.ts +116 -0
- package/src/connector.ts +268 -0
- package/src/index.ts +36 -0
- package/src/main.ts +20 -0
- package/src/manifest.ts +86 -0
- package/src/sandbox.ts +38 -0
- package/src/schemas.ts +319 -0
- package/src/start.ts +52 -0
- package/src/throttle.ts +89 -0
- package/src/transient-retry.ts +74 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
import { t as __export } from "./chunk-Bp6m_JJh.js";
|
|
2
|
+
import { n as manifest, t as ShopifyConfigDef } from "./manifest-BFfx2LkS.js";
|
|
3
|
+
import { Auth, Connector, ConnectorError, Cursor, Fetch, Metrics, Resource, Telemetry, Webhook } from "@useairfoil/connector-kit";
|
|
4
|
+
import { Clock, Config, Context, Data, Duration, Effect, Layer, Option, Random, Redacted, Schedule, Schema, Stream } from "effect";
|
|
5
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse, HttpServerResponse } from "effect/unstable/http";
|
|
6
|
+
import * as Schema$1 from "effect/Schema";
|
|
7
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
//#region src/auth.ts
|
|
10
|
+
var ShopifyAuthError = class extends Data.TaggedError("ShopifyAuthError") {};
|
|
11
|
+
var ShopifyAuth = class extends Context.Service()("@useairfoil/producer-shopify/ShopifyAuth") {};
|
|
12
|
+
const TokenResponseSchema = Schema.Struct({
|
|
13
|
+
access_token: Schema.String,
|
|
14
|
+
expires_in: Schema.Number
|
|
15
|
+
});
|
|
16
|
+
const ErrorResponseSchema = Schema.Struct({ error: Schema.optional(Schema.String) });
|
|
17
|
+
const normalizedShopDomain = (shopDomain) => shopDomain.replace(/^https?:\/\//i, "").replace(/\/+$/g, "");
|
|
18
|
+
const errorFor = (reason) => {
|
|
19
|
+
switch (reason) {
|
|
20
|
+
case "shop_not_permitted": return new ShopifyAuthError({
|
|
21
|
+
reason,
|
|
22
|
+
message: "The Shopify app and store must belong to the same Shopify organization"
|
|
23
|
+
});
|
|
24
|
+
case "invalid_client": return new ShopifyAuthError({
|
|
25
|
+
reason,
|
|
26
|
+
message: "The Shopify client ID or client secret is invalid"
|
|
27
|
+
});
|
|
28
|
+
case "exchange_failed": return new ShopifyAuthError({
|
|
29
|
+
reason,
|
|
30
|
+
message: "Shopify authentication failed"
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const decodeRejectedResponse = (response) => HttpClientResponse.schemaBodyJson(ErrorResponseSchema)(response).pipe(Effect.map((body) => {
|
|
35
|
+
switch (body.error) {
|
|
36
|
+
case "shop_not_permitted": return "shop_not_permitted";
|
|
37
|
+
case "invalid_client": return "invalid_client";
|
|
38
|
+
default: return "exchange_failed";
|
|
39
|
+
}
|
|
40
|
+
}), Effect.catch(() => Effect.succeed("exchange_failed")));
|
|
41
|
+
const make$2 = Effect.fnUntraced(function* (config) {
|
|
42
|
+
const client = yield* HttpClient.HttpClient;
|
|
43
|
+
const endpoint = `https://${normalizedShopDomain(config.shopDomain)}/admin/oauth/access_token`;
|
|
44
|
+
return yield* Auth.makeTokenCache({ acquire: Effect.scoped(Effect.gen(function* () {
|
|
45
|
+
const request = HttpClientRequest.post(endpoint).pipe(HttpClientRequest.bodyUrlParams({
|
|
46
|
+
grant_type: "client_credentials",
|
|
47
|
+
client_id: config.clientId,
|
|
48
|
+
client_secret: Redacted.value(config.clientSecret)
|
|
49
|
+
}));
|
|
50
|
+
const response = yield* client.execute(request).pipe(Effect.mapError(() => errorFor("exchange_failed")));
|
|
51
|
+
const successfulResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.catchTag("HttpClientError", () => decodeRejectedResponse(response).pipe(Effect.flatMap((reason) => Effect.fail(errorFor(reason))))));
|
|
52
|
+
const body = yield* HttpClientResponse.schemaBodyJson(TokenResponseSchema)(successfulResponse).pipe(Effect.mapError(() => errorFor("exchange_failed")));
|
|
53
|
+
return {
|
|
54
|
+
value: Redacted.make(body.access_token),
|
|
55
|
+
expiresIn: Duration.seconds(body.expires_in)
|
|
56
|
+
};
|
|
57
|
+
})) });
|
|
58
|
+
});
|
|
59
|
+
const layer$2 = (config) => Layer.effect(ShopifyAuth)(make$2(config));
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/schemas.ts
|
|
63
|
+
const PageInfoSchema = Schema$1.Struct({
|
|
64
|
+
hasNextPage: Schema$1.Boolean,
|
|
65
|
+
endCursor: Schema$1.NullOr(Schema$1.String)
|
|
66
|
+
});
|
|
67
|
+
const ProductStatusSchema = Schema$1.Literals([
|
|
68
|
+
"ACTIVE",
|
|
69
|
+
"ARCHIVED",
|
|
70
|
+
"DRAFT",
|
|
71
|
+
"UNLISTED"
|
|
72
|
+
]);
|
|
73
|
+
const RestProductStatusSchema = Schema$1.Literals([
|
|
74
|
+
"active",
|
|
75
|
+
"archived",
|
|
76
|
+
"draft"
|
|
77
|
+
]);
|
|
78
|
+
const ProductVariantInventoryPolicySchema = Schema$1.Literals(["CONTINUE", "DENY"]);
|
|
79
|
+
const RestProductVariantInventoryPolicySchema = Schema$1.Literals(["continue", "deny"]);
|
|
80
|
+
const ProductFeaturedMediaSchema = Schema$1.Any;
|
|
81
|
+
const ProductOptionSchema = Schema$1.Struct({
|
|
82
|
+
id: Schema$1.String,
|
|
83
|
+
name: Schema$1.String,
|
|
84
|
+
position: Schema$1.Number,
|
|
85
|
+
values: Schema$1.Array(Schema$1.String)
|
|
86
|
+
});
|
|
87
|
+
const ProductVariantSchema = Schema$1.Struct({
|
|
88
|
+
id: Schema$1.String,
|
|
89
|
+
legacyResourceId: Schema$1.String,
|
|
90
|
+
title: Schema$1.String,
|
|
91
|
+
sku: Schema$1.NullOr(Schema$1.String),
|
|
92
|
+
barcode: Schema$1.NullOr(Schema$1.String),
|
|
93
|
+
price: Schema$1.String,
|
|
94
|
+
compareAtPrice: Schema$1.NullOr(Schema$1.String),
|
|
95
|
+
inventoryPolicy: ProductVariantInventoryPolicySchema,
|
|
96
|
+
taxable: Schema$1.Boolean,
|
|
97
|
+
createdAt: Schema$1.String,
|
|
98
|
+
updatedAt: Schema$1.String
|
|
99
|
+
});
|
|
100
|
+
const ProductWebhookImageSchema = Schema$1.Struct({
|
|
101
|
+
src: Schema$1.String,
|
|
102
|
+
alt: Schema$1.NullOr(Schema$1.String)
|
|
103
|
+
});
|
|
104
|
+
const ProductWebhookOptionSchema = Schema$1.Struct({
|
|
105
|
+
id: Schema$1.Number,
|
|
106
|
+
name: Schema$1.String,
|
|
107
|
+
position: Schema$1.Number,
|
|
108
|
+
values: Schema$1.Array(Schema$1.String)
|
|
109
|
+
});
|
|
110
|
+
const ProductWebhookVariantSchema = Schema$1.Struct({
|
|
111
|
+
admin_graphql_api_id: Schema$1.String,
|
|
112
|
+
id: Schema$1.Number,
|
|
113
|
+
title: Schema$1.String,
|
|
114
|
+
price: Schema$1.Union([Schema$1.String, Schema$1.Number]),
|
|
115
|
+
inventory_policy: RestProductVariantInventoryPolicySchema,
|
|
116
|
+
compare_at_price: Schema$1.NullOr(Schema$1.Union([Schema$1.String, Schema$1.Number])),
|
|
117
|
+
created_at: Schema$1.String,
|
|
118
|
+
updated_at: Schema$1.String,
|
|
119
|
+
taxable: Schema$1.Boolean,
|
|
120
|
+
barcode: Schema$1.NullOr(Schema$1.String),
|
|
121
|
+
sku: Schema$1.NullOr(Schema$1.String)
|
|
122
|
+
});
|
|
123
|
+
const ProductWebhookVariantGidSchema = Schema$1.Struct({
|
|
124
|
+
admin_graphql_api_id: Schema$1.String,
|
|
125
|
+
updated_at: Schema$1.String
|
|
126
|
+
});
|
|
127
|
+
const ProductSchema = Schema$1.Struct({
|
|
128
|
+
id: Schema$1.String,
|
|
129
|
+
legacyResourceId: Schema$1.String,
|
|
130
|
+
title: Schema$1.String,
|
|
131
|
+
handle: Schema$1.String,
|
|
132
|
+
descriptionHtml: Schema$1.String,
|
|
133
|
+
productType: Schema$1.String,
|
|
134
|
+
vendor: Schema$1.String,
|
|
135
|
+
status: ProductStatusSchema,
|
|
136
|
+
tags: Schema$1.Array(Schema$1.String),
|
|
137
|
+
createdAt: Schema$1.String,
|
|
138
|
+
updatedAt: Schema$1.String,
|
|
139
|
+
publishedAt: Schema$1.NullOr(Schema$1.String),
|
|
140
|
+
templateSuffix: Schema$1.NullOr(Schema$1.String),
|
|
141
|
+
featuredMedia: Schema$1.NullOr(ProductFeaturedMediaSchema),
|
|
142
|
+
options: Schema$1.Array(ProductOptionSchema),
|
|
143
|
+
variants: Schema$1.Array(ProductVariantSchema)
|
|
144
|
+
});
|
|
145
|
+
const ProductWebhookPayloadSchema = Schema$1.Struct({
|
|
146
|
+
id: Schema$1.Number,
|
|
147
|
+
admin_graphql_api_id: Schema$1.String,
|
|
148
|
+
body_html: Schema$1.NullOr(Schema$1.String),
|
|
149
|
+
created_at: Schema$1.NullOr(Schema$1.String),
|
|
150
|
+
handle: Schema$1.String,
|
|
151
|
+
image: Schema$1.optional(Schema$1.NullOr(ProductWebhookImageSchema)),
|
|
152
|
+
images: Schema$1.optional(Schema$1.Array(Schema$1.Any)),
|
|
153
|
+
options: Schema$1.Array(ProductWebhookOptionSchema),
|
|
154
|
+
product_type: Schema$1.String,
|
|
155
|
+
published_at: Schema$1.NullOr(Schema$1.String),
|
|
156
|
+
published_scope: Schema$1.optional(Schema$1.String),
|
|
157
|
+
status: RestProductStatusSchema,
|
|
158
|
+
tags: Schema$1.String,
|
|
159
|
+
template_suffix: Schema$1.NullOr(Schema$1.String),
|
|
160
|
+
title: Schema$1.String,
|
|
161
|
+
updated_at: Schema$1.String,
|
|
162
|
+
variants: Schema$1.Array(ProductWebhookVariantSchema),
|
|
163
|
+
variant_gids: Schema$1.Array(ProductWebhookVariantGidSchema),
|
|
164
|
+
vendor: Schema$1.String
|
|
165
|
+
});
|
|
166
|
+
const ProductDeleteWebhookPayloadSchema = Schema$1.Struct({ id: Schema$1.Number });
|
|
167
|
+
const ProductEventSchema = Schema$1.Union([Schema$1.Struct({
|
|
168
|
+
_tag: Schema$1.Literal("upsert"),
|
|
169
|
+
payload: ProductWebhookPayloadSchema
|
|
170
|
+
}), Schema$1.Struct({
|
|
171
|
+
_tag: Schema$1.Literal("delete"),
|
|
172
|
+
id: Schema$1.String,
|
|
173
|
+
version: Schema$1.String
|
|
174
|
+
})]);
|
|
175
|
+
const MoneySchema = Schema$1.Struct({
|
|
176
|
+
amount: Schema$1.String,
|
|
177
|
+
currency_code: Schema$1.String
|
|
178
|
+
});
|
|
179
|
+
const MoneyBagSchema = Schema$1.Struct({
|
|
180
|
+
shop_money: MoneySchema,
|
|
181
|
+
presentment_money: MoneySchema
|
|
182
|
+
});
|
|
183
|
+
const CartLineItemSchema = Schema$1.Struct({
|
|
184
|
+
id: Schema$1.Union([Schema$1.String, Schema$1.Number]),
|
|
185
|
+
properties: Schema$1.NullOr(Schema$1.Unknown),
|
|
186
|
+
quantity: Schema$1.Number,
|
|
187
|
+
variant_id: Schema$1.Union([Schema$1.String, Schema$1.Number]),
|
|
188
|
+
key: Schema$1.String,
|
|
189
|
+
discounted_price: Schema$1.String,
|
|
190
|
+
discounts: Schema$1.Array(Schema$1.Unknown),
|
|
191
|
+
gift_card: Schema$1.Boolean,
|
|
192
|
+
grams: Schema$1.Number,
|
|
193
|
+
line_price: Schema$1.String,
|
|
194
|
+
original_line_price: Schema$1.String,
|
|
195
|
+
original_price: Schema$1.String,
|
|
196
|
+
price: Schema$1.String,
|
|
197
|
+
product_id: Schema$1.Union([Schema$1.String, Schema$1.Number]),
|
|
198
|
+
sku: Schema$1.NullOr(Schema$1.String),
|
|
199
|
+
taxable: Schema$1.Boolean,
|
|
200
|
+
title: Schema$1.String,
|
|
201
|
+
total_discount: Schema$1.String,
|
|
202
|
+
vendor: Schema$1.String,
|
|
203
|
+
discounted_price_set: MoneyBagSchema,
|
|
204
|
+
line_price_set: MoneyBagSchema,
|
|
205
|
+
original_line_price_set: MoneyBagSchema,
|
|
206
|
+
price_set: MoneyBagSchema,
|
|
207
|
+
total_discount_set: MoneyBagSchema,
|
|
208
|
+
parent_relationship: Schema$1.NullOr(Schema$1.Unknown)
|
|
209
|
+
});
|
|
210
|
+
const CartWebhookPayloadSchema = Schema$1.Struct({
|
|
211
|
+
id: Schema$1.String,
|
|
212
|
+
token: Schema$1.String,
|
|
213
|
+
line_items: Schema$1.Array(CartLineItemSchema),
|
|
214
|
+
note: Schema$1.NullOr(Schema$1.String),
|
|
215
|
+
updated_at: Schema$1.String,
|
|
216
|
+
created_at: Schema$1.String
|
|
217
|
+
});
|
|
218
|
+
const CartEventSchema = Schema$1.Struct({
|
|
219
|
+
id: Schema$1.String,
|
|
220
|
+
token: Schema$1.String,
|
|
221
|
+
topic: Schema$1.Literals(["carts/create", "carts/update"]),
|
|
222
|
+
lineItems: Schema$1.Array(CartLineItemSchema),
|
|
223
|
+
note: Schema$1.NullOr(Schema$1.String),
|
|
224
|
+
updatedAt: Schema$1.String,
|
|
225
|
+
createdAt: Schema$1.String
|
|
226
|
+
});
|
|
227
|
+
const WebhookPayloadSchema = Schema$1.Unknown;
|
|
228
|
+
const splitTags = (value) => {
|
|
229
|
+
if (value.trim() === "") return [];
|
|
230
|
+
return value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
|
|
231
|
+
};
|
|
232
|
+
const normalizeProductStatus = (status) => {
|
|
233
|
+
switch (status) {
|
|
234
|
+
case "active": return "ACTIVE";
|
|
235
|
+
case "archived": return "ARCHIVED";
|
|
236
|
+
case "draft": return "DRAFT";
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const normalizeInventoryPolicy = (policy) => {
|
|
240
|
+
switch (policy) {
|
|
241
|
+
case "continue": return "CONTINUE";
|
|
242
|
+
case "deny": return "DENY";
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const normalizeProductOption = (restOption) => {
|
|
246
|
+
return {
|
|
247
|
+
id: `gid://shopify/ProductOption/${restOption.id}`,
|
|
248
|
+
name: restOption.name,
|
|
249
|
+
position: restOption.position,
|
|
250
|
+
values: restOption.values
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
const normalizeProductVariant = (restVariant) => {
|
|
254
|
+
return {
|
|
255
|
+
id: restVariant.admin_graphql_api_id,
|
|
256
|
+
legacyResourceId: String(restVariant.id),
|
|
257
|
+
title: restVariant.title,
|
|
258
|
+
sku: restVariant.sku,
|
|
259
|
+
barcode: restVariant.barcode,
|
|
260
|
+
price: String(restVariant.price),
|
|
261
|
+
compareAtPrice: restVariant.compare_at_price === null ? null : String(restVariant.compare_at_price),
|
|
262
|
+
inventoryPolicy: normalizeInventoryPolicy(restVariant.inventory_policy),
|
|
263
|
+
taxable: restVariant.taxable,
|
|
264
|
+
createdAt: restVariant.created_at,
|
|
265
|
+
updatedAt: restVariant.updated_at
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
const normalizeProductFeaturedMedia = (image) => {
|
|
269
|
+
if (image === null || image === void 0) return null;
|
|
270
|
+
return { image: {
|
|
271
|
+
url: image.src,
|
|
272
|
+
altText: image.alt
|
|
273
|
+
} };
|
|
274
|
+
};
|
|
275
|
+
const ShopifyNormalize = {
|
|
276
|
+
productWebhook: (payload) => ({
|
|
277
|
+
id: payload.admin_graphql_api_id,
|
|
278
|
+
legacyResourceId: String(payload.id),
|
|
279
|
+
title: payload.title,
|
|
280
|
+
handle: payload.handle,
|
|
281
|
+
descriptionHtml: payload.body_html ?? "",
|
|
282
|
+
productType: payload.product_type,
|
|
283
|
+
vendor: payload.vendor,
|
|
284
|
+
status: normalizeProductStatus(payload.status),
|
|
285
|
+
tags: splitTags(payload.tags),
|
|
286
|
+
createdAt: payload.created_at,
|
|
287
|
+
updatedAt: payload.updated_at,
|
|
288
|
+
publishedAt: payload.published_at,
|
|
289
|
+
templateSuffix: payload.template_suffix,
|
|
290
|
+
featuredMedia: normalizeProductFeaturedMedia(payload.image),
|
|
291
|
+
options: payload.options.map(normalizeProductOption),
|
|
292
|
+
variants: payload.variants.map(normalizeProductVariant)
|
|
293
|
+
}),
|
|
294
|
+
cartWebhook: (payload, topic) => ({
|
|
295
|
+
id: payload.id,
|
|
296
|
+
token: payload.token,
|
|
297
|
+
topic,
|
|
298
|
+
lineItems: payload.line_items,
|
|
299
|
+
note: payload.note,
|
|
300
|
+
updatedAt: payload.updated_at,
|
|
301
|
+
createdAt: payload.created_at
|
|
302
|
+
})
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/throttle.ts
|
|
307
|
+
const ThrottleStatusSchema = Schema.Struct({
|
|
308
|
+
maximumAvailable: Schema.Number,
|
|
309
|
+
currentlyAvailable: Schema.Number,
|
|
310
|
+
restoreRate: Schema.Number
|
|
311
|
+
});
|
|
312
|
+
const GraphQLErrorSchema = Schema.Struct({ extensions: Schema.optional(Schema.Struct({ code: Schema.optional(Schema.String) })) });
|
|
313
|
+
const GraphQLEnvelopeSchema = Schema.Struct({
|
|
314
|
+
errors: Schema.optional(Schema.Array(GraphQLErrorSchema)),
|
|
315
|
+
extensions: Schema.optional(Schema.Struct({ cost: Schema.optional(Schema.Struct({
|
|
316
|
+
requestedQueryCost: Schema.optional(Schema.Number),
|
|
317
|
+
throttleStatus: Schema.optional(ThrottleStatusSchema)
|
|
318
|
+
})) }))
|
|
319
|
+
});
|
|
320
|
+
const decodeEnvelope = Schema.decodeUnknownOption(GraphQLEnvelopeSchema);
|
|
321
|
+
const readThrottleCost = (body) => decodeEnvelope(body).pipe(Option.flatMap((envelope) => {
|
|
322
|
+
const cost = envelope.extensions?.cost;
|
|
323
|
+
const status = cost?.throttleStatus;
|
|
324
|
+
const requestedQueryCost = cost?.requestedQueryCost;
|
|
325
|
+
return status === void 0 || requestedQueryCost === void 0 ? Option.none() : Option.some({
|
|
326
|
+
requestedQueryCost,
|
|
327
|
+
status
|
|
328
|
+
});
|
|
329
|
+
}), Option.getOrUndefined);
|
|
330
|
+
const retryableCodes = new Set(["THROTTLED", "INTERNAL_SERVER_ERROR"]);
|
|
331
|
+
/** Retries only when every GraphQL error is transient. */
|
|
332
|
+
const retryableErrorCode = (body) => decodeEnvelope(body).pipe(Option.flatMap((envelope) => {
|
|
333
|
+
const codes = envelope.errors?.map((error) => error.extensions?.code) ?? [];
|
|
334
|
+
if (!(codes.length > 0 && codes.every((code) => code !== void 0 && retryableCodes.has(code)))) return Option.none();
|
|
335
|
+
return Option.some(codes.includes("THROTTLED") ? "THROTTLED" : "INTERNAL_SERVER_ERROR");
|
|
336
|
+
}), Option.getOrUndefined);
|
|
337
|
+
const backoff$1 = (baseDelay, attempt) => Random.nextBetween(.8, 1.2).pipe(Effect.map((jitter) => Duration.times(baseDelay, 2 ** attempt * jitter)));
|
|
338
|
+
/** Uses Shopify's cost deficit for throttling and backoff for internal errors. */
|
|
339
|
+
const retryDelay = (code, body, attempt, options) => {
|
|
340
|
+
if (code === "INTERNAL_SERVER_ERROR") return backoff$1(options.baseDelay, attempt);
|
|
341
|
+
const cost = readThrottleCost(body);
|
|
342
|
+
if (cost === void 0 || cost.status.restoreRate <= 0) return backoff$1(options.baseDelay, attempt);
|
|
343
|
+
const deficitMillis = Math.max(0, cost.requestedQueryCost - cost.status.currentlyAvailable) / cost.status.restoreRate * 1e3;
|
|
344
|
+
return Effect.succeed(Duration.max(Duration.millis(Math.ceil(deficitMillis)), options.baseDelay));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/transient-retry.ts
|
|
349
|
+
const parseRetryAfter = (header, atMillis) => {
|
|
350
|
+
if (header === void 0 || header.trim() === "") return void 0;
|
|
351
|
+
const seconds = Number(header);
|
|
352
|
+
if (Number.isFinite(seconds)) return Duration.seconds(Math.max(seconds, 0));
|
|
353
|
+
const parsedDate = Date.parse(header);
|
|
354
|
+
return Number.isNaN(parsedDate) ? void 0 : Duration.millis(Math.max(parsedDate - atMillis, 0));
|
|
355
|
+
};
|
|
356
|
+
const otherTransientStatuses = new Set([
|
|
357
|
+
408,
|
|
358
|
+
500,
|
|
359
|
+
502,
|
|
360
|
+
503,
|
|
361
|
+
504
|
|
362
|
+
]);
|
|
363
|
+
const backoff = (baseDelay, attempt) => Random.nextBetween(.8, 1.2).pipe(Effect.map((jitter) => Duration.times(baseDelay, 2 ** attempt * jitter)));
|
|
364
|
+
const delayFor = (response, attempt, options) => {
|
|
365
|
+
if (response.status !== 429) return backoff(options.baseDelay, attempt);
|
|
366
|
+
return Clock.currentTimeMillis.pipe(Effect.map((now) => parseRetryAfter(response.headers["retry-after"], now) ?? options.retryAfterFallback));
|
|
367
|
+
};
|
|
368
|
+
const retryReason = (status) => status === 429 ? "rate_limit" : status === 408 ? "timeout" : "server_error";
|
|
369
|
+
/** Retries transient responses and honors Shopify's `Retry-After` header on 429. */
|
|
370
|
+
const withTransientRetry = (client, options) => {
|
|
371
|
+
const loop = (effect, remaining, attempt) => Effect.flatMap(effect, (response) => {
|
|
372
|
+
if (!(response.status === 429 || otherTransientStatuses.has(response.status)) || remaining <= 0) return Effect.succeed(response);
|
|
373
|
+
return delayFor(response, attempt, options).pipe(Effect.tap(() => Metrics.recordApiRetry({
|
|
374
|
+
connector: manifest.name,
|
|
375
|
+
reason: retryReason(response.status)
|
|
376
|
+
})), Effect.flatMap(Effect.sleep), Effect.flatMap(() => loop(effect, remaining - 1, attempt + 1)));
|
|
377
|
+
});
|
|
378
|
+
return HttpClient.transform(client, (effect) => loop(effect, options.maxRetries, 0));
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/api.ts
|
|
383
|
+
var api_exports = /* @__PURE__ */ __export({
|
|
384
|
+
ShopifyApiClient: () => ShopifyApiClient,
|
|
385
|
+
layer: () => layer$1,
|
|
386
|
+
layerConfig: () => layerConfig$1,
|
|
387
|
+
make: () => make$1
|
|
388
|
+
});
|
|
389
|
+
var ShopifyApiClient = class extends Context.Service()("@useairfoil/producer-shopify/ShopifyApiClient") {};
|
|
390
|
+
const ProductsQuery = `#graphql
|
|
391
|
+
query AirfoilProducts($first: Int!, $after: String) {
|
|
392
|
+
products(first: $first, after: $after, sortKey: UPDATED_AT, reverse: true) {
|
|
393
|
+
nodes {
|
|
394
|
+
id
|
|
395
|
+
legacyResourceId
|
|
396
|
+
title
|
|
397
|
+
handle
|
|
398
|
+
descriptionHtml
|
|
399
|
+
productType
|
|
400
|
+
vendor
|
|
401
|
+
status
|
|
402
|
+
tags
|
|
403
|
+
createdAt
|
|
404
|
+
updatedAt
|
|
405
|
+
publishedAt
|
|
406
|
+
templateSuffix
|
|
407
|
+
featuredMedia {
|
|
408
|
+
... on MediaImage {
|
|
409
|
+
image {
|
|
410
|
+
url
|
|
411
|
+
altText
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
options(first: 100) {
|
|
416
|
+
id
|
|
417
|
+
name
|
|
418
|
+
position
|
|
419
|
+
values
|
|
420
|
+
}
|
|
421
|
+
variants(first: 25) {
|
|
422
|
+
nodes {
|
|
423
|
+
id
|
|
424
|
+
legacyResourceId
|
|
425
|
+
title
|
|
426
|
+
sku
|
|
427
|
+
barcode
|
|
428
|
+
price
|
|
429
|
+
compareAtPrice
|
|
430
|
+
inventoryPolicy
|
|
431
|
+
taxable
|
|
432
|
+
createdAt
|
|
433
|
+
updatedAt
|
|
434
|
+
}
|
|
435
|
+
pageInfo {
|
|
436
|
+
hasNextPage
|
|
437
|
+
endCursor
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
pageInfo {
|
|
442
|
+
hasNextPage
|
|
443
|
+
endCursor
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
`;
|
|
448
|
+
const ProductByIdQuery = `#graphql
|
|
449
|
+
query AirfoilProductById($id: ID!) {
|
|
450
|
+
product(id: $id) {
|
|
451
|
+
id
|
|
452
|
+
legacyResourceId
|
|
453
|
+
title
|
|
454
|
+
handle
|
|
455
|
+
descriptionHtml
|
|
456
|
+
productType
|
|
457
|
+
vendor
|
|
458
|
+
status
|
|
459
|
+
tags
|
|
460
|
+
createdAt
|
|
461
|
+
updatedAt
|
|
462
|
+
publishedAt
|
|
463
|
+
templateSuffix
|
|
464
|
+
featuredMedia {
|
|
465
|
+
... on MediaImage {
|
|
466
|
+
image {
|
|
467
|
+
url
|
|
468
|
+
altText
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
options(first: 100) {
|
|
473
|
+
id
|
|
474
|
+
name
|
|
475
|
+
position
|
|
476
|
+
values
|
|
477
|
+
}
|
|
478
|
+
variants(first: 25) {
|
|
479
|
+
nodes {
|
|
480
|
+
id
|
|
481
|
+
legacyResourceId
|
|
482
|
+
title
|
|
483
|
+
sku
|
|
484
|
+
barcode
|
|
485
|
+
price
|
|
486
|
+
compareAtPrice
|
|
487
|
+
inventoryPolicy
|
|
488
|
+
taxable
|
|
489
|
+
createdAt
|
|
490
|
+
updatedAt
|
|
491
|
+
}
|
|
492
|
+
pageInfo {
|
|
493
|
+
hasNextPage
|
|
494
|
+
endCursor
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
`;
|
|
500
|
+
const ProductVariantsQuery = `#graphql
|
|
501
|
+
query AirfoilProductVariants($id: ID!, $first: Int!, $after: String) {
|
|
502
|
+
product(id: $id) {
|
|
503
|
+
variants(first: $first, after: $after) {
|
|
504
|
+
nodes {
|
|
505
|
+
id
|
|
506
|
+
legacyResourceId
|
|
507
|
+
title
|
|
508
|
+
sku
|
|
509
|
+
barcode
|
|
510
|
+
price
|
|
511
|
+
compareAtPrice
|
|
512
|
+
inventoryPolicy
|
|
513
|
+
taxable
|
|
514
|
+
createdAt
|
|
515
|
+
updatedAt
|
|
516
|
+
}
|
|
517
|
+
pageInfo {
|
|
518
|
+
hasNextPage
|
|
519
|
+
endCursor
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
`;
|
|
525
|
+
const ProductsAccessQuery = `#graphql
|
|
526
|
+
query AirfoilProductsAccess {
|
|
527
|
+
products(first: 1) {
|
|
528
|
+
nodes {
|
|
529
|
+
id
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
`;
|
|
534
|
+
const ShopIdentityQuery = `#graphql
|
|
535
|
+
query AirfoilShopIdentity {
|
|
536
|
+
shop {
|
|
537
|
+
id
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
`;
|
|
541
|
+
const ShopIdentitySchema = Schema.Struct({ shop: Schema.Struct({ id: Schema.String }) });
|
|
542
|
+
const ProductsAccessSchema = Schema.Struct({ products: Schema.Struct({ nodes: Schema.Array(Schema.Struct({ id: Schema.String })) }) });
|
|
543
|
+
const LegacyResourceIdSchema = Schema.Union([Schema.String, Schema.Number]);
|
|
544
|
+
const GraphQLProductVariantNodeSchema = Schema.Struct({
|
|
545
|
+
id: Schema.String,
|
|
546
|
+
legacyResourceId: LegacyResourceIdSchema,
|
|
547
|
+
title: Schema.String,
|
|
548
|
+
sku: Schema.NullOr(Schema.String),
|
|
549
|
+
barcode: Schema.NullOr(Schema.String),
|
|
550
|
+
price: Schema.String,
|
|
551
|
+
compareAtPrice: Schema.NullOr(Schema.String),
|
|
552
|
+
inventoryPolicy: ProductVariantInventoryPolicySchema,
|
|
553
|
+
taxable: Schema.Boolean,
|
|
554
|
+
createdAt: Schema.String,
|
|
555
|
+
updatedAt: Schema.String
|
|
556
|
+
});
|
|
557
|
+
const GraphQLProductNodeSchema = Schema.Struct({
|
|
558
|
+
id: Schema.String,
|
|
559
|
+
legacyResourceId: LegacyResourceIdSchema,
|
|
560
|
+
title: Schema.String,
|
|
561
|
+
handle: Schema.String,
|
|
562
|
+
descriptionHtml: Schema.String,
|
|
563
|
+
productType: Schema.String,
|
|
564
|
+
vendor: Schema.String,
|
|
565
|
+
status: ProductStatusSchema,
|
|
566
|
+
tags: Schema.Array(Schema.String),
|
|
567
|
+
createdAt: Schema.String,
|
|
568
|
+
updatedAt: Schema.String,
|
|
569
|
+
publishedAt: Schema.NullOr(Schema.String),
|
|
570
|
+
templateSuffix: Schema.NullOr(Schema.String),
|
|
571
|
+
featuredMedia: Schema.NullOr(Schema.Any),
|
|
572
|
+
options: Schema.Array(ProductOptionSchema),
|
|
573
|
+
variants: Schema.Struct({
|
|
574
|
+
nodes: Schema.Array(GraphQLProductVariantNodeSchema),
|
|
575
|
+
pageInfo: PageInfoSchema
|
|
576
|
+
})
|
|
577
|
+
});
|
|
578
|
+
const GraphQLProductsDataSchema = Schema.Struct({ products: Schema.Struct({
|
|
579
|
+
nodes: Schema.Array(GraphQLProductNodeSchema),
|
|
580
|
+
pageInfo: PageInfoSchema
|
|
581
|
+
}) });
|
|
582
|
+
const GraphQLProductByIdDataSchema = Schema.Struct({ product: Schema.NullOr(GraphQLProductNodeSchema) });
|
|
583
|
+
const GraphQLProductVariantsDataSchema = Schema.Struct({ product: Schema.NullOr(Schema.Struct({ variants: Schema.Struct({
|
|
584
|
+
nodes: Schema.Array(GraphQLProductVariantNodeSchema),
|
|
585
|
+
pageInfo: PageInfoSchema
|
|
586
|
+
}) })) });
|
|
587
|
+
const normalizeProductNode = (node, variants) => ({
|
|
588
|
+
id: node.id,
|
|
589
|
+
legacyResourceId: String(node.legacyResourceId),
|
|
590
|
+
title: node.title,
|
|
591
|
+
handle: node.handle,
|
|
592
|
+
descriptionHtml: node.descriptionHtml,
|
|
593
|
+
productType: node.productType,
|
|
594
|
+
vendor: node.vendor,
|
|
595
|
+
status: node.status,
|
|
596
|
+
tags: node.tags,
|
|
597
|
+
createdAt: node.createdAt,
|
|
598
|
+
updatedAt: node.updatedAt,
|
|
599
|
+
publishedAt: node.publishedAt,
|
|
600
|
+
templateSuffix: node.templateSuffix,
|
|
601
|
+
featuredMedia: node.featuredMedia,
|
|
602
|
+
options: node.options,
|
|
603
|
+
variants: variants.map((variant) => ({
|
|
604
|
+
...variant,
|
|
605
|
+
legacyResourceId: String(variant.legacyResourceId)
|
|
606
|
+
}))
|
|
607
|
+
});
|
|
608
|
+
const graphqlEndpoint = (config) => {
|
|
609
|
+
return `https://${config.shopDomain.replace(/^https?:\/\//i, "").replace(/\/+$/g, "")}/admin/api/${config.apiVersion}/graphql.json`;
|
|
610
|
+
};
|
|
611
|
+
const hasGraphqlErrors = (body) => {
|
|
612
|
+
if (typeof body !== "object" || body === null) return false;
|
|
613
|
+
const errors = body.errors;
|
|
614
|
+
return Array.isArray(errors) && errors.length > 0;
|
|
615
|
+
};
|
|
616
|
+
const summarizeBody = (body) => {
|
|
617
|
+
try {
|
|
618
|
+
return JSON.stringify(body);
|
|
619
|
+
} catch {
|
|
620
|
+
return String(body);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const make$1 = Effect.fnUntraced(function* (config) {
|
|
624
|
+
const auth = yield* ShopifyAuth;
|
|
625
|
+
const retryBaseDelay = Duration.millis(config.retryBaseDelayMs);
|
|
626
|
+
const graphqlRetryBaseDelay = Duration.millis(config.graphqlRetryBaseDelayMs);
|
|
627
|
+
const requestTimeout = Duration.seconds(config.requestTimeoutSeconds);
|
|
628
|
+
const transportRetrySchedule = Schedule.exponential(retryBaseDelay).pipe(Schedule.jittered, Schedule.upTo({ times: config.transportMaxRetries }), Schedule.tap(() => Metrics.recordApiRetry({
|
|
629
|
+
connector: manifest.name,
|
|
630
|
+
reason: "transport"
|
|
631
|
+
})));
|
|
632
|
+
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.mapRequestEffect((request) => auth.get.pipe(Effect.map((token) => HttpClientRequest.setHeader(request, "X-Shopify-Access-Token", Redacted.value(token))))), HttpClient.mapRequest(HttpClientRequest.acceptJson), (c) => withTransientRetry(c, {
|
|
633
|
+
maxRetries: config.responseMaxRetries,
|
|
634
|
+
baseDelay: retryBaseDelay,
|
|
635
|
+
retryAfterFallback: Duration.seconds(config.retryAfterFallbackSeconds)
|
|
636
|
+
}), HttpClient.retryTransient({
|
|
637
|
+
retryOn: "errors-only",
|
|
638
|
+
schedule: transportRetrySchedule
|
|
639
|
+
}));
|
|
640
|
+
const endpoint = graphqlEndpoint(config);
|
|
641
|
+
const fetchGraphQL = (options) => {
|
|
642
|
+
const attempt = (remainingRetries) => Effect.gen(function* () {
|
|
643
|
+
const request = yield* HttpClientRequest.post(endpoint).pipe(HttpClientRequest.bodyJson({
|
|
644
|
+
query: options.query,
|
|
645
|
+
variables: options.variables ?? {}
|
|
646
|
+
}), Effect.mapError((cause) => new ConnectorError({
|
|
647
|
+
message: "Failed to encode Shopify GraphQL request",
|
|
648
|
+
cause
|
|
649
|
+
})));
|
|
650
|
+
const { body, status } = yield* Effect.scoped(client.execute(request).pipe(Effect.tapError((error) => Telemetry.annotateError("api_http", error)), Effect.mapError((error) => {
|
|
651
|
+
if (error._tag === "ShopifyAuthError") return new ConnectorError({
|
|
652
|
+
message: error.message,
|
|
653
|
+
cause: error
|
|
654
|
+
});
|
|
655
|
+
return new ConnectorError({
|
|
656
|
+
message: "Shopify GraphQL request failed",
|
|
657
|
+
cause: error
|
|
658
|
+
});
|
|
659
|
+
}), Effect.flatMap((response) => response.json.pipe(Effect.tapError((error) => Telemetry.annotateError("api_json", error)), Effect.mapError((error) => new ConnectorError({
|
|
660
|
+
message: "Shopify GraphQL returned invalid JSON",
|
|
661
|
+
cause: error
|
|
662
|
+
})), Effect.map((body$1) => ({
|
|
663
|
+
body: body$1,
|
|
664
|
+
status: response.status
|
|
665
|
+
}))))));
|
|
666
|
+
if (status < 200 || status >= 300) {
|
|
667
|
+
const error = {
|
|
668
|
+
status,
|
|
669
|
+
body,
|
|
670
|
+
operationName: options.operationName
|
|
671
|
+
};
|
|
672
|
+
yield* Effect.logWarning("Shopify GraphQL returned non-2xx status").pipe(Effect.annotateLogs({
|
|
673
|
+
operationName: options.operationName,
|
|
674
|
+
status,
|
|
675
|
+
body: summarizeBody(body)
|
|
676
|
+
}));
|
|
677
|
+
yield* Telemetry.annotateError("api_status", error);
|
|
678
|
+
return yield* Effect.fail(new ConnectorError({
|
|
679
|
+
message: "Shopify GraphQL returned non-2xx status",
|
|
680
|
+
cause: error
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
if (hasGraphqlErrors(body)) {
|
|
684
|
+
const retryableCode = retryableErrorCode(body);
|
|
685
|
+
if (retryableCode !== void 0 && remainingRetries > 0) {
|
|
686
|
+
const attemptIndex = config.graphqlMaxRetries - remainingRetries;
|
|
687
|
+
const wait = yield* retryDelay(retryableCode, body, attemptIndex, { baseDelay: graphqlRetryBaseDelay });
|
|
688
|
+
yield* Effect.logWarning("Shopify GraphQL request returned a retryable error, retrying").pipe(Effect.annotateLogs({
|
|
689
|
+
operationName: options.operationName,
|
|
690
|
+
code: retryableCode,
|
|
691
|
+
remainingRetries,
|
|
692
|
+
waitMillis: Duration.toMillis(wait)
|
|
693
|
+
}));
|
|
694
|
+
yield* Metrics.recordApiRetry({
|
|
695
|
+
connector: manifest.name,
|
|
696
|
+
reason: retryableCode === "THROTTLED" ? "rate_limit" : "server_error"
|
|
697
|
+
});
|
|
698
|
+
yield* Effect.sleep(wait);
|
|
699
|
+
return yield* attempt(remainingRetries - 1);
|
|
700
|
+
}
|
|
701
|
+
yield* Effect.logWarning("Shopify GraphQL returned errors").pipe(Effect.annotateLogs({
|
|
702
|
+
operationName: options.operationName,
|
|
703
|
+
body: summarizeBody(body)
|
|
704
|
+
}));
|
|
705
|
+
yield* Telemetry.annotateError("api_graphql", body);
|
|
706
|
+
return yield* Effect.fail(new ConnectorError({
|
|
707
|
+
message: "Shopify GraphQL returned errors",
|
|
708
|
+
cause: body
|
|
709
|
+
}));
|
|
710
|
+
}
|
|
711
|
+
const data = typeof body === "object" && body !== null ? body.data : void 0;
|
|
712
|
+
return yield* Schema.decodeUnknownEffect(options.schema)(data).pipe(Effect.tapError((error) => Telemetry.annotateError("api_decode", error)), Effect.mapError((error) => new ConnectorError({
|
|
713
|
+
message: "Shopify GraphQL response schema decode failed",
|
|
714
|
+
cause: error
|
|
715
|
+
})));
|
|
716
|
+
}).pipe(Effect.withSpan(Telemetry.SpanName.apiFetch, {
|
|
717
|
+
kind: "client",
|
|
718
|
+
attributes: { [Telemetry.Attr.apiPath]: `graphql:${options.operationName}` }
|
|
719
|
+
}));
|
|
720
|
+
return attempt(config.graphqlMaxRetries).pipe(Effect.timeout(requestTimeout), Effect.mapError((error) => error instanceof ConnectorError ? error : new ConnectorError({
|
|
721
|
+
message: "Shopify GraphQL request timed out",
|
|
722
|
+
cause: error
|
|
723
|
+
})));
|
|
724
|
+
};
|
|
725
|
+
const nextPageCursor = (pageInfo) => {
|
|
726
|
+
if (!pageInfo.hasNextPage) return Effect.succeed(Option.none());
|
|
727
|
+
if (pageInfo.endCursor === null) return Effect.fail(new ConnectorError({ message: "Shopify GraphQL pageInfo.endCursor is required when hasNextPage is true" }));
|
|
728
|
+
return Effect.succeed(Option.some(pageInfo.endCursor));
|
|
729
|
+
};
|
|
730
|
+
const fetchRemainingProductVariants = (productId, initialCursor) => Stream.paginate(initialCursor, (after) => fetchGraphQL({
|
|
731
|
+
operationName: "AirfoilProductVariants",
|
|
732
|
+
query: ProductVariantsQuery,
|
|
733
|
+
variables: {
|
|
734
|
+
id: productId,
|
|
735
|
+
first: 100,
|
|
736
|
+
after
|
|
737
|
+
},
|
|
738
|
+
schema: GraphQLProductVariantsDataSchema
|
|
739
|
+
}).pipe(Effect.flatMap((data) => {
|
|
740
|
+
if (data.product === null) return Effect.fail(new ConnectorError({ message: "Shopify product disappeared while fetching variants" }));
|
|
741
|
+
const variants = data.product.variants;
|
|
742
|
+
return nextPageCursor(variants.pageInfo).pipe(Effect.map((cursor) => [variants.nodes, cursor]));
|
|
743
|
+
}))).pipe(Stream.runCollect);
|
|
744
|
+
const loadProductVariants = (node) => nextPageCursor(node.variants.pageInfo).pipe(Effect.flatMap(Option.match({
|
|
745
|
+
onNone: () => Effect.succeed(node.variants.nodes),
|
|
746
|
+
onSome: (cursor) => fetchRemainingProductVariants(node.id, cursor).pipe(Effect.map((remaining) => [...node.variants.nodes, ...remaining]))
|
|
747
|
+
})));
|
|
748
|
+
const fetchProducts = (options) => fetchGraphQL({
|
|
749
|
+
operationName: "AirfoilProducts",
|
|
750
|
+
query: ProductsQuery,
|
|
751
|
+
variables: {
|
|
752
|
+
first: options.first,
|
|
753
|
+
after: options.after ?? null
|
|
754
|
+
},
|
|
755
|
+
schema: GraphQLProductsDataSchema
|
|
756
|
+
}).pipe(Effect.flatMap((data) => nextPageCursor(data.products.pageInfo).pipe(Effect.andThen(Effect.forEach(data.products.nodes, (node) => loadProductVariants(node).pipe(Effect.map((variants) => normalizeProductNode(node, variants))))), Effect.map((items) => ({
|
|
757
|
+
items,
|
|
758
|
+
endCursor: data.products.pageInfo.endCursor,
|
|
759
|
+
hasMore: data.products.pageInfo.hasNextPage
|
|
760
|
+
})))));
|
|
761
|
+
const fetchProductById = (id) => fetchGraphQL({
|
|
762
|
+
operationName: "AirfoilProductById",
|
|
763
|
+
query: ProductByIdQuery,
|
|
764
|
+
variables: { id },
|
|
765
|
+
schema: GraphQLProductByIdDataSchema
|
|
766
|
+
}).pipe(Effect.flatMap((data) => {
|
|
767
|
+
const node = data.product;
|
|
768
|
+
if (node === null) return Effect.fail(new ConnectorError({ message: "Shopify product not found while refreshing from webhook" }));
|
|
769
|
+
return loadProductVariants(node).pipe(Effect.map((variants) => normalizeProductNode(node, variants)));
|
|
770
|
+
}));
|
|
771
|
+
return {
|
|
772
|
+
checkConnection: fetchGraphQL({
|
|
773
|
+
operationName: "AirfoilShopIdentity",
|
|
774
|
+
query: ShopIdentityQuery,
|
|
775
|
+
schema: ShopIdentitySchema
|
|
776
|
+
}).pipe(Effect.asVoid),
|
|
777
|
+
checkProductsAccess: fetchGraphQL({
|
|
778
|
+
operationName: "AirfoilProductsAccess",
|
|
779
|
+
query: ProductsAccessQuery,
|
|
780
|
+
schema: ProductsAccessSchema
|
|
781
|
+
}).pipe(Effect.asVoid),
|
|
782
|
+
fetchGraphQL,
|
|
783
|
+
fetchProducts,
|
|
784
|
+
fetchProductById
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
const layer$1 = (config) => Layer.effect(ShopifyApiClient)(make$1(config));
|
|
788
|
+
const layerConfig$1 = (config) => Layer.effect(ShopifyApiClient)(Config.unwrap(config).pipe(Effect.flatMap(make$1)));
|
|
789
|
+
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/connector.ts
|
|
792
|
+
var connector_exports = /* @__PURE__ */ __export({
|
|
793
|
+
ShopifyConfigDef: () => ShopifyConfigDef,
|
|
794
|
+
ShopifyConnector: () => ShopifyConnector,
|
|
795
|
+
layer: () => layer,
|
|
796
|
+
layerConfig: () => layerConfig,
|
|
797
|
+
make: () => make,
|
|
798
|
+
manifest: () => manifest
|
|
799
|
+
});
|
|
800
|
+
const verifyWebhookSignature = (options) => Effect.try({
|
|
801
|
+
try: () => {
|
|
802
|
+
if (!options.signature) throw new Error("Missing x-shopify-hmac-sha256 header");
|
|
803
|
+
const digest = createHmac("sha256", options.secret).update(Buffer.from(options.rawBody)).digest();
|
|
804
|
+
const provided = Buffer.from(options.signature, "base64");
|
|
805
|
+
if (provided.length !== digest.length || !timingSafeEqual(digest, provided)) throw new Error("Invalid Shopify webhook signature");
|
|
806
|
+
},
|
|
807
|
+
catch: (cause) => new ConnectorError({
|
|
808
|
+
message: "Shopify webhook verification failed",
|
|
809
|
+
cause
|
|
810
|
+
})
|
|
811
|
+
});
|
|
812
|
+
const decodeWebhookPayload = (schema) => Schema.decodeUnknownEffect(schema);
|
|
813
|
+
const make = Effect.fnUntraced(function* (config) {
|
|
814
|
+
const api = yield* ShopifyApiClient;
|
|
815
|
+
const Products = Resource.entity({
|
|
816
|
+
name: "products",
|
|
817
|
+
schema: ProductSchema,
|
|
818
|
+
key: "id",
|
|
819
|
+
version: "updatedAt",
|
|
820
|
+
check: api.checkProductsAccess,
|
|
821
|
+
backfill: Fetch.page({
|
|
822
|
+
pageCursor: Cursor.string(),
|
|
823
|
+
cutoff: Cursor.isoDateTime(),
|
|
824
|
+
fetch: ({ pageCursor, cutoff }) => api.fetchProducts({
|
|
825
|
+
first: 50,
|
|
826
|
+
after: typeof pageCursor === "string" ? pageCursor : void 0
|
|
827
|
+
}).pipe(Effect.map((page) => ({
|
|
828
|
+
mutations: page.items.filter((row) => Date.parse(row.updatedAt) <= Date.parse(String(cutoff))).map(Resource.upsert),
|
|
829
|
+
nextPageCursor: page.endCursor ?? void 0,
|
|
830
|
+
hasMore: page.hasMore
|
|
831
|
+
})))
|
|
832
|
+
}),
|
|
833
|
+
webhook: Resource.webhook({
|
|
834
|
+
schema: ProductEventSchema,
|
|
835
|
+
handler: ({ payload }) => {
|
|
836
|
+
if (payload._tag === "delete") return Effect.succeed([Resource.delete({
|
|
837
|
+
key: `gid://shopify/Product/${payload.id}`,
|
|
838
|
+
version: payload.version
|
|
839
|
+
})]);
|
|
840
|
+
const product = payload.payload;
|
|
841
|
+
if (product.created_at !== null && product.variants.length === product.variant_gids.length) return Effect.succeed([Resource.upsert(ShopifyNormalize.productWebhook({
|
|
842
|
+
...product,
|
|
843
|
+
created_at: product.created_at
|
|
844
|
+
}))]);
|
|
845
|
+
return api.fetchProductById(product.admin_graphql_api_id).pipe(Effect.map((row) => [Resource.upsert(row)]));
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
});
|
|
849
|
+
const CartEvents = Resource.entity({
|
|
850
|
+
name: "cart_events",
|
|
851
|
+
schema: CartEventSchema,
|
|
852
|
+
key: "id",
|
|
853
|
+
version: "updatedAt",
|
|
854
|
+
check: api.checkConnection,
|
|
855
|
+
webhook: Resource.webhook({
|
|
856
|
+
schema: Schema.Struct({
|
|
857
|
+
...CartWebhookPayloadSchema.fields,
|
|
858
|
+
topic: Schema.Literals(["carts/create", "carts/update"])
|
|
859
|
+
}),
|
|
860
|
+
handler: ({ payload }) => Effect.succeed([Resource.upsert(ShopifyNormalize.cartWebhook(payload, payload.topic))])
|
|
861
|
+
})
|
|
862
|
+
});
|
|
863
|
+
const webhookRoute = Webhook.route({
|
|
864
|
+
path: "/webhooks/shopify",
|
|
865
|
+
ackMode: "after-publish",
|
|
866
|
+
schema: Schema.Unknown,
|
|
867
|
+
handler: ({ request, rawBody, payload: json, to }) => Effect.gen(function* () {
|
|
868
|
+
const verificationError = yield* verifyWebhookSignature({
|
|
869
|
+
rawBody,
|
|
870
|
+
signature: request.headers["x-shopify-hmac-sha256"] ?? null,
|
|
871
|
+
secret: Redacted.value(config.webhookSecret)
|
|
872
|
+
}).pipe(Effect.match({
|
|
873
|
+
onFailure: (error) => error,
|
|
874
|
+
onSuccess: () => void 0
|
|
875
|
+
}));
|
|
876
|
+
if (verificationError) return HttpServerResponse.jsonUnsafe({
|
|
877
|
+
ok: false,
|
|
878
|
+
error: verificationError.message
|
|
879
|
+
}, { status: 401 });
|
|
880
|
+
const topic = request.headers["x-shopify-topic"] ?? "";
|
|
881
|
+
switch (topic) {
|
|
882
|
+
case "products/create":
|
|
883
|
+
case "products/update": {
|
|
884
|
+
const payload = yield* decodeWebhookPayload(ProductWebhookPayloadSchema)(json).pipe(Effect.mapError((cause) => new ConnectorError({
|
|
885
|
+
message: `Invalid Shopify webhook payload for ${topic}`,
|
|
886
|
+
cause
|
|
887
|
+
})), Effect.match({
|
|
888
|
+
onFailure: (error) => ({
|
|
889
|
+
_tag: "Error",
|
|
890
|
+
error
|
|
891
|
+
}),
|
|
892
|
+
onSuccess: (value) => ({
|
|
893
|
+
_tag: "Success",
|
|
894
|
+
value
|
|
895
|
+
})
|
|
896
|
+
}));
|
|
897
|
+
if (payload._tag === "Error") return HttpServerResponse.jsonUnsafe({
|
|
898
|
+
ok: false,
|
|
899
|
+
error: payload.error.message
|
|
900
|
+
}, { status: 400 });
|
|
901
|
+
yield* to(Products, {
|
|
902
|
+
_tag: "upsert",
|
|
903
|
+
payload: payload.value
|
|
904
|
+
});
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
case "products/delete": {
|
|
908
|
+
const triggeredAt = request.headers["x-shopify-triggered-at"];
|
|
909
|
+
if (!triggeredAt) return HttpServerResponse.jsonUnsafe({
|
|
910
|
+
ok: false,
|
|
911
|
+
error: "Missing x-shopify-triggered-at header"
|
|
912
|
+
}, { status: 400 });
|
|
913
|
+
const payload = yield* decodeWebhookPayload(ProductDeleteWebhookPayloadSchema)(json).pipe(Effect.mapError((cause) => new ConnectorError({
|
|
914
|
+
message: "Invalid Shopify webhook payload for products/delete",
|
|
915
|
+
cause
|
|
916
|
+
})), Effect.match({
|
|
917
|
+
onFailure: (error) => ({
|
|
918
|
+
_tag: "Error",
|
|
919
|
+
error
|
|
920
|
+
}),
|
|
921
|
+
onSuccess: (value) => ({
|
|
922
|
+
_tag: "Success",
|
|
923
|
+
value
|
|
924
|
+
})
|
|
925
|
+
}));
|
|
926
|
+
if (payload._tag === "Error") return HttpServerResponse.jsonUnsafe({
|
|
927
|
+
ok: false,
|
|
928
|
+
error: payload.error.message
|
|
929
|
+
}, { status: 400 });
|
|
930
|
+
yield* to(Products, {
|
|
931
|
+
_tag: "delete",
|
|
932
|
+
id: String(payload.value.id),
|
|
933
|
+
version: triggeredAt
|
|
934
|
+
});
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
case "carts/create":
|
|
938
|
+
case "carts/update": {
|
|
939
|
+
const payload = yield* decodeWebhookPayload(CartWebhookPayloadSchema)(json).pipe(Effect.mapError((cause) => new ConnectorError({
|
|
940
|
+
message: `Invalid Shopify webhook payload for ${topic}`,
|
|
941
|
+
cause
|
|
942
|
+
})), Effect.match({
|
|
943
|
+
onFailure: (error) => ({
|
|
944
|
+
_tag: "Error",
|
|
945
|
+
error
|
|
946
|
+
}),
|
|
947
|
+
onSuccess: (value) => ({
|
|
948
|
+
_tag: "Success",
|
|
949
|
+
value
|
|
950
|
+
})
|
|
951
|
+
}));
|
|
952
|
+
if (payload._tag === "Error") return HttpServerResponse.jsonUnsafe({
|
|
953
|
+
ok: false,
|
|
954
|
+
error: payload.error.message
|
|
955
|
+
}, { status: 400 });
|
|
956
|
+
yield* to(CartEvents, {
|
|
957
|
+
...payload.value,
|
|
958
|
+
topic
|
|
959
|
+
});
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
default: yield* Effect.logWarning("Ignoring unknown Shopify webhook topic").pipe(Effect.annotateLogs({ topic }));
|
|
963
|
+
}
|
|
964
|
+
return HttpServerResponse.jsonUnsafe({ ok: true });
|
|
965
|
+
})
|
|
966
|
+
});
|
|
967
|
+
return Connector.define({
|
|
968
|
+
name: "producer-shopify",
|
|
969
|
+
title: "Shopify",
|
|
970
|
+
resources: [Products, CartEvents],
|
|
971
|
+
webhooks: [webhookRoute]
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
var ShopifyConnector = class extends Context.Service()("@useairfoil/producer-shopify/ShopifyConnector") {};
|
|
975
|
+
const layer = (config) => {
|
|
976
|
+
const authLayer = layer$2(config);
|
|
977
|
+
const apiLayer = layer$1(config).pipe(Layer.provide(authLayer));
|
|
978
|
+
return Layer.effect(ShopifyConnector)(make(config).pipe(Effect.annotateLogs({ component: "producer-shopify" }))).pipe(Layer.provide(apiLayer));
|
|
979
|
+
};
|
|
980
|
+
const layerConfig = (config) => Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
|
|
981
|
+
|
|
982
|
+
//#endregion
|
|
983
|
+
export { ProductWebhookPayloadSchema as _, CartEventSchema as a, MoneyBagSchema as c, ProductDeleteWebhookPayloadSchema as d, ProductOptionSchema as f, ProductVariantSchema as g, ProductVariantInventoryPolicySchema as h, api_exports as i, MoneySchema as l, ProductStatusSchema as m, connector_exports as n, CartLineItemSchema as o, ProductSchema as p, layerConfig as r, CartWebhookPayloadSchema as s, ShopifyConnector as t, PageInfoSchema as u, ShopifyNormalize as v, WebhookPayloadSchema as y };
|
|
984
|
+
//# sourceMappingURL=src-DqmiLrYS.js.map
|