@drawbridge/drawbridge-utils 0.0.169 → 0.0.171

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.
@@ -1234,8 +1234,6 @@ var attentive = {
1234
1234
  // status() already stops a connection reaching Active without a
1235
1235
  // segment; this is the belt to that braces. A workflow saved before the
1236
1236
  // segment was chosen must not silently write into nothing.
1237
- if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
1238
-
1239
1237
  const email = lead?.canonical?.email?.value || lead?.email;
1240
1238
 
1241
1239
  // E.164 OR NOTHING. Attentive requires it, and a national-format number
@@ -1243,9 +1241,22 @@ var attentive = {
1243
1241
  // and the email carries the sync instead.
1244
1242
  const phone = toE164( lead?.canonical?.phone?.value || lead?.phone );
1245
1243
 
1244
+ // WHAT WENT TO THE VENDOR, so a sync run in the activity feed can
1245
+ // answer what was actually sent. The three contact syncs were the only
1246
+ // hook family carrying none, and a merchant opening one saw
1247
+ // request : null. Attentive identifies on either channel, so both are
1248
+ // recorded — which of the two carried the sync is the first question
1249
+ // anyone debugging one of these asks.
1250
+ const request = { email : email || null, phone : phone || null, segment : settings?.segment || null };
1251
+
1252
+ // status() already stops a connection reaching Active without a
1253
+ // segment; this is the belt to that braces. A workflow saved before the
1254
+ // segment was chosen must not silently write into nothing.
1255
+ if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', request, skipped : true };
1256
+
1246
1257
  // Their `user` requires phone OR email. With neither there is nobody to
1247
1258
  // subscribe, so this is a skip rather than a failure.
1248
- if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', skipped : true };
1259
+ if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', request, skipped : true };
1249
1260
 
1250
1261
  const user = {
1251
1262
  ...( email && { email }),
@@ -1285,6 +1296,7 @@ var attentive = {
1285
1296
 
1286
1297
  return {
1287
1298
  message : 'Attentive accepted an unsubscribe for this contact — they have opted out.',
1299
+ request,
1288
1300
  response : { accepted : true, unsubscribed : true }
1289
1301
  };
1290
1302
 
@@ -1332,6 +1344,7 @@ var attentive = {
1332
1344
  // queued them — a merchant who reads "synced" and looks for the person
1333
1345
  // in Attentive a second later has been told the wrong thing.
1334
1346
  message : 'Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.',
1347
+ request,
1335
1348
  response : {
1336
1349
  accepted : true,
1337
1350
  ...( membership?.batchJobId && { batchJobId : membership.batchJobId })
@@ -3353,7 +3366,7 @@ var drawbridge = {
3353
3366
  text : { max : 2000, type : 'string' }
3354
3367
  },
3355
3368
  triggers : [ 'schedule.day', 'schedule.week', 'schedule.month' ],
3356
- usage : { actions : 0 }
3369
+ usage : { actions : channels.email.actionsPerSend }
3357
3370
  }),
3358
3371
 
3359
3372
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
@@ -3379,10 +3392,14 @@ var drawbridge = {
3379
3392
  subject : { max : 150, type : 'string' }
3380
3393
  },
3381
3394
  triggers : [ 'lead.insert' ],
3382
- // Zero is a PRICE, and a deliberate one. Declared rather than omitted
3383
- // so "this is free" and "nobody decided" stay different statements;
3384
- // completeStep bills only when actions > 0.
3385
- usage : { actions : 0 }
3395
+ // EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
3396
+ // leaves our SendGrid account and costs the same to send as any other
3397
+ // email, so it carries the same weight. Whether anything is charged at
3398
+ // all is the workflow's `billable` tag, not the step's audience — and
3399
+ // this step triggers on lead.insert, so a merchant notifying
3400
+ // themselves on every entry spends against their allowance twice per
3401
+ // lead, once for the entry and once for the notification.
3402
+ usage : { actions : channels.email.actionsPerSend }
3386
3403
  }),
3387
3404
 
3388
3405
  // To a LEAD. Suppression applies and the send is billed.
@@ -3835,11 +3852,16 @@ var klaviyo = {
3835
3852
  // incomplete() already stops a connection reaching Active without a
3836
3853
  // list; this is the belt to that braces. A workflow saved before the
3837
3854
  // list was chosen must not silently write into nothing.
3838
- if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', skipped : true };
3855
+ // WHAT WENT TO THE VENDOR, so a sync run in the activity feed can answer
3856
+ // what was actually sent. The three contact syncs were the only hook
3857
+ // family that carried none, so a merchant opening one saw request : null.
3858
+ const request = { email : lead?.canonical?.email?.value || lead?.email || null, list : settings?.list || null };
3859
+
3860
+ if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', request, skipped : true };
3839
3861
 
3840
3862
  const email = lead?.canonical?.email?.value || lead?.email;
3841
3863
 
3842
- if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
3864
+ if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
3843
3865
 
3844
3866
  // A Klaviyo profile is keyed on an email, so IDENTITY comes from the
3845
3867
  // lead. RANKING comes from the contact behind it, because only a
@@ -3867,6 +3889,13 @@ var klaviyo = {
3867
3889
  // fetched 2026-09-09).
3868
3890
  //
3869
3891
  // IT MERGES, and that is why the blocks below are conditional rather
3892
+ // GROUPED OR FLAT, read as one number. contact.totals.orders and .gross
3893
+ // are { total, conversions, redemptions } now, but a contact the
3894
+ // backfill has not reached still holds the old flat number — and a
3895
+ // naked `totals.orders || 0` would hand Klaviyo the whole object as a
3896
+ // profile property. Klaviyo gets the total either way.
3897
+ const count = ( value ) => typeof value === 'number' ? value : ( value?.total || 0 );
3898
+
3870
3899
  // than defaulted. "Not including a field in your request will leave it
3871
3900
  // unchanged" — so omitting the totals on a lead.insert preserves
3872
3901
  // whatever the last segment run published, where sending zeros would
@@ -3884,13 +3913,13 @@ var klaviyo = {
3884
3913
  drawbridge_campaigns : ( person.campaigns || [] ).length,
3885
3914
  drawbridge_draws : totals.draws || 0,
3886
3915
  drawbridge_entries : totals.entries || 0,
3887
- drawbridge_orders : totals.orders || 0,
3916
+ drawbridge_orders : count( totals.orders ),
3888
3917
  // Campaign-attributed, NOT lifetime. A merchant running
3889
3918
  // Shopify already has lifetime revenue in Klaviyo through
3890
3919
  // Klaviyo's own integration; what only we can say is how
3891
3920
  // much a campaign drove. Named so the two cannot be
3892
3921
  // mistaken for one another in a segment builder.
3893
- drawbridge_revenue : totals.gross || 0
3922
+ drawbridge_revenue : count( totals.gross )
3894
3923
  }),
3895
3924
  // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3896
3925
  // merchant builds Klaviyo segments on top of. Klaviyo owns no
@@ -3927,7 +3956,7 @@ var klaviyo = {
3927
3956
 
3928
3957
  const profileId = profile?.data?.id;
3929
3958
 
3930
- if( ! profileId ) return { message : 'Klaviyo returned no profile id.', skipped : true };
3959
+ if( ! profileId ) return { message : 'Klaviyo returned no profile id.', request, skipped : true };
3931
3960
 
3932
3961
  // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
3933
3962
  //
@@ -3970,6 +3999,7 @@ var klaviyo = {
3970
3999
  message : suppressed
3971
4000
  ? 'Synced to Klaviyo as unsubscribed — this contact has opted out.'
3972
4001
  : 'Synced to the Klaviyo list.',
4002
+ request,
3973
4003
  // Recorded on the run for support to read back, not a write
3974
4004
  // instruction — the hook has already written what it needed to.
3975
4005
  response : { klaviyoProfileId : profileId }
@@ -4616,7 +4646,24 @@ const subscriberHash = ( email ) => createHash( 'md5' )
4616
4646
  // THE TAG'S NAME, and the one place it is spelled. The member write and the
4617
4647
  // register hook must agree character for character — they address the same
4618
4648
  // object, one by name and one by id — so a prefix change is one edit here.
4619
- const tagName = ( title ) => 'Drawbridge: ' + title;
4649
+ //
4650
+ // TRUNCATED TO 100, because that is Mailchimp's documented ceiling for a tag
4651
+ // name ("Tag names can be a maximum of 100 characters",
4652
+ // mailchimp.com/help/create-add-remove-tags, fetched 2026-09-12) and a segment
4653
+ // title can be 100 on its own — so the prefix pushes any title over 88 past it.
4654
+ // The limit is in their help centre and NOT in the API schema, which constrains
4655
+ // `name` to a bare string, so what the API does with an over-long name is
4656
+ // unspecified: it may reject the whole write or truncate server-side, and a
4657
+ // server-side truncation is the worse outcome because register would then look
4658
+ // for a name Mailchimp had silently changed.
4659
+ //
4660
+ // ponytail: two segments whose first 88 characters match would share one tag.
4661
+ // The upgrade is to append a short hash of the segment id, which costs the name
4662
+ // its readability in the merchant's own audience — not worth it until somebody
4663
+ // actually collides.
4664
+ const TAG_NAME_LIMIT = 100;
4665
+
4666
+ const tagName = ( title ) => ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT );
4620
4667
 
4621
4668
  // Mailchimp — contact sync, not a sender.
4622
4669
  var mailchimp = {
@@ -4773,16 +4820,24 @@ var mailchimp = {
4773
4820
 
4774
4821
  const audience = settings?.audience;
4775
4822
 
4823
+ // WHAT WENT TO THE VENDOR, recorded on the step so the activity feed
4824
+ // can answer "what did we actually send". Every other hook family
4825
+ // carries one; the three contact syncs did not, so a merchant opening
4826
+ // a sync run saw `request : null` and learned nothing — which is
4827
+ // exactly how it was reported. The audience and the address are the
4828
+ // whole identity of this write.
4829
+ const request = { audience : settings?.audience || null, email : lead?.canonical?.email?.value || lead?.email || null };
4830
+
4776
4831
  // status() already stops a connection reaching Active without an
4777
4832
  // audience; this is the belt to that braces. A workflow saved before
4778
4833
  // the audience was chosen must not silently write into nothing.
4779
- if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
4834
+ if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', request, skipped : true };
4780
4835
 
4781
4836
  const email = lead?.canonical?.email?.value || lead?.email;
4782
4837
 
4783
4838
  // A Mailchimp member IS an email address — there is no other identity
4784
4839
  // to write, so a lead without one is skipped rather than failed.
4785
- if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
4840
+ if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
4786
4841
 
4787
4842
  const hash = subscriberHash( email );
4788
4843
 
@@ -4938,6 +4993,7 @@ var mailchimp = {
4938
4993
  message : suppressed
4939
4994
  ? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
4940
4995
  : 'Synced to the Mailchimp audience.',
4996
+ request,
4941
4997
  // Recorded on the run for support to read back, not a write
4942
4998
  // instruction — the hook has already written what it needed to.
4943
4999
  response : { mailchimpMemberId : member?.id || hash }
@@ -4964,10 +5020,12 @@ var mailchimp = {
4964
5020
  // on attaching people to it by name. Both address the same object. The
4965
5021
  // segment schema says it outright: "The type of segment. Static segments
4966
5022
  // are now known as tags"
4967
- // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
5023
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Segments/Response.json,
5024
+ // fetched 2026-09-12 — the root Swagger.json carries no prose, only $refs
5025
+ // into fragment files like this one).
4968
5026
  //
4969
- // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4970
- // sweep and on backfill, it converges. That is what lets one hook serve
5027
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on a connection
5028
+ // finishing its configuration, and on the backfill migration, it converges. That is what lets one hook serve
4971
5029
  // all four without a create-vs-update branch anywhere else.
4972
5030
  register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
4973
5031
 
@@ -5033,7 +5091,9 @@ var mailchimp = {
5033
5091
  // tag-search matches on PREFIX, not on the exact name: "The search
5034
5092
  // query will be compared to each tag as a prefix, so all tags that
5035
5093
  // have a name starting with this field will be returned"
5036
- // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11). So
5094
+ // (api.mailchimp.com/schema/3.0/Parameters/PrefixTagSearchName.json,
5095
+ // fetched 2026-09-12 — the TagSearch path only $refs this parameter, and
5096
+ // the root Swagger.json carries no prose at all). So
5037
5097
  // "Drawbridge: VIP" answers for "Drawbridge: VIPs" too, and attaching
5038
5098
  // the row to the first result would point this segment at a different
5039
5099
  // merchant's tag. The exact name is filtered here.
@@ -6385,7 +6445,7 @@ var shopify = {
6385
6445
  //
6386
6446
  // The shell has already refused a missing or inactive Shopify connection,
6387
6447
  // so what is left is the two things only this hook can know are wrong.
6388
- product : async ( { connection, context, workflow }, { mintId } = {} ) => {
6448
+ product : async ( { connection, context, workflow }, { mintId, read } = {} ) => {
6389
6449
 
6390
6450
  const request = {
6391
6451
  numericId : context?.id || null,
@@ -6401,11 +6461,30 @@ var shopify = {
6401
6461
 
6402
6462
  const providerId = 'gid://shopify/Product/' + context.id;
6403
6463
 
6404
- // Minted so the enqueue can name the row this upsert makes and the
6405
- // job carries the PROVIDER identity too, because under a concurrent
6406
- // redelivery this id may be the one that lost the upsert race. The
6407
- // worker falls back to provider + shop, which are stable either way.
6408
- const productId = mintId();
6464
+ // THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
6465
+ // does not.
6466
+ //
6467
+ // The write below is an upsert whose `id` sits in $setOnInsert, so a
6468
+ // product we have seen before keeps the id it was first given and a
6469
+ // newly minted one is silently discarded. Minting unconditionally
6470
+ // therefore published an id belonging to no row: the enqueue named a
6471
+ // product the worker then had to fall back off, and the step's
6472
+ // response showed QA a value that matched nothing they could look up
6473
+ // — which is exactly how it was reported.
6474
+ //
6475
+ // Reading first costs one indexed lookup on the same key the upsert
6476
+ // matches. A concurrent redelivery can still have both runs miss and
6477
+ // mint, which is why the job carries the PROVIDER identity too and the
6478
+ // worker falls back to provider + shop — stable either way.
6479
+ const query = {
6480
+ 'provider.id' : providerId,
6481
+ 'provider.slug' : 'shopify',
6482
+ 'source.domain' : connection.shop
6483
+ };
6484
+
6485
+ const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
6486
+
6487
+ const productId = existing?.id || mintId();
6409
6488
 
6410
6489
  return {
6411
6490
  enqueues : [ {
@@ -6436,11 +6515,7 @@ var shopify = {
6436
6515
  },
6437
6516
  operation : 'update',
6438
6517
  options : { upsert : true },
6439
- query : {
6440
- 'provider.id' : providerId,
6441
- 'provider.slug' : 'shopify',
6442
- 'source.domain' : connection.shop
6443
- }
6518
+ query
6444
6519
  } ]
6445
6520
  };
6446
6521
 
@@ -6913,10 +6988,10 @@ var shopify = {
6913
6988
  queue : 'connection'
6914
6989
  } ],
6915
6990
  message : ( scopesMissing?.length
6916
- ? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
6991
+ ? 'Health check: ping ok, scopes reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
6917
6992
  : refreshTokenRotated
6918
- ? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
6919
- : 'Health check passed — ping ok, webhooks reconciled.'
6993
+ ? 'Health check passed — refresh token rotated, ping ok, scopes reconciled.'
6994
+ : 'Health check passed — ping ok, scopes reconciled.'
6920
6995
  )
6921
6996
  + ( sourceRepaired ? ' Billing source repaired from the live shop.' : '' )
6922
6997
  + ( metered === null ? ' No usage line on the store\'s approved plan — order billing needs the merchant to approve the updated plan.' : '' ),
@@ -789,10 +789,11 @@ var attentive_default2 = {
789
789
  sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
790
790
  var _a, _b, _c, _d;
791
791
  const segment = settings == null ? void 0 : settings.segment;
792
- if (!segment) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
793
792
  const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
794
793
  const phone = toE164(((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.phone) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.phone));
795
- if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", skipped: true };
794
+ const request2 = { email: email || null, phone: phone || null, segment: (settings == null ? void 0 : settings.segment) || null };
795
+ if (!segment) return { message: "No Attentive segment is chosen for this connection.", request: request2, skipped: true };
796
+ if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", request: request2, skipped: true };
796
797
  const user = {
797
798
  ...email && { email },
798
799
  ...phone && { phone }
@@ -814,6 +815,7 @@ var attentive_default2 = {
814
815
  });
815
816
  return {
816
817
  message: "Attentive accepted an unsubscribe for this contact \u2014 they have opted out.",
818
+ request: request2,
817
819
  response: { accepted: true, unsubscribed: true }
818
820
  };
819
821
  }
@@ -854,6 +856,7 @@ var attentive_default2 = {
854
856
  // queued them — a merchant who reads "synced" and looks for the person
855
857
  // in Attentive a second later has been told the wrong thing.
856
858
  message: "Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.",
859
+ request: request2,
857
860
  response: {
858
861
  accepted: true,
859
862
  ...(membership == null ? void 0 : membership.batchJobId) && { batchJobId: membership.batchJobId }
@@ -2870,7 +2873,7 @@ var drawbridge_default2 = {
2870
2873
  text: { max: 2e3, type: "string" }
2871
2874
  },
2872
2875
  triggers: ["schedule.day", "schedule.week", "schedule.month"],
2873
- usage: { actions: 0 }
2876
+ usage: { actions: channels.email.actionsPerSend }
2874
2877
  }),
2875
2878
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2876
2879
  // not silence an alert to staff — and not billed.
@@ -2895,10 +2898,14 @@ var drawbridge_default2 = {
2895
2898
  subject: { max: 150, type: "string" }
2896
2899
  },
2897
2900
  triggers: ["lead.insert"],
2898
- // Zero is a PRICE, and a deliberate one. Declared rather than omitted
2899
- // so "this is free" and "nobody decided" stay different statements;
2900
- // completeStep bills only when actions > 0.
2901
- usage: { actions: 0 }
2901
+ // EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
2902
+ // leaves our SendGrid account and costs the same to send as any other
2903
+ // email, so it carries the same weight. Whether anything is charged at
2904
+ // all is the workflow's `billable` tag, not the step's audience — and
2905
+ // this step triggers on lead.insert, so a merchant notifying
2906
+ // themselves on every entry spends against their allowance twice per
2907
+ // lead, once for the entry and once for the notification.
2908
+ usage: { actions: channels.email.actionsPerSend }
2902
2909
  }),
2903
2910
  // To a LEAD. Suppression applies and the send is billed.
2904
2911
  send: () => ({
@@ -3254,13 +3261,15 @@ var klaviyo_default2 = {
3254
3261
  // different thing from deleting the profile.
3255
3262
  remove: false,
3256
3263
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3257
- var _a, _b, _c;
3264
+ var _a, _b, _c, _d, _e;
3258
3265
  const list = settings == null ? void 0 : settings.list;
3259
- if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
3260
- const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
3261
- if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3266
+ const request2 = { email: ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email) || null, list: (settings == null ? void 0 : settings.list) || null };
3267
+ if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3268
+ const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
3269
+ if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3262
3270
  const person = (context == null ? void 0 : context.contact) || null;
3263
3271
  const totals = (person == null ? void 0 : person.totals) || {};
3272
+ const count = (value) => typeof value === "number" ? value : (value == null ? void 0 : value.total) || 0;
3264
3273
  const profile = await api2("/profile-import", {
3265
3274
  fetcher,
3266
3275
  method: "POST",
@@ -3274,13 +3283,13 @@ var klaviyo_default2 = {
3274
3283
  drawbridge_campaigns: (person.campaigns || []).length,
3275
3284
  drawbridge_draws: totals.draws || 0,
3276
3285
  drawbridge_entries: totals.entries || 0,
3277
- drawbridge_orders: totals.orders || 0,
3286
+ drawbridge_orders: count(totals.orders),
3278
3287
  // Campaign-attributed, NOT lifetime. A merchant running
3279
3288
  // Shopify already has lifetime revenue in Klaviyo through
3280
3289
  // Klaviyo's own integration; what only we can say is how
3281
3290
  // much a campaign drove. Named so the two cannot be
3282
3291
  // mistaken for one another in a segment builder.
3283
- drawbridge_revenue: totals.gross || 0
3292
+ drawbridge_revenue: count(totals.gross)
3284
3293
  },
3285
3294
  // THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
3286
3295
  // merchant builds Klaviyo segments on top of. Klaviyo owns no
@@ -3314,8 +3323,8 @@ var klaviyo_default2 = {
3314
3323
  },
3315
3324
  token
3316
3325
  });
3317
- const profileId = (_c = profile == null ? void 0 : profile.data) == null ? void 0 : _c.id;
3318
- if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
3326
+ const profileId = (_e = profile == null ? void 0 : profile.data) == null ? void 0 : _e.id;
3327
+ if (!profileId) return { message: "Klaviyo returned no profile id.", request: request2, skipped: true };
3319
3328
  await api2("/profile-subscription-bulk-create-jobs/", {
3320
3329
  fetcher,
3321
3330
  method: "POST",
@@ -3344,6 +3353,7 @@ var klaviyo_default2 = {
3344
3353
  // Merged into `context` for later steps in this run.
3345
3354
  context: { klaviyoProfileId: profileId },
3346
3355
  message: suppressed ? "Synced to Klaviyo as unsubscribed \u2014 this contact has opted out." : "Synced to the Klaviyo list.",
3356
+ request: request2,
3347
3357
  // Recorded on the run for support to read back, not a write
3348
3358
  // instruction — the hook has already written what it needed to.
3349
3359
  response: { klaviyoProfileId: profileId }
@@ -3785,7 +3795,8 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3785
3795
  return response.status === 204 ? null : response.json();
3786
3796
  };
3787
3797
  var subscriberHash = (email) => createHash2("md5").update(String(email).trim().toLowerCase()).digest("hex");
3788
- var tagName = (title) => "Drawbridge: " + title;
3798
+ var TAG_NAME_LIMIT = 100;
3799
+ var tagName = (title) => ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT);
3789
3800
  var mailchimp_default2 = {
3790
3801
  // OAUTH 2, authorization code. Every url below is quoted from
3791
3802
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -3923,15 +3934,16 @@ var mailchimp_default2 = {
3923
3934
  // why there is no create-or-update branch here. Quoted from Mailchimp's
3924
3935
  // Marketing API reference for the list-members resource.
3925
3936
  sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
3926
- var _a, _b, _c;
3937
+ var _a, _b, _c, _d, _e;
3927
3938
  const audience = settings == null ? void 0 : settings.audience;
3928
- if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
3929
- const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
3930
- if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3939
+ const request2 = { audience: (settings == null ? void 0 : settings.audience) || null, email: ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email) || null };
3940
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
3941
+ const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
3942
+ if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3931
3943
  const hash = subscriberHash(email);
3932
3944
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
3933
3945
  const lastName = restOfName.join(" ");
3934
- const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
3946
+ const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
3935
3947
  const mergeFields = {
3936
3948
  ...firstName && { FNAME: firstName },
3937
3949
  ...lastName && { LNAME: lastName },
@@ -3988,6 +4000,7 @@ var mailchimp_default2 = {
3988
4000
  // Merged into `context` for later steps in this run.
3989
4001
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
3990
4002
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4003
+ request: request2,
3991
4004
  // Recorded on the run for support to read back, not a write
3992
4005
  // instruction — the hook has already written what it needed to.
3993
4006
  response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
@@ -4010,10 +4023,12 @@ var mailchimp_default2 = {
4010
4023
  // on attaching people to it by name. Both address the same object. The
4011
4024
  // segment schema says it outright: "The type of segment. Static segments
4012
4025
  // are now known as tags"
4013
- // (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
4026
+ // (api.mailchimp.com/schema/3.0/Definitions/Lists/Segments/Response.json,
4027
+ // fetched 2026-09-12 — the root Swagger.json carries no prose, only $refs
4028
+ // into fragment files like this one).
4014
4029
  //
4015
- // IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
4016
- // sweep and on backfill, it converges. That is what lets one hook serve
4030
+ // IDEMPOTENT ON EVERY PATH: called on create, on rename, on a connection
4031
+ // finishing its configuration, and on the backfill migration, it converges. That is what lets one hook serve
4017
4032
  // all four without a create-vs-update branch anywhere else.
4018
4033
  register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
4019
4034
  var _a;
@@ -5021,7 +5036,7 @@ var shopify_default2 = {
5021
5036
  //
5022
5037
  // The shell has already refused a missing or inactive Shopify connection,
5023
5038
  // so what is left is the two things only this hook can know are wrong.
5024
- product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
5039
+ product: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => {
5025
5040
  const request2 = {
5026
5041
  numericId: (context == null ? void 0 : context.id) || null,
5027
5042
  organizationId: workflow.organization,
@@ -5030,7 +5045,13 @@ var shopify_default2 = {
5030
5045
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
5031
5046
  if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
5032
5047
  const providerId = "gid://shopify/Product/" + context.id;
5033
- const productId = mintId();
5048
+ const query = {
5049
+ "provider.id": providerId,
5050
+ "provider.slug": "shopify",
5051
+ "source.domain": connection2.shop
5052
+ };
5053
+ const existing = (read == null ? void 0 : read.get) ? await read.get({ collection: "product", query }) : null;
5054
+ const productId = (existing == null ? void 0 : existing.id) || mintId();
5034
5055
  return {
5035
5056
  enqueues: [{
5036
5057
  data: { product: productId, providerId, shop: connection2.shop },
@@ -5060,11 +5081,7 @@ var shopify_default2 = {
5060
5081
  },
5061
5082
  operation: "update",
5062
5083
  options: { upsert: true },
5063
- query: {
5064
- "provider.id": providerId,
5065
- "provider.slug": "shopify",
5066
- "source.domain": connection2.shop
5067
- }
5084
+ query
5068
5085
  }]
5069
5086
  };
5070
5087
  }
@@ -5334,7 +5351,7 @@ var shopify_default2 = {
5334
5351
  options: { jobId: "connection.update.register." + workflow.connection + "." + randomUUID2() },
5335
5352
  queue: "connection"
5336
5353
  }],
5337
- message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
5354
+ message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, scopes reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, scopes reconciled." : "Health check passed \u2014 ping ok, scopes reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
5338
5355
  request: request2,
5339
5356
  response: {
5340
5357
  billingProbeFailed,