@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.
Files changed (61) hide show
  1. package/LICENSE.txt +201 -0
  2. package/README.md +85 -248
  3. package/dist/index.d.mts +6993 -0
  4. package/dist/index.mjs +3 -0
  5. package/dist/main.d.mts +1 -0
  6. package/dist/main.mjs +700 -0
  7. package/dist/main.mjs.map +1 -0
  8. package/dist/manifest.d.mts +32 -0
  9. package/dist/manifest.mjs +51 -0
  10. package/dist/manifest.mjs.map +1 -0
  11. package/dist/{chunk-Bp6m_JJh.js → rolldown-runtime-D7D4PA-g.mjs} +4 -4
  12. package/dist/src-YBU_KSf5.mjs +2472 -0
  13. package/dist/src-YBU_KSf5.mjs.map +1 -0
  14. package/package.json +27 -27
  15. package/src/{auth.ts → api/auth.ts} +15 -35
  16. package/src/api/client.ts +429 -0
  17. package/src/api/graphql.ts +221 -0
  18. package/src/api/http.ts +25 -0
  19. package/src/connector.ts +266 -163
  20. package/src/create-table.ts +10 -0
  21. package/src/demo/customers.ts +412 -0
  22. package/src/demo/index.ts +31 -0
  23. package/src/demo/setup.ts +63 -0
  24. package/src/demo/shared.ts +114 -0
  25. package/src/index.ts +35 -21
  26. package/src/main.ts +14 -8
  27. package/src/manifest.ts +4 -55
  28. package/src/resources/carts/row.ts +82 -0
  29. package/src/resources/carts/webhook.ts +98 -0
  30. package/src/resources/customers/graphql.ts +175 -0
  31. package/src/resources/customers/row.ts +140 -0
  32. package/src/resources/customers/webhook.ts +30 -0
  33. package/src/resources/orders/graphql.ts +419 -0
  34. package/src/resources/orders/row.ts +306 -0
  35. package/src/resources/orders/webhook.ts +248 -0
  36. package/src/resources/products/graphql.ts +142 -0
  37. package/src/resources/products/row.ts +122 -0
  38. package/src/resources/products/webhook.ts +130 -0
  39. package/src/resources/refunds/graphql.ts +196 -0
  40. package/src/resources/refunds/row.ts +137 -0
  41. package/src/resources/refunds/webhook.ts +123 -0
  42. package/src/resources/shared.ts +300 -0
  43. package/src/sandbox.ts +3 -3
  44. package/src/start.ts +4 -6
  45. package/src/tables.ts +15 -0
  46. package/dist/index.d.ts +0 -965
  47. package/dist/index.js +0 -4
  48. package/dist/main.d.ts +0 -1
  49. package/dist/main.js +0 -49
  50. package/dist/main.js.map +0 -1
  51. package/dist/manifest-BFfx2LkS.js +0 -91
  52. package/dist/manifest-BFfx2LkS.js.map +0 -1
  53. package/dist/manifest-CPOEExQU.d.ts +0 -34
  54. package/dist/manifest.d.ts +0 -2
  55. package/dist/manifest.js +0 -3
  56. package/dist/src-DqmiLrYS.js +0 -984
  57. package/dist/src-DqmiLrYS.js.map +0 -1
  58. package/src/api.ts +0 -636
  59. package/src/schemas.ts +0 -319
  60. package/src/throttle.ts +0 -89
  61. package/src/transient-retry.ts +0 -74
package/src/connector.ts CHANGED
@@ -6,229 +6,332 @@ import {
6
6
  Resource,
7
7
  Webhook,
8
8
  } from "@useairfoil/connector-kit";
9
- import { Config, Context, Effect, Layer, Redacted, Schema } from "effect";
10
- import { HttpServerResponse } from "effect/unstable/http";
9
+ import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect";
10
+ import { HttpServerResponse } from "effect/http";
11
11
  import { createHmac, timingSafeEqual } from "node:crypto";
12
12
 
13
13
  import type { ShopifyConfig } from "./manifest";
14
14
 
15
- import * as ShopifyApiClient from "./api";
16
- import * as ShopifyAuth from "./auth";
15
+ import * as ShopifyAuth from "./api/auth";
16
+ import * as ShopifyApiClient from "./api/client";
17
+ import { CartSchema } from "./resources/carts/row";
17
18
  import {
18
- CartEventSchema,
19
+ CartWebhookEventSchema,
19
20
  CartWebhookPayloadSchema,
21
+ fromCartWebhook,
22
+ } from "./resources/carts/webhook";
23
+ import { CustomerSchema } from "./resources/customers/row";
24
+ import {
25
+ CustomerDeleteWebhookPayloadSchema,
26
+ CustomerEmailConsentPayloadSchema,
27
+ CustomerEventSchema,
28
+ CustomerPurchaseSummaryPayloadSchema,
29
+ CustomerSmsConsentPayloadSchema,
30
+ CustomerTagsPayloadSchema,
31
+ CustomerWebhookPayloadSchema,
32
+ } from "./resources/customers/webhook";
33
+ import { OrderSchema } from "./resources/orders/row";
34
+ import {
35
+ fromOrderWebhook,
36
+ OrderDeleteWebhookPayloadSchema,
37
+ OrderEventSchema,
38
+ OrderWebhookPayloadSchema,
39
+ } from "./resources/orders/webhook";
40
+ import { ProductSchema } from "./resources/products/row";
41
+ import {
42
+ fromProductWebhook,
20
43
  ProductDeleteWebhookPayloadSchema,
21
44
  ProductEventSchema,
22
- ProductSchema,
23
45
  ProductWebhookPayloadSchema,
24
- ShopifyNormalize,
25
- } from "./schemas";
46
+ } from "./resources/products/webhook";
47
+ import { RefundSchema } from "./resources/refunds/row";
48
+ import {
49
+ fromRefundWebhook,
50
+ RefundEventSchema,
51
+ RefundWebhookPayloadSchema,
52
+ } from "./resources/refunds/webhook";
53
+ import { gid } from "./resources/shared";
26
54
  export { manifest, ShopifyConfigDef } from "./manifest";
27
55
  export type { ShopifyConfig } from "./manifest";
28
56
 
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
- });
57
+ const isValidWebhookSignature = (
58
+ rawBody: Uint8Array,
59
+ signature: string | undefined,
60
+ secret: string,
61
+ ): boolean => {
62
+ if (!signature) return false;
63
+ const digest = createHmac("sha256", secret).update(Buffer.from(rawBody)).digest();
64
+ const provided = Buffer.from(signature, "base64");
65
+ return provided.length === digest.length && timingSafeEqual(digest, provided);
66
+ };
67
+
68
+ const decodeTriggeredAt = (value: string | undefined) =>
69
+ Option.getOrNull(Schema.decodeUnknownOption(Schema.DateFromString)(value));
53
70
 
54
- const decodeWebhookPayload = <A>(schema: Schema.Decoder<A>) => Schema.decodeUnknownEffect(schema);
71
+ const backfillPages = <A extends object>(
72
+ fetchPage: (options: {
73
+ readonly first: number;
74
+ readonly after?: string;
75
+ }) => Effect.Effect<ShopifyApiClient.ShopifyPage<A>, ConnectorError>,
76
+ ) =>
77
+ Fetch.page({
78
+ pageCursor: Cursor.string(),
79
+ cutoff: Cursor.isoDateTime(),
80
+ fetch: ({ pageCursor }) =>
81
+ fetchPage({
82
+ first: 50,
83
+ after: typeof pageCursor === "string" ? pageCursor : undefined,
84
+ }).pipe(
85
+ Effect.map((page) => ({
86
+ rows: page.items,
87
+ nextPageCursor: page.endCursor ?? undefined,
88
+ hasMore: page.hasMore,
89
+ })),
90
+ ),
91
+ });
55
92
 
56
93
  export const make = Effect.fnUntraced(function* (config: ShopifyConfig) {
57
94
  const api = yield* ShopifyApiClient.ShopifyApiClient;
58
95
 
59
96
  const Products = Resource.entity({
60
97
  name: "products",
61
- schema: ProductSchema,
62
- key: "id",
98
+ rowSchema: ProductSchema,
63
99
  version: "updatedAt",
100
+
64
101
  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({
102
+ backfill: backfillPages(api.fetchProducts),
103
+ webhook: {
85
104
  schema: ProductEventSchema,
86
105
  handler: ({ payload }) => {
87
106
  if (payload._tag === "delete") {
88
107
  return Effect.succeed([
89
- Resource.delete({
90
- key: `gid://shopify/Product/${payload.id}`,
91
- version: payload.version,
92
- }),
108
+ {
109
+ id: payload.id,
110
+ updatedAt: payload.version,
111
+ _deleted: true,
112
+ },
93
113
  ]);
94
114
  }
115
+
95
116
  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
- ]);
117
+ if (product.variants.length === product.variant_gids.length) {
118
+ return Effect.succeed([fromProductWebhook(product)]);
105
119
  }
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)]));
120
+
121
+ // Refetch when Shopify cuts off variants.
122
+ const triggeredAt = payload.triggeredAt;
123
+ return Effect.gen(function* () {
124
+ const refetched = yield* api.fetchProductById(product.admin_graphql_api_id);
125
+ if (Option.isSome(refetched)) {
126
+ return [refetched.value];
127
+ }
128
+ // A deleted product has no updatedAt, so use the webhook time.
129
+ return [{ id: product.admin_graphql_api_id, updatedAt: triggeredAt, _deleted: true }];
130
+ });
110
131
  },
111
- }),
132
+ },
112
133
  });
113
134
 
114
- const CartEvents = Resource.entity({
115
- name: "cart_events",
116
- schema: CartEventSchema,
117
- key: "id",
135
+ const Customers = Resource.entity({
136
+ name: "customers",
137
+ rowSchema: CustomerSchema,
118
138
  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
- }),
139
+
140
+ check: api.checkCustomersAccess,
141
+ backfill: backfillPages(api.fetchCustomers),
142
+ webhook: {
143
+ schema: CustomerEventSchema,
144
+ handler: ({ payload: { _tag, id, version } }) =>
145
+ Effect.gen(function* () {
146
+ // No customer means it was deleted. Its delete webhook can be missed.
147
+ const customer = _tag === "refresh" ? yield* api.fetchCustomerById(id) : Option.none();
148
+ return Option.isNone(customer)
149
+ ? [{ id, updatedAt: version, _deleted: true }]
150
+ : [customer.value];
151
+ }),
152
+ },
153
+ });
154
+
155
+ const Orders = Resource.entity({
156
+ name: "orders",
157
+ rowSchema: OrderSchema,
158
+ version: "updatedAt",
159
+
160
+ check: api.checkOrdersAccess,
161
+ backfill: backfillPages(api.fetchOrders),
162
+ webhook: {
163
+ schema: OrderEventSchema,
164
+ // Do not refetch. Shopify hides older orders without read_all_orders.
125
165
  handler: ({ payload }) =>
126
- Effect.succeed([Resource.upsert(ShopifyNormalize.cartWebhook(payload, payload.topic))]),
127
- }),
166
+ Effect.succeed(
167
+ payload._tag === "delete"
168
+ ? [{ id: payload.id, updatedAt: payload.version, _deleted: true }]
169
+ : [fromOrderWebhook(payload.payload)],
170
+ ),
171
+ },
172
+ });
173
+
174
+ const Refunds = Resource.entity({
175
+ name: "refunds",
176
+ rowSchema: RefundSchema,
177
+ // Refund webhooks have no updated_at. A later settlement with the same
178
+ // processedAt may not replace the first row.
179
+ version: "processedAt",
180
+
181
+ check: api.checkOrdersAccess,
182
+ backfill: backfillPages(api.fetchRefunds),
183
+ webhook: {
184
+ schema: RefundEventSchema,
185
+ handler: ({ payload }) => Effect.succeed([fromRefundWebhook(payload)]),
186
+ },
187
+ });
188
+
189
+ const Carts = Resource.entity({
190
+ name: "carts",
191
+ rowSchema: CartSchema,
192
+ version: "updatedAt",
193
+
194
+ check: api.checkConnection,
195
+ webhook: {
196
+ schema: CartWebhookEventSchema,
197
+ handler: ({ payload }) => Effect.succeed([fromCartWebhook(payload)]),
198
+ },
128
199
  });
129
200
 
130
201
  const webhookRoute = Webhook.route({
131
202
  path: "/webhooks/shopify",
132
- ackMode: "after-publish",
203
+ ackMode: "after-ingest",
133
204
  schema: Schema.Unknown,
134
205
  handler: ({ request, rawBody, payload: json, to }) =>
135
206
  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) {
207
+ const signature = request.headers["x-shopify-hmac-sha256"];
208
+ if (!isValidWebhookSignature(rawBody, signature, Redacted.value(config.webhookSecret))) {
142
209
  return HttpServerResponse.jsonUnsafe(
143
- { ok: false, error: verificationError.message },
210
+ { ok: false, error: "Shopify webhook verification failed" },
144
211
  { status: 401 },
145
212
  );
146
213
  }
147
214
 
148
215
  const topic = request.headers["x-shopify-topic"] ?? "";
216
+ const decode = <A>(schema: Schema.Decoder<A>) => Schema.decodeUnknownOption(schema)(json);
217
+ const invalid = HttpServerResponse.jsonUnsafe(
218
+ { ok: false, error: `Invalid Shopify webhook for ${topic}` },
219
+ { status: 400 },
220
+ );
221
+ // Shopify sends it on every webhook.
222
+ const triggeredAt = decodeTriggeredAt(request.headers["x-shopify-triggered-at"]);
223
+ if (triggeredAt === null) return invalid;
224
+
149
225
  switch (topic) {
150
226
  case "products/create":
151
227
  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 });
228
+ const payload = decode(ProductWebhookPayloadSchema);
229
+ if (Option.isNone(payload)) return invalid;
230
+ yield* to(Products, { _tag: "upsert", payload: payload.value, triggeredAt });
172
231
  break;
173
232
  }
174
233
  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
- }
234
+ const payload = decode(ProductDeleteWebhookPayloadSchema);
235
+ if (Option.isNone(payload)) return invalid;
203
236
  yield* to(Products, {
204
237
  _tag: "delete",
205
- id: String(payload.value.id),
238
+ id: gid("Product", payload.value.id),
206
239
  version: triggeredAt,
207
240
  });
208
241
  break;
209
242
  }
210
243
  case "carts/create":
211
244
  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 });
245
+ const payload = decode(CartWebhookPayloadSchema);
246
+ if (Option.isNone(payload)) return invalid;
247
+ yield* to(Carts, { ...payload.value, topic });
248
+ break;
249
+ }
250
+ case "customers/create":
251
+ case "customers/disable":
252
+ case "customers/enable":
253
+ case "customers/update": {
254
+ const payload = decode(CustomerWebhookPayloadSchema);
255
+ if (Option.isNone(payload)) return invalid;
256
+ yield* to(Customers, {
257
+ _tag: "refresh",
258
+ id: payload.value.admin_graphql_api_id,
259
+ version: payload.value.updated_at,
260
+ });
261
+ break;
262
+ }
263
+ case "customers/purchasing_summary": {
264
+ const payload = decode(CustomerPurchaseSummaryPayloadSchema);
265
+ if (Option.isNone(payload)) return invalid;
266
+ yield* to(Customers, {
267
+ _tag: "refresh",
268
+ id: payload.value.customerId,
269
+ version: payload.value.occurredAt,
270
+ });
271
+ break;
272
+ }
273
+ case "customers_email_marketing_consent/update": {
274
+ const payload = decode(CustomerEmailConsentPayloadSchema);
275
+ if (Option.isNone(payload)) return invalid;
276
+ yield* to(Customers, {
277
+ _tag: "refresh",
278
+ id: gid("Customer", payload.value.customer_id),
279
+ version: triggeredAt,
280
+ });
281
+ break;
282
+ }
283
+ case "customers_marketing_consent/update": {
284
+ const payload = decode(CustomerSmsConsentPayloadSchema);
285
+ if (Option.isNone(payload)) return invalid;
286
+ yield* to(Customers, {
287
+ _tag: "refresh",
288
+ id: gid("Customer", payload.value.id),
289
+ version: triggeredAt,
290
+ });
291
+ break;
292
+ }
293
+ case "customer.tags_added":
294
+ case "customer.tags_removed": {
295
+ const payload = decode(CustomerTagsPayloadSchema);
296
+ if (Option.isNone(payload)) return invalid;
297
+ yield* to(Customers, {
298
+ _tag: "refresh",
299
+ id: payload.value.customerId,
300
+ version: payload.value.occurredAt,
301
+ });
302
+ break;
303
+ }
304
+ case "customers/delete": {
305
+ const payload = decode(CustomerDeleteWebhookPayloadSchema);
306
+ if (Option.isNone(payload)) return invalid;
307
+ yield* to(Customers, {
308
+ _tag: "delete",
309
+ id: gid("Customer", payload.value.id),
310
+ version: triggeredAt,
311
+ });
312
+ break;
313
+ }
314
+ case "orders/create":
315
+ case "orders/updated": {
316
+ const payload = decode(OrderWebhookPayloadSchema);
317
+ if (Option.isNone(payload)) return invalid;
318
+ yield* to(Orders, { _tag: "upsert", payload: payload.value });
319
+ break;
320
+ }
321
+ case "orders/delete": {
322
+ const payload = decode(OrderDeleteWebhookPayloadSchema);
323
+ if (Option.isNone(payload)) return invalid;
324
+ yield* to(Orders, {
325
+ _tag: "delete",
326
+ id: gid("Order", payload.value.id),
327
+ version: triggeredAt,
328
+ });
329
+ break;
330
+ }
331
+ case "refunds/create": {
332
+ const payload = decode(RefundWebhookPayloadSchema);
333
+ if (Option.isNone(payload)) return invalid;
334
+ yield* to(Refunds, payload.value);
232
335
  break;
233
336
  }
234
337
  default:
@@ -244,7 +347,7 @@ export const make = Effect.fnUntraced(function* (config: ShopifyConfig) {
244
347
  return Connector.define({
245
348
  name: "producer-shopify",
246
349
  title: "Shopify",
247
- resources: [Products, CartEvents],
350
+ resources: [Products, Carts, Customers, Orders, Refunds],
248
351
  webhooks: [webhookRoute],
249
352
  });
250
353
  });
@@ -0,0 +1,10 @@
1
+ import { ConnectorApp, RuntimeConfig } from "@useairfoil/connector-kit";
2
+ import { Command } from "effect/cli";
3
+
4
+ import { tableSchemas } from "./tables";
5
+
6
+ export const createTableCommand = ConnectorApp.makeCreateTableCommand(tableSchemas);
7
+
8
+ export const setupCommand = ConnectorApp.makeSetupCommand(tableSchemas).pipe(
9
+ Command.provide(RuntimeConfig.layerHosted()),
10
+ );