@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/src/auth.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { Auth } from "@useairfoil/connector-kit";
2
+ import { Config, Context, Data, Duration, Effect, Layer, Redacted, Schema } from "effect";
3
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
+
5
+ import type { ShopifyConfig } from "./manifest";
6
+
7
+ type ShopifyAuthConfig = Pick<ShopifyConfig, "shopDomain" | "clientId" | "clientSecret">;
8
+
9
+ export type ShopifyAuthErrorReason = "shop_not_permitted" | "invalid_client" | "exchange_failed";
10
+
11
+ export class ShopifyAuthError extends Data.TaggedError("ShopifyAuthError")<{
12
+ readonly reason: ShopifyAuthErrorReason;
13
+ readonly message: string;
14
+ }> {}
15
+
16
+ export type ShopifyAuthService = Auth.TokenCache<ShopifyAuthError>;
17
+
18
+ export class ShopifyAuth extends Context.Service<ShopifyAuth, ShopifyAuthService>()(
19
+ "@useairfoil/producer-shopify/ShopifyAuth",
20
+ ) {}
21
+
22
+ const TokenResponseSchema = Schema.Struct({
23
+ access_token: Schema.String,
24
+ expires_in: Schema.Number,
25
+ });
26
+
27
+ const ErrorResponseSchema = Schema.Struct({
28
+ error: Schema.optional(Schema.String),
29
+ });
30
+
31
+ const normalizedShopDomain = (shopDomain: string): string =>
32
+ shopDomain.replace(/^https?:\/\//i, "").replace(/\/+$/g, "");
33
+
34
+ const errorFor = (reason: ShopifyAuthErrorReason): ShopifyAuthError => {
35
+ switch (reason) {
36
+ case "shop_not_permitted":
37
+ return new ShopifyAuthError({
38
+ reason,
39
+ message: "The Shopify app and store must belong to the same Shopify organization",
40
+ });
41
+ case "invalid_client":
42
+ return new ShopifyAuthError({
43
+ reason,
44
+ message: "The Shopify client ID or client secret is invalid",
45
+ });
46
+ case "exchange_failed":
47
+ return new ShopifyAuthError({
48
+ reason,
49
+ message: "Shopify authentication failed",
50
+ });
51
+ }
52
+ };
53
+
54
+ const decodeRejectedResponse = (
55
+ response: HttpClientResponse.HttpClientResponse,
56
+ ): Effect.Effect<ShopifyAuthErrorReason> =>
57
+ HttpClientResponse.schemaBodyJson(ErrorResponseSchema)(response).pipe(
58
+ Effect.map((body) => {
59
+ switch (body.error) {
60
+ case "shop_not_permitted":
61
+ return "shop_not_permitted";
62
+ case "invalid_client":
63
+ return "invalid_client";
64
+ default:
65
+ return "exchange_failed";
66
+ }
67
+ }),
68
+ Effect.catch(() => Effect.succeed<ShopifyAuthErrorReason>("exchange_failed")),
69
+ );
70
+
71
+ export const make = Effect.fnUntraced(function* (config: ShopifyAuthConfig) {
72
+ const client = yield* HttpClient.HttpClient;
73
+ const endpoint = `https://${normalizedShopDomain(config.shopDomain)}/admin/oauth/access_token`;
74
+
75
+ return yield* Auth.makeTokenCache({
76
+ acquire: Effect.scoped(
77
+ Effect.gen(function* () {
78
+ const request = HttpClientRequest.post(endpoint).pipe(
79
+ HttpClientRequest.bodyUrlParams({
80
+ grant_type: "client_credentials",
81
+ client_id: config.clientId,
82
+ client_secret: Redacted.value(config.clientSecret),
83
+ }),
84
+ );
85
+ const response = yield* client
86
+ .execute(request)
87
+ .pipe(Effect.mapError(() => errorFor("exchange_failed")));
88
+ const successfulResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(
89
+ Effect.catchTag("HttpClientError", () =>
90
+ decodeRejectedResponse(response).pipe(
91
+ Effect.flatMap((reason) => Effect.fail(errorFor(reason))),
92
+ ),
93
+ ),
94
+ );
95
+ const body = yield* HttpClientResponse.schemaBodyJson(TokenResponseSchema)(
96
+ successfulResponse,
97
+ ).pipe(Effect.mapError(() => errorFor("exchange_failed")));
98
+
99
+ return {
100
+ value: Redacted.make(body.access_token),
101
+ expiresIn: Duration.seconds(body.expires_in),
102
+ };
103
+ }),
104
+ ),
105
+ });
106
+ });
107
+
108
+ export const layer = (
109
+ config: ShopifyAuthConfig,
110
+ ): Layer.Layer<ShopifyAuth, never, HttpClient.HttpClient> =>
111
+ Layer.effect(ShopifyAuth)(make(config));
112
+
113
+ export const layerConfig = (
114
+ config: Config.Wrap<ShopifyAuthConfig>,
115
+ ): Layer.Layer<ShopifyAuth, Config.ConfigError, HttpClient.HttpClient> =>
116
+ Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
@@ -0,0 +1,268 @@
1
+ import {
2
+ Connector,
3
+ ConnectorError,
4
+ Cursor,
5
+ Fetch,
6
+ Resource,
7
+ Webhook,
8
+ } from "@useairfoil/connector-kit";
9
+ import { Config, Context, Effect, Layer, Redacted, Schema } from "effect";
10
+ import { HttpServerResponse } from "effect/unstable/http";
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+
13
+ import type { ShopifyConfig } from "./manifest";
14
+
15
+ import * as ShopifyApiClient from "./api";
16
+ import * as ShopifyAuth from "./auth";
17
+ import {
18
+ CartEventSchema,
19
+ CartWebhookPayloadSchema,
20
+ ProductDeleteWebhookPayloadSchema,
21
+ ProductEventSchema,
22
+ ProductSchema,
23
+ ProductWebhookPayloadSchema,
24
+ ShopifyNormalize,
25
+ } from "./schemas";
26
+ export { manifest, ShopifyConfigDef } from "./manifest";
27
+ export type { ShopifyConfig } from "./manifest";
28
+
29
+ const verifyWebhookSignature = (options: {
30
+ readonly rawBody: Uint8Array;
31
+ readonly signature: string | null;
32
+ readonly secret: string;
33
+ }): Effect.Effect<void, ConnectorError> =>
34
+ Effect.try({
35
+ try: () => {
36
+ if (!options.signature) {
37
+ throw new Error("Missing x-shopify-hmac-sha256 header");
38
+ }
39
+ const digest = createHmac("sha256", options.secret)
40
+ .update(Buffer.from(options.rawBody))
41
+ .digest();
42
+ const provided = Buffer.from(options.signature, "base64");
43
+ if (provided.length !== digest.length || !timingSafeEqual(digest, provided)) {
44
+ throw new Error("Invalid Shopify webhook signature");
45
+ }
46
+ },
47
+ catch: (cause) =>
48
+ new ConnectorError({
49
+ message: "Shopify webhook verification failed",
50
+ cause,
51
+ }),
52
+ });
53
+
54
+ const decodeWebhookPayload = <A>(schema: Schema.Decoder<A>) => Schema.decodeUnknownEffect(schema);
55
+
56
+ export const make = Effect.fnUntraced(function* (config: ShopifyConfig) {
57
+ const api = yield* ShopifyApiClient.ShopifyApiClient;
58
+
59
+ const Products = Resource.entity({
60
+ name: "products",
61
+ schema: ProductSchema,
62
+ key: "id",
63
+ version: "updatedAt",
64
+ check: api.checkProductsAccess,
65
+ backfill: Fetch.page({
66
+ pageCursor: Cursor.string(),
67
+ cutoff: Cursor.isoDateTime(),
68
+ fetch: ({ pageCursor, cutoff }) =>
69
+ api
70
+ .fetchProducts({
71
+ first: 50,
72
+ after: typeof pageCursor === "string" ? pageCursor : undefined,
73
+ })
74
+ .pipe(
75
+ Effect.map((page) => ({
76
+ mutations: page.items
77
+ .filter((row) => Date.parse(row.updatedAt) <= Date.parse(String(cutoff)))
78
+ .map(Resource.upsert),
79
+ nextPageCursor: page.endCursor ?? undefined,
80
+ hasMore: page.hasMore,
81
+ })),
82
+ ),
83
+ }),
84
+ webhook: Resource.webhook({
85
+ schema: ProductEventSchema,
86
+ handler: ({ payload }) => {
87
+ if (payload._tag === "delete") {
88
+ return Effect.succeed([
89
+ Resource.delete({
90
+ key: `gid://shopify/Product/${payload.id}`,
91
+ version: payload.version,
92
+ }),
93
+ ]);
94
+ }
95
+ const product = payload.payload;
96
+ if (
97
+ product.created_at !== null &&
98
+ product.variants.length === product.variant_gids.length
99
+ ) {
100
+ return Effect.succeed([
101
+ Resource.upsert(
102
+ ShopifyNormalize.productWebhook({ ...product, created_at: product.created_at }),
103
+ ),
104
+ ]);
105
+ }
106
+ // Refetch when Shopify truncates variants or omits the creation time.
107
+ return api
108
+ .fetchProductById(product.admin_graphql_api_id)
109
+ .pipe(Effect.map((row) => [Resource.upsert(row)]));
110
+ },
111
+ }),
112
+ });
113
+
114
+ const CartEvents = Resource.entity({
115
+ name: "cart_events",
116
+ schema: CartEventSchema,
117
+ key: "id",
118
+ version: "updatedAt",
119
+ check: api.checkConnection,
120
+ webhook: Resource.webhook({
121
+ schema: Schema.Struct({
122
+ ...CartWebhookPayloadSchema.fields,
123
+ topic: Schema.Literals(["carts/create", "carts/update"]),
124
+ }),
125
+ handler: ({ payload }) =>
126
+ Effect.succeed([Resource.upsert(ShopifyNormalize.cartWebhook(payload, payload.topic))]),
127
+ }),
128
+ });
129
+
130
+ const webhookRoute = Webhook.route({
131
+ path: "/webhooks/shopify",
132
+ ackMode: "after-publish",
133
+ schema: Schema.Unknown,
134
+ handler: ({ request, rawBody, payload: json, to }) =>
135
+ Effect.gen(function* () {
136
+ const verificationError = yield* verifyWebhookSignature({
137
+ rawBody,
138
+ signature: request.headers["x-shopify-hmac-sha256"] ?? null,
139
+ secret: Redacted.value(config.webhookSecret),
140
+ }).pipe(Effect.match({ onFailure: (error) => error, onSuccess: () => undefined }));
141
+ if (verificationError) {
142
+ return HttpServerResponse.jsonUnsafe(
143
+ { ok: false, error: verificationError.message },
144
+ { status: 401 },
145
+ );
146
+ }
147
+
148
+ const topic = request.headers["x-shopify-topic"] ?? "";
149
+ switch (topic) {
150
+ case "products/create":
151
+ case "products/update": {
152
+ const payload = yield* decodeWebhookPayload(ProductWebhookPayloadSchema)(json).pipe(
153
+ Effect.mapError(
154
+ (cause) =>
155
+ new ConnectorError({
156
+ message: `Invalid Shopify webhook payload for ${topic}`,
157
+ cause,
158
+ }),
159
+ ),
160
+ Effect.match({
161
+ onFailure: (error) => ({ _tag: "Error" as const, error }),
162
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
163
+ }),
164
+ );
165
+ if (payload._tag === "Error") {
166
+ return HttpServerResponse.jsonUnsafe(
167
+ { ok: false, error: payload.error.message },
168
+ { status: 400 },
169
+ );
170
+ }
171
+ yield* to(Products, { _tag: "upsert", payload: payload.value });
172
+ break;
173
+ }
174
+ case "products/delete": {
175
+ const triggeredAt = request.headers["x-shopify-triggered-at"];
176
+ if (!triggeredAt) {
177
+ return HttpServerResponse.jsonUnsafe(
178
+ { ok: false, error: "Missing x-shopify-triggered-at header" },
179
+ { status: 400 },
180
+ );
181
+ }
182
+ const payload = yield* decodeWebhookPayload(ProductDeleteWebhookPayloadSchema)(
183
+ json,
184
+ ).pipe(
185
+ Effect.mapError(
186
+ (cause) =>
187
+ new ConnectorError({
188
+ message: "Invalid Shopify webhook payload for products/delete",
189
+ cause,
190
+ }),
191
+ ),
192
+ Effect.match({
193
+ onFailure: (error) => ({ _tag: "Error" as const, error }),
194
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
195
+ }),
196
+ );
197
+ if (payload._tag === "Error") {
198
+ return HttpServerResponse.jsonUnsafe(
199
+ { ok: false, error: payload.error.message },
200
+ { status: 400 },
201
+ );
202
+ }
203
+ yield* to(Products, {
204
+ _tag: "delete",
205
+ id: String(payload.value.id),
206
+ version: triggeredAt,
207
+ });
208
+ break;
209
+ }
210
+ case "carts/create":
211
+ case "carts/update": {
212
+ const payload = yield* decodeWebhookPayload(CartWebhookPayloadSchema)(json).pipe(
213
+ Effect.mapError(
214
+ (cause) =>
215
+ new ConnectorError({
216
+ message: `Invalid Shopify webhook payload for ${topic}`,
217
+ cause,
218
+ }),
219
+ ),
220
+ Effect.match({
221
+ onFailure: (error) => ({ _tag: "Error" as const, error }),
222
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
223
+ }),
224
+ );
225
+ if (payload._tag === "Error") {
226
+ return HttpServerResponse.jsonUnsafe(
227
+ { ok: false, error: payload.error.message },
228
+ { status: 400 },
229
+ );
230
+ }
231
+ yield* to(CartEvents, { ...payload.value, topic });
232
+ break;
233
+ }
234
+ default:
235
+ yield* Effect.logWarning("Ignoring unknown Shopify webhook topic").pipe(
236
+ Effect.annotateLogs({ topic }),
237
+ );
238
+ }
239
+
240
+ return HttpServerResponse.jsonUnsafe({ ok: true });
241
+ }),
242
+ });
243
+
244
+ return Connector.define({
245
+ name: "producer-shopify",
246
+ title: "Shopify",
247
+ resources: [Products, CartEvents],
248
+ webhooks: [webhookRoute],
249
+ });
250
+ });
251
+
252
+ export type ShopifyConnectorRuntime = Effect.Success<ReturnType<typeof make>>;
253
+
254
+ export class ShopifyConnector extends Context.Service<ShopifyConnector, ShopifyConnectorRuntime>()(
255
+ "@useairfoil/producer-shopify/ShopifyConnector",
256
+ ) {}
257
+
258
+ export const layer = (config: ShopifyConfig) => {
259
+ const authLayer = ShopifyAuth.layer(config);
260
+ const apiLayer = ShopifyApiClient.layer(config).pipe(Layer.provide(authLayer));
261
+
262
+ return Layer.effect(ShopifyConnector)(
263
+ make(config).pipe(Effect.annotateLogs({ component: "producer-shopify" })),
264
+ ).pipe(Layer.provide(apiLayer));
265
+ };
266
+
267
+ export const layerConfig = (config: Config.Wrap<ShopifyConfig>) =>
268
+ Layer.unwrap(Config.unwrap(config).pipe(Effect.map(layer)));
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ export * as ShopifyApiClient from "./api";
2
+ export * as ShopifyConnector from "./connector";
3
+ export { manifest } from "./manifest";
4
+ export type {
5
+ CartEvent,
6
+ CartLineItem,
7
+ CartWebhookPayload,
8
+ Money,
9
+ MoneyBag,
10
+ PageInfo,
11
+ Product,
12
+ ProductDeleteWebhookPayload,
13
+ ProductOption,
14
+ ProductStatus,
15
+ ProductVariant,
16
+ ProductVariantInventoryPolicy,
17
+ ProductWebhookPayload,
18
+ WebhookPayload,
19
+ } from "./schemas";
20
+ export {
21
+ CartEventSchema,
22
+ CartLineItemSchema,
23
+ CartWebhookPayloadSchema,
24
+ MoneyBagSchema,
25
+ MoneySchema,
26
+ PageInfoSchema,
27
+ ProductOptionSchema,
28
+ ProductDeleteWebhookPayloadSchema,
29
+ ProductSchema,
30
+ ProductStatusSchema,
31
+ ProductVariantInventoryPolicySchema,
32
+ ProductVariantSchema,
33
+ ProductWebhookPayloadSchema,
34
+ ShopifyNormalize,
35
+ WebhookPayloadSchema,
36
+ } from "./schemas";
package/src/main.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
2
+ import { Effect, Layer } from "effect";
3
+ import { Command } from "effect/unstable/cli";
4
+ import { FetchHttpClient } from "effect/unstable/http";
5
+
6
+ import packageJson from "../package.json";
7
+ import { sandboxCommand } from "./sandbox";
8
+ import { startCommand } from "./start";
9
+
10
+ const BootstrapLayer = Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer);
11
+
12
+ const program = Command.make("producer-shopify", {}, () => Effect.void).pipe(
13
+ Command.withSubcommands([startCommand, sandboxCommand]),
14
+ );
15
+
16
+ Command.run(program, { version: packageJson.version }).pipe(
17
+ Effect.provide(BootstrapLayer),
18
+ Effect.scoped,
19
+ NodeRuntime.runMain,
20
+ );
@@ -0,0 +1,86 @@
1
+ import * as Manifest from "@useairfoil/connector-kit/manifest";
2
+
3
+ export const ShopifyConfigDef = Manifest.defineConfig({
4
+ shopDomain: Manifest.string({
5
+ runtimeKey: "SHOPIFY_SHOP_DOMAIN",
6
+ description: "Shopify shop domain, for example example.myshopify.com.",
7
+ }),
8
+ apiVersion: Manifest.string({
9
+ runtimeKey: "SHOPIFY_API_VERSION",
10
+ description: "Shopify Admin API version used in GraphQL endpoint URLs.",
11
+ default: "2026-07",
12
+ }),
13
+ clientId: Manifest.string({
14
+ runtimeKey: "SHOPIFY_CLIENT_ID",
15
+ description: "Client ID for the merchant-owned Shopify Dev Dashboard app.",
16
+ }),
17
+ clientSecret: Manifest.secret({
18
+ runtimeKey: "SHOPIFY_CLIENT_SECRET",
19
+ description: "Client secret for the merchant-owned Shopify Dev Dashboard app.",
20
+ }),
21
+ responseMaxRetries: Manifest.number({
22
+ runtimeKey: "SHOPIFY_RESPONSE_MAX_RETRIES",
23
+ description: "Maximum retries for temporary HTTP responses.",
24
+ default: 5,
25
+ integer: true,
26
+ minimum: 0,
27
+ }),
28
+ transportMaxRetries: Manifest.number({
29
+ runtimeKey: "SHOPIFY_TRANSPORT_MAX_RETRIES",
30
+ description: "Maximum retries for connection and transport failures.",
31
+ default: 5,
32
+ integer: true,
33
+ minimum: 0,
34
+ }),
35
+ graphqlMaxRetries: Manifest.number({
36
+ runtimeKey: "SHOPIFY_GRAPHQL_MAX_RETRIES",
37
+ description: "Maximum retries for temporary GraphQL errors.",
38
+ default: 5,
39
+ integer: true,
40
+ minimum: 0,
41
+ }),
42
+ retryBaseDelayMs: Manifest.number({
43
+ runtimeKey: "SHOPIFY_RETRY_BASE_DELAY_MS",
44
+ description: "Initial delay for HTTP and transport retries.",
45
+ default: 200,
46
+ integer: true,
47
+ minimum: 1,
48
+ }),
49
+ graphqlRetryBaseDelayMs: Manifest.number({
50
+ runtimeKey: "SHOPIFY_GRAPHQL_RETRY_BASE_DELAY_MS",
51
+ description: "Initial delay for temporary GraphQL errors.",
52
+ default: 500,
53
+ integer: true,
54
+ minimum: 1,
55
+ }),
56
+ retryAfterFallbackSeconds: Manifest.number({
57
+ runtimeKey: "SHOPIFY_RETRY_AFTER_FALLBACK_SECONDS",
58
+ description: "Retry delay used when Shopify omits Retry-After.",
59
+ default: 1,
60
+ integer: true,
61
+ minimum: 1,
62
+ }),
63
+ requestTimeoutSeconds: Manifest.number({
64
+ runtimeKey: "SHOPIFY_REQUEST_TIMEOUT_SECONDS",
65
+ description: "Maximum request time including retries.",
66
+ default: 120,
67
+ integer: true,
68
+ minimum: 1,
69
+ }),
70
+ webhookSecret: Manifest.secret({
71
+ runtimeKey: "SHOPIFY_WEBHOOK_SECRET",
72
+ description: "Shopify webhook HMAC secret.",
73
+ }),
74
+ });
75
+
76
+ export type ShopifyConfig = Manifest.ConfigValuesOf<typeof ShopifyConfigDef>;
77
+
78
+ export const manifest = Manifest.define({
79
+ name: "producer-shopify",
80
+ title: "Shopify",
81
+ config: ShopifyConfigDef.spec,
82
+ resources: [
83
+ { name: "products", capabilities: ["backfill", "webhook"] },
84
+ { name: "cart_events", capabilities: ["webhook"] },
85
+ ],
86
+ });
package/src/sandbox.ts ADDED
@@ -0,0 +1,38 @@
1
+ import {
2
+ ConnectorApp,
3
+ Publisher,
4
+ RuntimeConfig,
5
+ StateStore,
6
+ Telemetry,
7
+ } from "@useairfoil/connector-kit";
8
+ import { Effect, Layer, Logger } from "effect";
9
+ import { Command } from "effect/unstable/cli";
10
+
11
+ import { ShopifyConnector } from "./index";
12
+
13
+ const ConnectorLayer = ShopifyConnector.layerConfig(ShopifyConnector.ShopifyConfigDef.config);
14
+
15
+ const TelemetryLayer = Layer.mergeAll(
16
+ Telemetry.layerOtlp({ redactedHeaders: ["x-shopify-access-token"] }),
17
+ Telemetry.layerMetricsConsoleDump(),
18
+ );
19
+
20
+ export const sandboxCommand = Command.make("sandbox", {}, () =>
21
+ Effect.gen(function* () {
22
+ const port = yield* RuntimeConfig.httpPort;
23
+ const entrypoint = yield* ShopifyConnector.ShopifyConnector;
24
+
25
+ return yield* ConnectorApp.start(entrypoint, { port });
26
+ }).pipe(
27
+ Effect.annotateLogs({ component: "producer-shopify" }),
28
+ Effect.provide(
29
+ Layer.mergeAll(
30
+ StateStore.layerMemory,
31
+ Publisher.layerConsole,
32
+ ConnectorLayer,
33
+ Logger.layer([Logger.consolePretty()]),
34
+ TelemetryLayer,
35
+ ),
36
+ ),
37
+ ),
38
+ ).pipe(Command.withDescription("Run the connector locally and log ingested data"));