@useairfoil/producer-shopify 0.2.0 → 0.2.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.
- 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 +698 -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-Bgvuhcfk.mjs +2473 -0
- package/dist/src-Bgvuhcfk.mjs.map +1 -0
- package/package.json +19 -19
- 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 +5 -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 +8 -8
- package/src/manifest.ts +4 -55
- package/src/resources/carts/row.ts +58 -0
- package/src/resources/carts/webhook.ts +98 -0
- package/src/resources/customers/graphql.ts +175 -0
- package/src/resources/customers/row.ts +112 -0
- package/src/resources/customers/webhook.ts +30 -0
- package/src/resources/orders/graphql.ts +419 -0
- package/src/resources/orders/row.ts +180 -0
- package/src/resources/orders/webhook.ts +248 -0
- package/src/resources/products/graphql.ts +142 -0
- package/src/resources/products/row.ts +92 -0
- package/src/resources/products/webhook.ts +130 -0
- package/src/resources/refunds/graphql.ts +196 -0
- package/src/resources/refunds/row.ts +97 -0
- package/src/resources/refunds/webhook.ts +123 -0
- package/src/resources/shared.ts +293 -0
- package/src/sandbox.ts +3 -3
- package/src/start.ts +3 -5
- 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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { ConnectorError, Metrics, Telemetry } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Data, Duration, Effect, Option, Redacted, Schedule, Schema } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
|
+
|
|
5
|
+
import { manifest, type ShopifyConfig } from "../manifest";
|
|
6
|
+
import * as ShopifyAuth from "./auth";
|
|
7
|
+
import { retrySchedule } from "./http";
|
|
8
|
+
|
|
9
|
+
// Keep this version aligned with the queries and webhook schemas.
|
|
10
|
+
export const SHOPIFY_API_VERSION = "2026-07";
|
|
11
|
+
|
|
12
|
+
const maxRetries = 5;
|
|
13
|
+
const retryBaseDelay = Duration.millis(500);
|
|
14
|
+
const requestTimeout = Duration.minutes(2);
|
|
15
|
+
|
|
16
|
+
// Shopify reports GraphQL throttling in HTTP 200 responses.
|
|
17
|
+
// https://shopify.dev/docs/api/usage/limits
|
|
18
|
+
const ThrottleStatusSchema = Schema.Struct({
|
|
19
|
+
maximumAvailable: Schema.Number,
|
|
20
|
+
currentlyAvailable: Schema.Number,
|
|
21
|
+
restoreRate: Schema.Number,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const GraphQLResponseSchema = Schema.Struct({
|
|
25
|
+
data: Schema.optional(Schema.Unknown),
|
|
26
|
+
errors: Schema.optional(
|
|
27
|
+
Schema.Array(
|
|
28
|
+
Schema.Struct({
|
|
29
|
+
extensions: Schema.optional(Schema.Struct({ code: Schema.optional(Schema.String) })),
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
extensions: Schema.optional(
|
|
34
|
+
Schema.Struct({
|
|
35
|
+
cost: Schema.optional(
|
|
36
|
+
Schema.Struct({
|
|
37
|
+
requestedQueryCost: Schema.optional(Schema.Number),
|
|
38
|
+
throttleStatus: Schema.optional(ThrottleStatusSchema),
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
type GraphQLResponse = Schema.Schema.Type<typeof GraphQLResponseSchema>;
|
|
46
|
+
|
|
47
|
+
const retryableCodes = ["THROTTLED", "INTERNAL_SERVER_ERROR"] as const;
|
|
48
|
+
|
|
49
|
+
/** How long Shopify needs to restore the query's cost, when it says so. */
|
|
50
|
+
const throttleWait = (response: GraphQLResponse): Option.Option<Duration.Duration> => {
|
|
51
|
+
const cost = response.extensions?.cost;
|
|
52
|
+
const status = cost?.throttleStatus;
|
|
53
|
+
if (cost?.requestedQueryCost === undefined || status === undefined) return Option.none();
|
|
54
|
+
const deficit = cost.requestedQueryCost - status.currentlyAvailable;
|
|
55
|
+
return Option.some(Duration.millis(Math.ceil((deficit / status.restoreRate) * 1000)));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
class Retryable extends Data.TaggedError("Retryable")<{
|
|
59
|
+
readonly code: (typeof retryableCodes)[number];
|
|
60
|
+
readonly wait: Option.Option<Duration.Duration>;
|
|
61
|
+
readonly body: unknown;
|
|
62
|
+
}> {}
|
|
63
|
+
|
|
64
|
+
export const make = Effect.fnUntraced(function* (config: ShopifyConfig) {
|
|
65
|
+
const auth = yield* ShopifyAuth.ShopifyAuth;
|
|
66
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
67
|
+
HttpClient.mapRequestEffect((request) =>
|
|
68
|
+
auth.get.pipe(
|
|
69
|
+
Effect.map((token) =>
|
|
70
|
+
HttpClientRequest.setHeader(request, "X-Shopify-Access-Token", Redacted.value(token)),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
HttpClient.mapRequest(HttpClientRequest.acceptJson),
|
|
75
|
+
HttpClient.retryTransient({
|
|
76
|
+
schedule: retrySchedule({ maxRetries, baseDelay: retryBaseDelay }),
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
const endpoint = `${ShopifyAuth.shopUrl(config.shopDomain)}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`;
|
|
80
|
+
|
|
81
|
+
// A throttled query waits until Shopify restores its cost. Others back off.
|
|
82
|
+
const graphqlRetrySchedule = Schedule.exponential(retryBaseDelay).pipe(
|
|
83
|
+
Schedule.jittered,
|
|
84
|
+
Schedule.upTo({ times: maxRetries }),
|
|
85
|
+
Schedule.modifyDelay(({ input, duration }) =>
|
|
86
|
+
Effect.succeed(
|
|
87
|
+
input instanceof Retryable
|
|
88
|
+
? Option.match(input.wait, {
|
|
89
|
+
onNone: () => duration,
|
|
90
|
+
onSome: (wait) => Duration.max(wait, retryBaseDelay),
|
|
91
|
+
})
|
|
92
|
+
: duration,
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const fetchGraphQL = <A>(options: {
|
|
98
|
+
readonly operationName: string;
|
|
99
|
+
readonly query: string;
|
|
100
|
+
readonly variables?: Record<string, unknown>;
|
|
101
|
+
readonly schema: Schema.Decoder<A>;
|
|
102
|
+
}): Effect.Effect<A, ConnectorError> => {
|
|
103
|
+
const fail = (message: string, cause: unknown, annotation: string) =>
|
|
104
|
+
Effect.logWarning(message).pipe(
|
|
105
|
+
Effect.annotateLogs({
|
|
106
|
+
operationName: options.operationName,
|
|
107
|
+
body: JSON.stringify(cause),
|
|
108
|
+
}),
|
|
109
|
+
Effect.andThen(Telemetry.annotateError(annotation, cause)),
|
|
110
|
+
Effect.andThen(Effect.fail(new ConnectorError({ message, cause }))),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const decodeError = (error: Schema.SchemaError) =>
|
|
114
|
+
Telemetry.annotateError("api_decode", error).pipe(
|
|
115
|
+
Effect.andThen(
|
|
116
|
+
Effect.fail(
|
|
117
|
+
new ConnectorError({
|
|
118
|
+
message: "Shopify GraphQL response schema decode failed",
|
|
119
|
+
cause: error,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const attempt = Effect.gen(function* () {
|
|
126
|
+
const request = HttpClientRequest.post(endpoint).pipe(
|
|
127
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
128
|
+
query: options.query,
|
|
129
|
+
variables: options.variables ?? {},
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const response = yield* client.execute(request).pipe(
|
|
133
|
+
Effect.tapError((error) => Telemetry.annotateError("api_http", error)),
|
|
134
|
+
Effect.mapError((error) =>
|
|
135
|
+
error._tag === "ShopifyAuthError"
|
|
136
|
+
? new ConnectorError({ message: error.message, cause: error })
|
|
137
|
+
: new ConnectorError({ message: "Shopify GraphQL request failed", cause: error }),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
const body = yield* response.json.pipe(
|
|
141
|
+
Effect.tapError((error) => Telemetry.annotateError("api_json", error)),
|
|
142
|
+
Effect.mapError(
|
|
143
|
+
(error) =>
|
|
144
|
+
new ConnectorError({ message: "Shopify GraphQL returned invalid JSON", cause: error }),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (response.status < 200 || response.status >= 300) {
|
|
149
|
+
return yield* fail(
|
|
150
|
+
"Shopify GraphQL returned non-2xx status",
|
|
151
|
+
{ status: response.status, body, operationName: options.operationName },
|
|
152
|
+
"api_status",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const graphql = yield* Schema.decodeUnknownEffect(GraphQLResponseSchema)(body).pipe(
|
|
157
|
+
Effect.catch(decodeError),
|
|
158
|
+
);
|
|
159
|
+
if (graphql.errors !== undefined && graphql.errors.length > 0) {
|
|
160
|
+
const codes = graphql.errors.map((error) => error.extensions?.code);
|
|
161
|
+
const code = retryableCodes.find((retryable) => codes.includes(retryable));
|
|
162
|
+
if (code !== undefined) {
|
|
163
|
+
return yield* new Retryable({
|
|
164
|
+
code,
|
|
165
|
+
wait: code === "THROTTLED" ? throttleWait(graphql) : Option.none(),
|
|
166
|
+
body,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return yield* fail("Shopify GraphQL returned errors", body, "api_graphql");
|
|
170
|
+
}
|
|
171
|
+
return yield* Schema.decodeUnknownEffect(options.schema)(graphql.data).pipe(
|
|
172
|
+
Effect.catch(decodeError),
|
|
173
|
+
);
|
|
174
|
+
}).pipe(
|
|
175
|
+
Effect.withSpan(Telemetry.SpanName.apiFetch, {
|
|
176
|
+
kind: "client",
|
|
177
|
+
attributes: { [Telemetry.Attr.apiPath]: `graphql:${options.operationName}` },
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const run = attempt.pipe(
|
|
182
|
+
Effect.retry({
|
|
183
|
+
schedule: graphqlRetrySchedule.pipe(
|
|
184
|
+
Schedule.tap(({ input, duration }) =>
|
|
185
|
+
input instanceof Retryable
|
|
186
|
+
? Effect.logWarning(
|
|
187
|
+
"Shopify GraphQL request returned a retryable error, retrying",
|
|
188
|
+
).pipe(
|
|
189
|
+
Effect.annotateLogs({
|
|
190
|
+
operationName: options.operationName,
|
|
191
|
+
code: input.code,
|
|
192
|
+
waitMillis: Duration.toMillis(duration),
|
|
193
|
+
}),
|
|
194
|
+
Effect.andThen(
|
|
195
|
+
Metrics.recordApiRetry({
|
|
196
|
+
connector: manifest.name,
|
|
197
|
+
reason: input.code === "THROTTLED" ? "rate_limit" : "server_error",
|
|
198
|
+
}),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
: Effect.void,
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
while: (error) => error._tag === "Retryable",
|
|
205
|
+
}),
|
|
206
|
+
Effect.catchTag("Retryable", (error) =>
|
|
207
|
+
fail("Shopify GraphQL returned errors", error.body, "api_graphql"),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return run.pipe(
|
|
212
|
+
Effect.timeoutOrElse({
|
|
213
|
+
duration: requestTimeout,
|
|
214
|
+
orElse: () =>
|
|
215
|
+
Effect.fail(new ConnectorError({ message: "Shopify GraphQL request timed out" })),
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return fetchGraphQL;
|
|
221
|
+
});
|
package/src/api/http.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Metrics } from "@useairfoil/connector-kit";
|
|
2
|
+
import { Duration, Effect, Predicate, Schedule } from "effect";
|
|
3
|
+
import { HttpClientResponse } from "effect/http";
|
|
4
|
+
|
|
5
|
+
import { manifest } from "../manifest";
|
|
6
|
+
|
|
7
|
+
// Effect exports the response type ID, not a guard.
|
|
8
|
+
const isResponse = (value: unknown): value is HttpClientResponse.HttpClientResponse =>
|
|
9
|
+
Predicate.hasProperty(value, HttpClientResponse.TypeId);
|
|
10
|
+
|
|
11
|
+
export const retrySchedule = (options: {
|
|
12
|
+
readonly maxRetries: number;
|
|
13
|
+
readonly baseDelay: Duration.Duration;
|
|
14
|
+
}) =>
|
|
15
|
+
Metrics.retrySchedule({
|
|
16
|
+
connector: manifest.name,
|
|
17
|
+
baseDelay: options.baseDelay,
|
|
18
|
+
times: options.maxRetries,
|
|
19
|
+
}).pipe(
|
|
20
|
+
Schedule.modifyDelay(({ input, duration }) => {
|
|
21
|
+
if (!isResponse(input) || input.status !== 429) return Effect.succeed(duration);
|
|
22
|
+
const seconds = Number(input.headers["retry-after"]);
|
|
23
|
+
return Effect.succeed(Number.isFinite(seconds) ? Duration.seconds(seconds) : duration);
|
|
24
|
+
}),
|
|
25
|
+
);
|