@drawbridge/drawbridge-utils 0.0.170 → 0.0.172

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.
@@ -618,6 +618,7 @@ var row = ({ connection: connection2, data: data2, manifest, row: described }) =
618
618
  };
619
619
  };
620
620
  var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
621
+ if ((described == null ? void 0 : described.id) === void 0 || (described == null ? void 0 : described.id) === null) return [];
621
622
  const built = row({ connection: connection2, data: data2, manifest, row: described });
622
623
  return [
623
624
  // PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
@@ -865,10 +866,11 @@ var attentive_default2 = {
865
866
  sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
866
867
  var _a, _b, _c, _d;
867
868
  const segment = settings == null ? void 0 : settings.segment;
868
- if (!segment) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
869
869
  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);
870
870
  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));
871
- if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", skipped: true };
871
+ const request2 = { email: email || null, phone: phone || null, segment: (settings == null ? void 0 : settings.segment) || null };
872
+ if (!segment) return { message: "No Attentive segment is chosen for this connection.", request: request2, skipped: true };
873
+ if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", request: request2, skipped: true };
872
874
  const user = {
873
875
  ...email && { email },
874
876
  ...phone && { phone }
@@ -890,6 +892,7 @@ var attentive_default2 = {
890
892
  });
891
893
  return {
892
894
  message: "Attentive accepted an unsubscribe for this contact \u2014 they have opted out.",
895
+ request: request2,
893
896
  response: { accepted: true, unsubscribed: true }
894
897
  };
895
898
  }
@@ -930,6 +933,7 @@ var attentive_default2 = {
930
933
  // queued them — a merchant who reads "synced" and looks for the person
931
934
  // in Attentive a second later has been told the wrong thing.
932
935
  message: "Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.",
936
+ request: request2,
933
937
  response: {
934
938
  accepted: true,
935
939
  ...(membership == null ? void 0 : membership.batchJobId) && { batchJobId: membership.batchJobId }
@@ -2946,7 +2950,7 @@ var drawbridge_default2 = {
2946
2950
  text: { max: 2e3, type: "string" }
2947
2951
  },
2948
2952
  triggers: ["schedule.day", "schedule.week", "schedule.month"],
2949
- usage: { actions: 0 }
2953
+ usage: { actions: channels.email.actionsPerSend }
2950
2954
  }),
2951
2955
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2952
2956
  // not silence an alert to staff — and not billed.
@@ -2971,10 +2975,14 @@ var drawbridge_default2 = {
2971
2975
  subject: { max: 150, type: "string" }
2972
2976
  },
2973
2977
  triggers: ["lead.insert"],
2974
- // Zero is a PRICE, and a deliberate one. Declared rather than omitted
2975
- // so "this is free" and "nobody decided" stay different statements;
2976
- // completeStep bills only when actions > 0.
2977
- usage: { actions: 0 }
2978
+ // EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
2979
+ // leaves our SendGrid account and costs the same to send as any other
2980
+ // email, so it carries the same weight. Whether anything is charged at
2981
+ // all is the workflow's `billable` tag, not the step's audience — and
2982
+ // this step triggers on lead.insert, so a merchant notifying
2983
+ // themselves on every entry spends against their allowance twice per
2984
+ // lead, once for the entry and once for the notification.
2985
+ usage: { actions: channels.email.actionsPerSend }
2978
2986
  }),
2979
2987
  // To a LEAD. Suppression applies and the send is billed.
2980
2988
  send: () => ({
@@ -3083,7 +3091,7 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
3083
3091
  }
3084
3092
  return response.status === 204 ? null : response.json();
3085
3093
  };
3086
- var segmentName = (title) => "Drawbridge: " + title;
3094
+ var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
3087
3095
  var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
3088
3096
  var klaviyo_default2 = {
3089
3097
  // OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
@@ -3330,11 +3338,12 @@ var klaviyo_default2 = {
3330
3338
  // different thing from deleting the profile.
3331
3339
  remove: false,
3332
3340
  sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
3333
- var _a, _b, _c;
3341
+ var _a, _b, _c, _d, _e;
3334
3342
  const list = settings == null ? void 0 : settings.list;
3335
- if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
3336
- 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);
3337
- if (!email) return { message: "That lead has no email address to sync.", skipped: true };
3343
+ 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 };
3344
+ if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
3345
+ 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);
3346
+ if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
3338
3347
  const person = (context == null ? void 0 : context.contact) || null;
3339
3348
  const totals = (person == null ? void 0 : person.totals) || {};
3340
3349
  const count = (value) => typeof value === "number" ? value : (value == null ? void 0 : value.total) || 0;
@@ -3391,8 +3400,8 @@ var klaviyo_default2 = {
3391
3400
  },
3392
3401
  token
3393
3402
  });
3394
- const profileId = (_c = profile == null ? void 0 : profile.data) == null ? void 0 : _c.id;
3395
- if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
3403
+ const profileId = (_e = profile == null ? void 0 : profile.data) == null ? void 0 : _e.id;
3404
+ if (!profileId) return { message: "Klaviyo returned no profile id.", request: request2, skipped: true };
3396
3405
  await api2("/profile-subscription-bulk-create-jobs/", {
3397
3406
  fetcher,
3398
3407
  method: "POST",
@@ -3421,6 +3430,7 @@ var klaviyo_default2 = {
3421
3430
  // Merged into `context` for later steps in this run.
3422
3431
  context: { klaviyoProfileId: profileId },
3423
3432
  message: suppressed ? "Synced to Klaviyo as unsubscribed \u2014 this contact has opted out." : "Synced to the Klaviyo list.",
3433
+ request: request2,
3424
3434
  // Recorded on the run for support to read back, not a write
3425
3435
  // instruction — the hook has already written what it needed to.
3426
3436
  response: { klaviyoProfileId: profileId }
@@ -3456,7 +3466,7 @@ var klaviyo_default2 = {
3456
3466
  skipped: true
3457
3467
  };
3458
3468
  }
3459
- const name = segmentName(segment.title);
3469
+ const name = segmentName(segment.title, segment.id);
3460
3470
  const existing = segmentRowFor({ connection: connection2, segment });
3461
3471
  let id = null;
3462
3472
  if (existing == null ? void 0 : existing.id) {
@@ -3863,7 +3873,10 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
3863
3873
  };
3864
3874
  var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
3865
3875
  var TAG_NAME_LIMIT = 100;
3866
- var tagName = (title) => ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT);
3876
+ var tagName = (title, id) => {
3877
+ const suffix = " (" + String(id).slice(-6) + ")";
3878
+ return ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT - suffix.length) + suffix;
3879
+ };
3867
3880
  var mailchimp_default2 = {
3868
3881
  // OAUTH 2, authorization code. Every url below is quoted from
3869
3882
  // mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
@@ -4001,15 +4014,16 @@ var mailchimp_default2 = {
4001
4014
  // why there is no create-or-update branch here. Quoted from Mailchimp's
4002
4015
  // Marketing API reference for the list-members resource.
4003
4016
  sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
4004
- var _a, _b, _c;
4017
+ var _a, _b, _c, _d, _e;
4005
4018
  const audience = settings == null ? void 0 : settings.audience;
4006
- if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4007
- 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);
4008
- if (!email) return { message: "That lead has no email address to sync.", skipped: true };
4019
+ 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 };
4020
+ if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
4021
+ 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);
4022
+ if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
4009
4023
  const hash = subscriberHash(email);
4010
4024
  const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
4011
4025
  const lastName = restOfName.join(" ");
4012
- const phone = ((_c = lead == null ? void 0 : lead.phone) == null ? void 0 : _c.number) || null;
4026
+ const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
4013
4027
  const mergeFields = {
4014
4028
  ...firstName && { FNAME: firstName },
4015
4029
  ...lastName && { LNAME: lastName },
@@ -4043,14 +4057,17 @@ var mailchimp_default2 = {
4043
4057
  }
4044
4058
  },
4045
4059
  {
4046
- $project: { _id: 0, title: 1 }
4060
+ // THE ID AS WELL AS THE TITLE, because the tag name carries it —
4061
+ // see tagName. Without it every name here would end in the
4062
+ // string 'undefined' and match nothing register wrote.
4063
+ $project: { _id: 0, id: 1, title: 1 }
4047
4064
  }
4048
4065
  ]
4049
4066
  });
4050
- const joined = new Set(segments.map((entry) => entry.title));
4051
- const tags = (owned || []).map((entry) => entry.title).filter(Boolean).map((title) => ({
4052
- name: tagName(title),
4053
- status: joined.has(title) ? "active" : "inactive"
4067
+ const joined = new Set(segments.map((entry) => entry.id));
4068
+ const tags = (owned || []).filter((entry) => entry.id && entry.title).map((entry) => ({
4069
+ name: tagName(entry.title, entry.id),
4070
+ status: joined.has(entry.id) ? "active" : "inactive"
4054
4071
  }));
4055
4072
  if (tags.length > 0) {
4056
4073
  await api3("/lists/" + audience + "/members/" + hash + "/tags", {
@@ -4066,6 +4083,7 @@ var mailchimp_default2 = {
4066
4083
  // Merged into `context` for later steps in this run.
4067
4084
  context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
4068
4085
  message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
4086
+ request: request2,
4069
4087
  // Recorded on the run for support to read back, not a write
4070
4088
  // instruction — the hook has already written what it needed to.
4071
4089
  response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
@@ -4101,7 +4119,7 @@ var mailchimp_default2 = {
4101
4119
  if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
4102
4120
  const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
4103
4121
  if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
4104
- const name = tagName(segment.title);
4122
+ const name = tagName(segment.title, segment.id);
4105
4123
  const existing = segmentRowFor({ connection: connection2, segment });
4106
4124
  let id = null;
4107
4125
  if (existing == null ? void 0 : existing.id) {
@@ -4654,10 +4672,6 @@ var shopify_default2 = {
4654
4672
  if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
4655
4673
  if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
4656
4674
  const adminAccessToken = await adminToken();
4657
- if (!context.shopifyCustomerId) {
4658
- const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
4659
- if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
4660
- }
4661
4675
  const discountCode = await shopify.admin.createDiscountCode({
4662
4676
  adminAccessToken,
4663
4677
  code: "DB-" + generateDiscountCode(),
@@ -5101,7 +5115,7 @@ var shopify_default2 = {
5101
5115
  //
5102
5116
  // The shell has already refused a missing or inactive Shopify connection,
5103
5117
  // so what is left is the two things only this hook can know are wrong.
5104
- product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
5118
+ product: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => {
5105
5119
  const request2 = {
5106
5120
  numericId: (context == null ? void 0 : context.id) || null,
5107
5121
  organizationId: workflow.organization,
@@ -5110,7 +5124,13 @@ var shopify_default2 = {
5110
5124
  if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
5111
5125
  if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
5112
5126
  const providerId = "gid://shopify/Product/" + context.id;
5113
- const productId = mintId();
5127
+ const query = {
5128
+ "provider.id": providerId,
5129
+ "provider.slug": "shopify",
5130
+ "source.domain": connection2.shop
5131
+ };
5132
+ const existing = (read == null ? void 0 : read.get) ? await read.get({ collection: "product", query }) : null;
5133
+ const productId = (existing == null ? void 0 : existing.id) || mintId();
5114
5134
  return {
5115
5135
  enqueues: [{
5116
5136
  data: { product: productId, providerId, shop: connection2.shop },
@@ -5140,11 +5160,7 @@ var shopify_default2 = {
5140
5160
  },
5141
5161
  operation: "update",
5142
5162
  options: { upsert: true },
5143
- query: {
5144
- "provider.id": providerId,
5145
- "provider.slug": "shopify",
5146
- "source.domain": connection2.shop
5147
- }
5163
+ query
5148
5164
  }]
5149
5165
  };
5150
5166
  }
@@ -5414,7 +5430,7 @@ var shopify_default2 = {
5414
5430
  options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto6.randomUUID)() },
5415
5431
  queue: "connection"
5416
5432
  }],
5417
- 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." : ""),
5433
+ 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." : ""),
5418
5434
  request: request2,
5419
5435
  response: {
5420
5436
  billingProbeFailed,
@@ -854,6 +854,13 @@ const row = ({ connection, data, manifest, row : described }) => ({
854
854
 
855
855
  const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
856
856
 
857
+ // NO ID, NO ROW. `String( undefined )` is the string 'undefined', which is a
858
+ // perfectly valid string as far as the api's schema is concerned — so a row
859
+ // built without a vendor id would store and then render as a live-looking
860
+ // link to an object that does not exist. The three hooks all guard before
861
+ // they get here; this is the guard that does not depend on them remembering.
862
+ if( described?.id === undefined || described?.id === null ) return [];
863
+
857
864
  const built = row({ connection, data, manifest, row : described });
858
865
 
859
866
  return [
@@ -1234,8 +1241,6 @@ var attentive = {
1234
1241
  // status() already stops a connection reaching Active without a
1235
1242
  // segment; this is the belt to that braces. A workflow saved before the
1236
1243
  // 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
1244
  const email = lead?.canonical?.email?.value || lead?.email;
1240
1245
 
1241
1246
  // E.164 OR NOTHING. Attentive requires it, and a national-format number
@@ -1243,9 +1248,22 @@ var attentive = {
1243
1248
  // and the email carries the sync instead.
1244
1249
  const phone = toE164( lead?.canonical?.phone?.value || lead?.phone );
1245
1250
 
1251
+ // WHAT WENT TO THE VENDOR, so a sync run in the activity feed can
1252
+ // answer what was actually sent. The three contact syncs were the only
1253
+ // hook family carrying none, and a merchant opening one saw
1254
+ // request : null. Attentive identifies on either channel, so both are
1255
+ // recorded — which of the two carried the sync is the first question
1256
+ // anyone debugging one of these asks.
1257
+ const request = { email : email || null, phone : phone || null, segment : settings?.segment || null };
1258
+
1259
+ // status() already stops a connection reaching Active without a
1260
+ // segment; this is the belt to that braces. A workflow saved before the
1261
+ // segment was chosen must not silently write into nothing.
1262
+ if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', request, skipped : true };
1263
+
1246
1264
  // Their `user` requires phone OR email. With neither there is nobody to
1247
1265
  // 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 };
1266
+ if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', request, skipped : true };
1249
1267
 
1250
1268
  const user = {
1251
1269
  ...( email && { email }),
@@ -1285,6 +1303,7 @@ var attentive = {
1285
1303
 
1286
1304
  return {
1287
1305
  message : 'Attentive accepted an unsubscribe for this contact — they have opted out.',
1306
+ request,
1288
1307
  response : { accepted : true, unsubscribed : true }
1289
1308
  };
1290
1309
 
@@ -1332,6 +1351,7 @@ var attentive = {
1332
1351
  // queued them — a merchant who reads "synced" and looks for the person
1333
1352
  // in Attentive a second later has been told the wrong thing.
1334
1353
  message : 'Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.',
1354
+ request,
1335
1355
  response : {
1336
1356
  accepted : true,
1337
1357
  ...( membership?.batchJobId && { batchJobId : membership.batchJobId })
@@ -3353,7 +3373,7 @@ var drawbridge = {
3353
3373
  text : { max : 2000, type : 'string' }
3354
3374
  },
3355
3375
  triggers : [ 'schedule.day', 'schedule.week', 'schedule.month' ],
3356
- usage : { actions : 0 }
3376
+ usage : { actions : channels.email.actionsPerSend }
3357
3377
  }),
3358
3378
 
3359
3379
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
@@ -3379,10 +3399,14 @@ var drawbridge = {
3379
3399
  subject : { max : 150, type : 'string' }
3380
3400
  },
3381
3401
  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 }
3402
+ // EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
3403
+ // leaves our SendGrid account and costs the same to send as any other
3404
+ // email, so it carries the same weight. Whether anything is charged at
3405
+ // all is the workflow's `billable` tag, not the step's audience — and
3406
+ // this step triggers on lead.insert, so a merchant notifying
3407
+ // themselves on every entry spends against their allowance twice per
3408
+ // lead, once for the entry and once for the notification.
3409
+ usage : { actions : channels.email.actionsPerSend }
3386
3410
  }),
3387
3411
 
3388
3412
  // To a LEAD. Suppression applies and the send is billed.
@@ -3514,7 +3538,30 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
3514
3538
 
3515
3539
  // The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
3516
3540
  // below keys on the id — so a rename never has to touch a profile.
3517
- const segmentName = ( title ) => 'Drawbridge: ' + title;
3541
+ //
3542
+ // THE ID IS IN THE LABEL TOO, and that part is not cosmetic. Nothing makes a
3543
+ // THE ID IS IN THE NAME BECAUSE THE NAME IS NOT AN IDENTITY. Keyed on title
3544
+ // alone, a second segment called "VIP" found the first one's Klaviyo segment by
3545
+ // name and adopted it: both rows then pointed at one segment whose definition
3546
+ // computes the FIRST one's membership, so the merchant sent to the wrong people,
3547
+ // and deleting either segment deleted that object out from under the other with
3548
+ // nothing to repair it.
3549
+ //
3550
+ // The api now carries a unique index on (organization, title) and a 409 on both
3551
+ // create and rename, so that pair can no longer be created. The suffix stays
3552
+ // anyway: it is what makes the damage impossible rather than merely unlikely,
3553
+ // and it is the only part of this that holds if a title ever reaches Klaviyo
3554
+ // from somewhere that did not go through the route.
3555
+ //
3556
+ // KLAVIYO IS NOT DOING THIS FOR US. Create Segment describes `name` only as "A
3557
+ // helpful name to label the segment" and documents no uniqueness constraint and
3558
+ // no duplicate-name error (developers.klaviyo.com/en/reference/create_segment,
3559
+ // fetched 2026-09-12), so the search cannot lean on the vendor. What IS
3560
+ // documented is that filter comparisons are case-sensitive
3561
+ // (developers.klaviyo.com/en/docs/filtering_, same date), which is why the
3562
+ // find-by-name below re-checks with === and agrees with the vendor rather than
3563
+ // guessing.
3564
+ const segmentName = ( title, id ) => 'Drawbridge: ' + title + ' (' + String( id ).slice( -6 ) + ')';
3518
3565
 
3519
3566
  // THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
3520
3567
  // only what the merchant consented to, so a connection made before segments were
@@ -3835,11 +3882,16 @@ var klaviyo = {
3835
3882
  // incomplete() already stops a connection reaching Active without a
3836
3883
  // list; this is the belt to that braces. A workflow saved before the
3837
3884
  // list was chosen must not silently write into nothing.
3838
- if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', skipped : true };
3885
+ // WHAT WENT TO THE VENDOR, so a sync run in the activity feed can answer
3886
+ // what was actually sent. The three contact syncs were the only hook
3887
+ // family that carried none, so a merchant opening one saw request : null.
3888
+ const request = { email : lead?.canonical?.email?.value || lead?.email || null, list : settings?.list || null };
3889
+
3890
+ if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', request, skipped : true };
3839
3891
 
3840
3892
  const email = lead?.canonical?.email?.value || lead?.email;
3841
3893
 
3842
- if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
3894
+ if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
3843
3895
 
3844
3896
  // A Klaviyo profile is keyed on an email, so IDENTITY comes from the
3845
3897
  // lead. RANKING comes from the contact behind it, because only a
@@ -3934,7 +3986,7 @@ var klaviyo = {
3934
3986
 
3935
3987
  const profileId = profile?.data?.id;
3936
3988
 
3937
- if( ! profileId ) return { message : 'Klaviyo returned no profile id.', skipped : true };
3989
+ if( ! profileId ) return { message : 'Klaviyo returned no profile id.', request, skipped : true };
3938
3990
 
3939
3991
  // SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
3940
3992
  //
@@ -3977,6 +4029,7 @@ var klaviyo = {
3977
4029
  message : suppressed
3978
4030
  ? 'Synced to Klaviyo as unsubscribed — this contact has opted out.'
3979
4031
  : 'Synced to the Klaviyo list.',
4032
+ request,
3980
4033
  // Recorded on the run for support to read back, not a write
3981
4034
  // instruction — the hook has already written what it needed to.
3982
4035
  response : { klaviyoProfileId : profileId }
@@ -4024,7 +4077,7 @@ var klaviyo = {
4024
4077
 
4025
4078
  }
4026
4079
 
4027
- const name = segmentName( segment.title );
4080
+ const name = segmentName( segment.title, segment.id );
4028
4081
  const existing = segmentRowFor({ connection, segment });
4029
4082
 
4030
4083
  let id = null;
@@ -4634,13 +4687,29 @@ const subscriberHash = ( email ) => createHash( 'md5' )
4634
4687
  // server-side truncation is the worse outcome because register would then look
4635
4688
  // for a name Mailchimp had silently changed.
4636
4689
  //
4637
- // ponytail: two segments whose first 88 characters match would share one tag.
4638
- // The upgrade is to append a short hash of the segment id, which costs the name
4639
- // its readability in the merchant's own audience not worth it until somebody
4640
- // actually collides.
4690
+ // THE SEGMENT ID IS IN THE NAME, and it has to be. Nothing makes a Drawbridge
4691
+ // segment title unique no index on the collection, no check in the route, no
4692
+ // check in the form so two segments called "VIP" are ordinary. Keyed on title
4693
+ // alone, the second one's register finds the first one's tag, adopts it, and
4694
+ // both rows point at one object: the merchant's two segments are one tag, and
4695
+ // deleting either segment deletes the tag out from under the other, leaving a
4696
+ // row pointing at an id that no longer exists and nothing to repair it.
4697
+ //
4698
+ // The last six characters of the id are enough to separate them and short
4699
+ // enough to leave the title readable in the merchant's own audience.
4700
+ //
4701
+ // A rename now changes the name and keeps the suffix, so register's find-by-name
4702
+ // still matches after one — and the truncation below cuts the TITLE rather than
4703
+ // the suffix for the same reason.
4641
4704
  const TAG_NAME_LIMIT = 100;
4642
4705
 
4643
- const tagName = ( title ) => ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT );
4706
+ const tagName = ( title, id ) => {
4707
+
4708
+ const suffix = ' (' + String( id ).slice( -6 ) + ')';
4709
+
4710
+ return ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT - suffix.length ) + suffix;
4711
+
4712
+ };
4644
4713
 
4645
4714
  // Mailchimp — contact sync, not a sender.
4646
4715
  var mailchimp = {
@@ -4797,16 +4866,24 @@ var mailchimp = {
4797
4866
 
4798
4867
  const audience = settings?.audience;
4799
4868
 
4869
+ // WHAT WENT TO THE VENDOR, recorded on the step so the activity feed
4870
+ // can answer "what did we actually send". Every other hook family
4871
+ // carries one; the three contact syncs did not, so a merchant opening
4872
+ // a sync run saw `request : null` and learned nothing — which is
4873
+ // exactly how it was reported. The audience and the address are the
4874
+ // whole identity of this write.
4875
+ const request = { audience : settings?.audience || null, email : lead?.canonical?.email?.value || lead?.email || null };
4876
+
4800
4877
  // status() already stops a connection reaching Active without an
4801
4878
  // audience; this is the belt to that braces. A workflow saved before
4802
4879
  // the audience was chosen must not silently write into nothing.
4803
- if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
4880
+ if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', request, skipped : true };
4804
4881
 
4805
4882
  const email = lead?.canonical?.email?.value || lead?.email;
4806
4883
 
4807
4884
  // A Mailchimp member IS an email address — there is no other identity
4808
4885
  // to write, so a lead without one is skipped rather than failed.
4809
- if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
4886
+ if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
4810
4887
 
4811
4888
  const hash = subscriberHash( email );
4812
4889
 
@@ -4927,19 +5004,21 @@ var mailchimp = {
4927
5004
  }
4928
5005
  },
4929
5006
  {
4930
- $project : { _id : 0, title : 1 }
5007
+ // THE ID AS WELL AS THE TITLE, because the tag name carries it —
5008
+ // see tagName. Without it every name here would end in the
5009
+ // string 'undefined' and match nothing register wrote.
5010
+ $project : { _id : 0, id : 1, title : 1 }
4931
5011
  }
4932
5012
  ]
4933
5013
  });
4934
5014
 
4935
- const joined = new Set( segments.map( ( entry ) => entry.title ) );
5015
+ const joined = new Set( segments.map( ( entry ) => entry.id ) );
4936
5016
 
4937
5017
  const tags = ( owned || [] )
4938
- .map( ( entry ) => entry.title )
4939
- .filter( Boolean )
4940
- .map( ( title ) => ({
4941
- name : tagName( title ),
4942
- status : joined.has( title ) ? 'active' : 'inactive'
5018
+ .filter( ( entry ) => entry.id && entry.title )
5019
+ .map( ( entry ) => ({
5020
+ name : tagName( entry.title, entry.id ),
5021
+ status : joined.has( entry.id ) ? 'active' : 'inactive'
4943
5022
  }) );
4944
5023
 
4945
5024
  if( tags.length > 0 ){
@@ -4962,6 +5041,7 @@ var mailchimp = {
4962
5041
  message : suppressed
4963
5042
  ? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
4964
5043
  : 'Synced to the Mailchimp audience.',
5044
+ request,
4965
5045
  // Recorded on the run for support to read back, not a write
4966
5046
  // instruction — the hook has already written what it needed to.
4967
5047
  response : { mailchimpMemberId : member?.id || hash }
@@ -5008,7 +5088,7 @@ var mailchimp = {
5008
5088
 
5009
5089
  if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
5010
5090
 
5011
- const name = tagName( segment.title );
5091
+ const name = tagName( segment.title, segment.id );
5012
5092
  const existing = segmentRowFor({ connection, segment });
5013
5093
 
5014
5094
  let id = null;
@@ -5736,23 +5816,33 @@ var shopify = {
5736
5816
 
5737
5817
  const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
5738
5818
 
5819
+ // KEPT even though nothing below sends the address anywhere. A code
5820
+ // that reaches nobody is a wasted action and a merchant's wasted
5821
+ // allowance: issuing is only half a workflow, and the email step after
5822
+ // it needs somewhere to send the code.
5739
5823
  if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
5740
5824
  if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
5741
5825
  if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
5742
5826
 
5743
5827
  const adminAccessToken = await adminToken();
5744
5828
 
5745
- // The customer must exist before a code is mapped to them. An earlier
5746
- // commerce.customer step usually did this and left the id on the
5747
- // context; when this step runs alone, it does it here.
5748
- if( ! context.shopifyCustomerId ){
5749
-
5750
- const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain : connection.shop, email : context.email });
5751
-
5752
- if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
5753
-
5754
- }
5755
-
5829
+ // NOTHING IS SENT TO SHOPIFY TO LINK THE CODE TO THE PERSON, because
5830
+ // there is nothing to send: discountRedeemCodeBulkAdd takes a discount
5831
+ // and a list of codes, and no customer
5832
+ // (shopify.dev/docs/api/admin-graphql/2026-04/mutations/discountRedeemCodeBulkAdd).
5833
+ // The link lives entirely on our side — `lead.shopifyDiscountCode`
5834
+ // below and attribution happens when the order comes back carrying
5835
+ // the code, matched org-scoped against that field by commerce.order.
5836
+ //
5837
+ // This step used to call getOrCreateCustomer first, on the stated
5838
+ // reasoning that "the customer must exist before a code is mapped to
5839
+ // them". No code was ever mapped to them: the result was assigned and
5840
+ // discarded, createDiscountCode never received it, and redemption
5841
+ // attribution never consulted it. All it did was create a customer in
5842
+ // the merchant's store as a side effect of issuing a discount — which
5843
+ // QA reported as surprising, and was right to. A merchant who wants the
5844
+ // buyer to exist at the store adds the Create customer step, which is
5845
+ // what that step is for.
5756
5846
  const discountCode = await shopify.admin.createDiscountCode({
5757
5847
  adminAccessToken,
5758
5848
  code : 'DB-' + generateDiscountCode(),
@@ -6413,7 +6503,7 @@ var shopify = {
6413
6503
  //
6414
6504
  // The shell has already refused a missing or inactive Shopify connection,
6415
6505
  // so what is left is the two things only this hook can know are wrong.
6416
- product : async ( { connection, context, workflow }, { mintId } = {} ) => {
6506
+ product : async ( { connection, context, workflow }, { mintId, read } = {} ) => {
6417
6507
 
6418
6508
  const request = {
6419
6509
  numericId : context?.id || null,
@@ -6429,11 +6519,30 @@ var shopify = {
6429
6519
 
6430
6520
  const providerId = 'gid://shopify/Product/' + context.id;
6431
6521
 
6432
- // Minted so the enqueue can name the row this upsert makes and the
6433
- // job carries the PROVIDER identity too, because under a concurrent
6434
- // redelivery this id may be the one that lost the upsert race. The
6435
- // worker falls back to provider + shop, which are stable either way.
6436
- const productId = mintId();
6522
+ // THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
6523
+ // does not.
6524
+ //
6525
+ // The write below is an upsert whose `id` sits in $setOnInsert, so a
6526
+ // product we have seen before keeps the id it was first given and a
6527
+ // newly minted one is silently discarded. Minting unconditionally
6528
+ // therefore published an id belonging to no row: the enqueue named a
6529
+ // product the worker then had to fall back off, and the step's
6530
+ // response showed QA a value that matched nothing they could look up
6531
+ // — which is exactly how it was reported.
6532
+ //
6533
+ // Reading first costs one indexed lookup on the same key the upsert
6534
+ // matches. A concurrent redelivery can still have both runs miss and
6535
+ // mint, which is why the job carries the PROVIDER identity too and the
6536
+ // worker falls back to provider + shop — stable either way.
6537
+ const query = {
6538
+ 'provider.id' : providerId,
6539
+ 'provider.slug' : 'shopify',
6540
+ 'source.domain' : connection.shop
6541
+ };
6542
+
6543
+ const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
6544
+
6545
+ const productId = existing?.id || mintId();
6437
6546
 
6438
6547
  return {
6439
6548
  enqueues : [ {
@@ -6464,11 +6573,7 @@ var shopify = {
6464
6573
  },
6465
6574
  operation : 'update',
6466
6575
  options : { upsert : true },
6467
- query : {
6468
- 'provider.id' : providerId,
6469
- 'provider.slug' : 'shopify',
6470
- 'source.domain' : connection.shop
6471
- }
6576
+ query
6472
6577
  } ]
6473
6578
  };
6474
6579
 
@@ -6941,10 +7046,10 @@ var shopify = {
6941
7046
  queue : 'connection'
6942
7047
  } ],
6943
7048
  message : ( scopesMissing?.length
6944
- ? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
7049
+ ? 'Health check: ping ok, scopes reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
6945
7050
  : refreshTokenRotated
6946
- ? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
6947
- : 'Health check passed — ping ok, webhooks reconciled.'
7051
+ ? 'Health check passed — refresh token rotated, ping ok, scopes reconciled.'
7052
+ : 'Health check passed — ping ok, scopes reconciled.'
6948
7053
  )
6949
7054
  + ( sourceRepaired ? ' Billing source repaired from the live shop.' : '' )
6950
7055
  + ( metered === null ? ' No usage line on the store\'s approved plan — order billing needs the merchant to approve the updated plan.' : '' ),