@drip-apex/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -1,16 +1,24 @@
1
1
  import { fetchWithoutShopifyCartMutationNotification } from "./shopify-cart-events";
2
+ import { normalizeEndpoint } from "./endpoint";
2
3
 
3
4
  const CART_ATTRIBUTE_SYNC_KEY = "drip_cart_attr";
5
+ const CART_ATTRIBUTE_WRITE_CIRCUIT_BREAK_KEY = "drip_cart_attr_write_405";
6
+ const CART_WRITE_TIMEOUT_MS = 10_000;
4
7
 
5
8
  export type CartAssignment = {
6
9
  experimentId: string;
7
10
  variationId: string;
8
11
  assignmentEpoch?: number;
9
12
  commercePriceOnly?: boolean;
13
+ pageActive?: boolean;
10
14
  };
11
15
 
12
16
  export type CartAttributeSyncInput = {
13
17
  assignments: CartAssignment[];
18
+ /** Allows an explicit empty control stamp to clear prior treatment attributes. */
19
+ controlStamp?: boolean;
20
+ /** Writes only storefront identity without disturbing existing treatment attributes. */
21
+ identityOnly?: boolean;
14
22
  requiredAssignments?: CartAssignment[];
15
23
  visitorId: string;
16
24
  sessionId: string;
@@ -19,6 +27,8 @@ export type CartAttributeSyncInput = {
19
27
  ingestSignature?: string;
20
28
  url?: string;
21
29
  environment?: string;
30
+ platform?: string;
31
+ onAttributionEnvelope?: (envelope: string) => void;
22
32
  cartToken?: string;
23
33
  offersAuthorizationRevision?: number;
24
34
  offersExperimentIds?: string[];
@@ -35,6 +45,8 @@ export type CartAttributeSyncInput = {
35
45
  authorizePriceDisplayAssignments?: (assignments: CartAssignment[]) => void;
36
46
  authorizeShippingDisplayAssignments?: (assignments: CartAssignment[]) => void;
37
47
  authorizeOffersDisplayAssignments?: (assignments: CartAssignment[]) => void;
48
+ /** Receives the normalized Shopify cart token already observed by this sync. */
49
+ onCartToken?: (cartToken: string) => void;
38
50
  /** Receives the parsed /cart/update.js response body after a successful stamp write. */
39
51
  onCartSnapshot?: (cart: unknown) => void;
40
52
  exclusionGroupSelections?: Array<{
@@ -45,6 +57,57 @@ export type CartAttributeSyncInput = {
45
57
  };
46
58
 
47
59
  export type CartAttributeEntry = { key: string; value: string };
60
+ export type CartMetafieldInput = {
61
+ key: string;
62
+ type: string;
63
+ value: string;
64
+ };
65
+
66
+ export type CartMetafieldKeyInput = Pick<CartMetafieldInput, "key">;
67
+
68
+ const APEX_CART_ATTRIBUTE_KEY_VALUES = [
69
+ "apex_assignments",
70
+ "apex_assignment_authorization",
71
+ "apex_experiment_id",
72
+ "apex_variation_id",
73
+ "apex_assignment_epoch",
74
+ "apex_visitor_id",
75
+ "apex_session_id",
76
+ ] as const;
77
+
78
+ type ApexCartAttributeKey = (typeof APEX_CART_ATTRIBUTE_KEY_VALUES)[number];
79
+
80
+ /** Every Shopify cart attribute key owned and replaced by Apex. */
81
+ export const APEX_CART_ATTRIBUTE_KEYS: ReadonlySet<string> = new Set(
82
+ APEX_CART_ATTRIBUTE_KEY_VALUES,
83
+ );
84
+
85
+ /** Normalize Shopify metafield namespaces accepted by the Apex cart mirror. */
86
+ export function normalizeCartMetafieldNamespace(namespace?: string): string {
87
+ const requestedNamespace = namespace?.trim();
88
+ return requestedNamespace && /^[A-Za-z0-9_-]{2,255}$/.test(requestedNamespace)
89
+ ? requestedNamespace
90
+ : "apex";
91
+ }
92
+
93
+ /** Return every Apex cart-metafield key missing from the supplied mirror. */
94
+ export function getCartMetafieldStaleKeys(
95
+ currentMetafields: readonly CartMetafieldKeyInput[],
96
+ namespace = "apex",
97
+ ): string[] {
98
+ const normalizedNamespace = normalizeCartMetafieldNamespace(namespace);
99
+ const currentKeys = new Set(currentMetafields.map(({ key }) => key));
100
+ return APEX_CART_ATTRIBUTE_KEY_VALUES
101
+ .map((key) => `${normalizedNamespace}.${key}`)
102
+ .filter((key) => !currentKeys.has(key));
103
+ }
104
+
105
+ function apexCartAttributeEntry(
106
+ key: ApexCartAttributeKey,
107
+ value: string,
108
+ ): CartAttributeEntry {
109
+ return { key, value };
110
+ }
48
111
 
49
112
  export type CartAttributeSyncResult = {
50
113
  status: "sent" | "skipped" | "failed";
@@ -56,7 +119,10 @@ export type CartAttributeSyncOptions = {
56
119
  force?: boolean;
57
120
  };
58
121
 
59
- const inFlightBySignature = new Map<string, Promise<CartAttributeSyncResult>>();
122
+ const inFlightBySignature = new Map<string, {
123
+ generation: number;
124
+ promise: Promise<CartAttributeSyncResult>;
125
+ }>();
60
126
  const validatedAuthorizationSignatures = new Set<string>();
61
127
  let authorizedAssignmentsBySignature: {
62
128
  price: Map<string, CartAssignment[]>;
@@ -64,10 +130,31 @@ let authorizedAssignmentsBySignature: {
64
130
  offers: Map<string, CartAssignment[]>;
65
131
  } | null = null;
66
132
  let latestSyncGeneration = 0;
133
+ let pendingCartAttributeWrite: {
134
+ generation: number;
135
+ submit: (signal: AbortSignal | undefined) => Promise<Response>;
136
+ resolve: (outcome: CartAttributeWriteOutcome) => void;
137
+ } | null = null;
138
+ type CartAttributeWriterState =
139
+ | { status: "idle" }
140
+ | {
141
+ status: "in-flight";
142
+ generation: number;
143
+ controller: AbortController | null;
144
+ abortReason: "cart_write_aborted" | "cart_write_timeout" | null;
145
+ };
146
+ let cartAttributeWriterState: CartAttributeWriterState = { status: "idle" };
147
+ let cartAttributeWriteCircuitBrokenInMemory = false;
67
148
 
68
- /** Prevent an obsolete authorization response from reaching the cart write. */
149
+ /** Prevent an obsolete authorization response or pending request from reaching the cart. */
69
150
  export function invalidateShopifyCartAttributeSync(): void {
70
151
  latestSyncGeneration += 1;
152
+ pendingCartAttributeWrite?.resolve({ status: "superseded" });
153
+ pendingCartAttributeWrite = null;
154
+ if (cartAttributeWriterState.status === "in-flight") {
155
+ cartAttributeWriterState.abortReason = "cart_write_aborted";
156
+ cartAttributeWriterState.controller?.abort();
157
+ }
71
158
  }
72
159
 
73
160
  function ensureAuthorizedAssignmentCache() {
@@ -153,6 +240,26 @@ function writeStampedSignature(signature: string): void {
153
240
  }
154
241
  }
155
242
 
243
+ function cartAttributeWriteCircuitBroken(): boolean {
244
+ if (cartAttributeWriteCircuitBrokenInMemory) return true;
245
+ try {
246
+ cartAttributeWriteCircuitBrokenInMemory =
247
+ sessionStorage.getItem(CART_ATTRIBUTE_WRITE_CIRCUIT_BREAK_KEY) === "1";
248
+ return cartAttributeWriteCircuitBrokenInMemory;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ function breakCartAttributeWriteCircuit(): void {
255
+ cartAttributeWriteCircuitBrokenInMemory = true;
256
+ try {
257
+ sessionStorage.setItem(CART_ATTRIBUTE_WRITE_CIRCUIT_BREAK_KEY, "1");
258
+ } catch {
259
+ // Storage persistence is best-effort; the module-level breaker remains active.
260
+ }
261
+ }
262
+
156
263
  function normalizeTargetingUrl(value: unknown): string | null {
157
264
  if (typeof value !== "string") return null;
158
265
  const trimmed = value.trim();
@@ -171,6 +278,11 @@ function normalizeTargetingUrl(value: unknown): string | null {
171
278
 
172
279
  function syncSignature(input: CartAttributeSyncInput): string {
173
280
  return JSON.stringify({
281
+ ...(input.controlStamp
282
+ ? { mode: "control" }
283
+ : input.identityOnly
284
+ ? { mode: "identity" }
285
+ : {}),
174
286
  assignments: input.assignments
175
287
  .map((assignment) => [
176
288
  assignment.experimentId,
@@ -244,10 +356,26 @@ function normalizeProofCartToken(value: unknown): string | null {
244
356
  return typeof value === "string" && normalizeCartToken(value) ? value.trim() : null;
245
357
  }
246
358
 
359
+ function observeCartToken(
360
+ input: CartAttributeSyncInput,
361
+ value: unknown,
362
+ ): string | null {
363
+ const proofToken = normalizeProofCartToken(value);
364
+ const cartToken = normalizeCartToken(proofToken);
365
+ if (cartToken) {
366
+ try {
367
+ input.onCartToken?.(cartToken);
368
+ } catch {
369
+ // Cart-token observers must not affect cart authorization or writes.
370
+ }
371
+ }
372
+ return proofToken;
373
+ }
374
+
247
375
  async function resolveChargeAuthorizationCartToken(
248
376
  input: CartAttributeSyncInput,
249
377
  ): Promise<string | null> {
250
- const supplied = normalizeProofCartToken(input.cartToken);
378
+ const supplied = observeCartToken(input, input.cartToken);
251
379
  if (supplied) return supplied;
252
380
  if (
253
381
  input.priceAuthorizationRevision == null &&
@@ -261,7 +389,7 @@ async function resolveChargeAuthorizationCartToken(
261
389
  cache: "no-store",
262
390
  });
263
391
  if (!response.ok) return null;
264
- return normalizeProofCartToken(((await response.json()) as { token?: unknown }).token);
392
+ return observeCartToken(input, ((await response.json()) as { token?: unknown }).token);
265
393
  } catch {
266
394
  return null;
267
395
  }
@@ -272,9 +400,20 @@ export function syncShopifyCartAttributes(
272
400
  options: CartAttributeSyncOptions = {},
273
401
  ): Promise<CartAttributeSyncResult> {
274
402
  try {
403
+ // An empty visitor ID is the fail-closed sentinel for the identity-free
404
+ // consent-pending input built by index.ts.
405
+ const armOnly = input.assignments.length > 0 && !input.visitorId;
275
406
  if (!isShopifyStorefront()) return Promise.resolve({ status: "skipped", reason: "not_shopify" });
276
407
  if (typeof fetch !== "function") return Promise.resolve({ status: "skipped", reason: "fetch_missing" });
277
- if (!input.assignments[0] && !requiresCommerceAuthorization(input)) {
408
+ if (cartAttributeWriteCircuitBroken()) {
409
+ return Promise.resolve({ status: "skipped", reason: "cart_update_405_circuit_break" });
410
+ }
411
+ if (
412
+ !input.assignments[0] &&
413
+ !input.controlStamp &&
414
+ !input.identityOnly &&
415
+ !requiresCommerceAuthorization(input)
416
+ ) {
278
417
  return Promise.resolve({ status: "skipped", reason: "no_assignments" });
279
418
  }
280
419
  if (cannotRequestRequiredCommerceAuthorization(input)) {
@@ -284,17 +423,35 @@ export function syncShopifyCartAttributes(
284
423
  const signature = syncSignature(input);
285
424
  const existingInFlight = inFlightBySignature.get(signature);
286
425
  if (existingInFlight) {
287
- if (!options.force) return existingInFlight;
426
+ if (!options.force && existingInFlight.generation === latestSyncGeneration) {
427
+ return existingInFlight.promise;
428
+ }
429
+ if (!options.force) {
430
+ // A -> B -> A must not attach the final A to the first A. B may write
431
+ // after that request, so enqueue a fresh A once the original settles.
432
+ const continuationGeneration = latestSyncGeneration;
433
+ return existingInFlight.promise.then(() =>
434
+ continuationGeneration === latestSyncGeneration
435
+ ? syncShopifyCartAttributes(input, options)
436
+ : { status: "skipped", reason: "superseded" },
437
+ );
438
+ }
288
439
  // A forced sync follows a real cart mutation, so cart identity may have
289
440
  // changed underneath the pending request. Invalidate it immediately;
290
441
  // ordinary overlaps still share the existing request.
291
442
  latestSyncGeneration += 1;
292
- return existingInFlight.then(() => syncShopifyCartAttributes(input, options));
443
+ const continuationGeneration = latestSyncGeneration;
444
+ return existingInFlight.promise.then(() =>
445
+ continuationGeneration === latestSyncGeneration
446
+ ? syncShopifyCartAttributes(input, options)
447
+ : { status: "skipped", reason: "superseded" },
448
+ );
293
449
  }
294
450
  const generation = ++latestSyncGeneration;
295
451
  const requiresCurrentAssignmentProof = requiresCommerceAuthorization(input);
296
452
  if (
297
453
  !options.force &&
454
+ !armOnly &&
298
455
  readStampedSignature() === signature &&
299
456
  (!requiresCurrentAssignmentProof || validatedAuthorizationSignatures.has(signature))
300
457
  ) {
@@ -328,20 +485,36 @@ export function syncShopifyCartAttributes(
328
485
  }): Promise<{
329
486
  response: Response | null;
330
487
  authorizationCompleted: boolean;
488
+ writeCircuitBroken: boolean;
331
489
  authorizedSignature: string;
332
490
  authorizedPriceAssignments: CartAssignment[];
333
491
  authorizedShippingAssignments: CartAssignment[];
334
492
  authorizedOffersAssignments: CartAssignment[];
335
493
  offersAdoptionAssignments?: CartAssignment[];
494
+ writeOutcome: CartAttributeWriteOutcome;
336
495
  }> => {
337
- if (!cartWriteAllowed || generation !== latestSyncGeneration) {
496
+ if (generation !== latestSyncGeneration) {
497
+ return {
498
+ response: null,
499
+ authorizationCompleted: completed,
500
+ writeCircuitBroken: false,
501
+ authorizedSignature,
502
+ authorizedPriceAssignments: [],
503
+ authorizedShippingAssignments: [],
504
+ authorizedOffersAssignments: [],
505
+ writeOutcome: { status: "superseded" },
506
+ };
507
+ }
508
+ if (!cartWriteAllowed) {
338
509
  return {
339
510
  response: null,
340
511
  authorizationCompleted: false,
512
+ writeCircuitBroken: false,
341
513
  authorizedSignature,
342
514
  authorizedPriceAssignments: [],
343
515
  authorizedShippingAssignments: [],
344
516
  authorizedOffersAssignments: [],
517
+ writeOutcome: { status: "failed", reason: "authorization_failed" },
345
518
  };
346
519
  }
347
520
  const authorizedInput = { ...input, assignments };
@@ -367,31 +540,65 @@ export function syncShopifyCartAttributes(
367
540
  const attributes = Object.fromEntries(
368
541
  buildCartAttributeEntries(authorizedInput, token).map((entry) => [entry.key, entry.value]),
369
542
  );
370
- const response = await fetchWithoutShopifyCartMutationNotification("/cart/update.js", {
371
- method: "POST",
372
- headers: { "Content-Type": "application/json" },
373
- body: JSON.stringify({ attributes }),
374
- keepalive: true,
375
- });
543
+ if (cartAttributeWriteCircuitBroken()) {
544
+ return {
545
+ response: null,
546
+ authorizationCompleted: completed,
547
+ writeCircuitBroken: true,
548
+ authorizedSignature,
549
+ authorizedPriceAssignments,
550
+ authorizedShippingAssignments,
551
+ authorizedOffersAssignments,
552
+ offersAdoptionAssignments,
553
+ writeOutcome: { status: "failed", reason: "cart_update_405_circuit_break" },
554
+ };
555
+ }
556
+ const writeOutcome = await submitNewestCartAttributeWrite(generation, (signal) =>
557
+ fetchWithoutShopifyCartMutationNotification("/cart/update.js", {
558
+ method: "POST",
559
+ headers: { "Content-Type": "application/json" },
560
+ body: JSON.stringify({ attributes }),
561
+ keepalive: true,
562
+ signal,
563
+ })
564
+ );
565
+ const response = writeOutcome.status === "sent" ? writeOutcome.response : null;
566
+ if (response?.status === 405) breakCartAttributeWriteCircuit();
376
567
  return {
377
568
  response,
378
569
  authorizationCompleted: completed,
570
+ writeCircuitBroken: false,
379
571
  authorizedSignature,
380
572
  authorizedPriceAssignments,
381
573
  authorizedShippingAssignments,
382
574
  authorizedOffersAssignments,
383
575
  offersAdoptionAssignments,
576
+ writeOutcome,
384
577
  };
385
578
  })
386
579
  .then(({
387
580
  response,
388
581
  authorizationCompleted,
582
+ writeCircuitBroken,
389
583
  authorizedSignature: completedSignature,
390
584
  authorizedPriceAssignments,
391
585
  authorizedShippingAssignments,
392
586
  authorizedOffersAssignments,
393
587
  offersAdoptionAssignments,
588
+ writeOutcome,
394
589
  }) => {
590
+ if (writeOutcome.status === "superseded") {
591
+ return { status: "skipped", reason: "superseded" } as const;
592
+ }
593
+ if (writeCircuitBroken) {
594
+ return { status: "skipped", reason: "cart_update_405_circuit_break" } as const;
595
+ }
596
+ if (writeOutcome.status === "aborted" || writeOutcome.status === "timeout") {
597
+ return { status: "failed", reason: writeOutcome.reason } as const;
598
+ }
599
+ if (writeOutcome.status === "failed") {
600
+ return { status: "failed", reason: writeOutcome.reason } as const;
601
+ }
395
602
  if (!authorizationCompleted) {
396
603
  return { status: "failed", reason: "authorization_failed" } as const;
397
604
  }
@@ -411,7 +618,7 @@ export function syncShopifyCartAttributes(
411
618
  ) {
412
619
  return { status: "failed", reason: "offers_adoption_failed" } as const;
413
620
  }
414
- if (input.onCartSnapshot) {
621
+ if (input.onCartSnapshot || input.onCartToken) {
415
622
  // The stamp-write response body is the full updated cart — a free
416
623
  // snapshot consumers use to rediscover Apex-managed lines without
417
624
  // an extra request (best-effort; never blocks the sync result).
@@ -420,10 +627,13 @@ export function syncShopifyCartAttributes(
420
627
  // stamped envelope does not authorize could retain or insert a
421
628
  // gift the Function will not discount.
422
629
  void response.clone().json()
423
- .then((cart) => input.onCartSnapshot?.(cart))
630
+ .then((cart) => {
631
+ observeCartToken(input, (cart as { token?: unknown })?.token);
632
+ input.onCartSnapshot?.(cart);
633
+ })
424
634
  .catch(() => {});
425
635
  }
426
- writeStampedSignature(completedSignature);
636
+ if (!armOnly) writeStampedSignature(completedSignature);
427
637
  if (requiresCurrentAssignmentProof) {
428
638
  validatedAuthorizationSignatures.clear();
429
639
  validatedAuthorizationSignatures.add(completedSignature);
@@ -463,11 +673,11 @@ export function syncShopifyCartAttributes(
463
673
  reason: error instanceof Error ? error.message : String(error),
464
674
  }) as const)
465
675
  .finally(() => {
466
- if (inFlightBySignature.get(signature) === pending) {
676
+ if (inFlightBySignature.get(signature)?.promise === pending) {
467
677
  inFlightBySignature.delete(signature);
468
678
  }
469
679
  });
470
- inFlightBySignature.set(signature, pending);
680
+ inFlightBySignature.set(signature, { generation, promise: pending });
471
681
  return pending;
472
682
  } catch {
473
683
  // Shopify cart attribution must never block or break assignment rendering.
@@ -475,6 +685,93 @@ export function syncShopifyCartAttributes(
475
685
  }
476
686
  }
477
687
 
688
+ function submitNewestCartAttributeWrite(
689
+ generation: number,
690
+ submit: (signal: AbortSignal | undefined) => Promise<Response>,
691
+ ): Promise<CartAttributeWriteOutcome> {
692
+ const outcome = new Promise<CartAttributeWriteOutcome>((resolve) => {
693
+ const requested = { generation, submit, resolve };
694
+ if (!pendingCartAttributeWrite || generation >= pendingCartAttributeWrite.generation) {
695
+ pendingCartAttributeWrite?.resolve({ status: "superseded" });
696
+ pendingCartAttributeWrite = requested;
697
+ } else {
698
+ resolve({ status: "superseded" });
699
+ }
700
+ });
701
+ if (cartAttributeWriterState.status === "idle") drainCartAttributeWrites();
702
+ return outcome;
703
+ }
704
+
705
+ function drainCartAttributeWrites(): void {
706
+ if (cartAttributeWriterState.status !== "idle") return;
707
+ const requested = pendingCartAttributeWrite;
708
+ pendingCartAttributeWrite = null;
709
+ if (!requested) return;
710
+ if (requested.generation !== latestSyncGeneration) {
711
+ requested.resolve({ status: "superseded" });
712
+ drainCartAttributeWrites();
713
+ return;
714
+ }
715
+
716
+ const controller = typeof AbortController === "undefined" ? null : new AbortController();
717
+ const inFlight: Extract<CartAttributeWriterState, { status: "in-flight" }> = {
718
+ status: "in-flight",
719
+ generation: requested.generation,
720
+ controller,
721
+ abortReason: null,
722
+ };
723
+ cartAttributeWriterState = inFlight;
724
+ let timeout: ReturnType<typeof setTimeout> | undefined;
725
+ const deadline = new Promise<never>((_resolve, reject) => {
726
+ timeout = setTimeout(() => {
727
+ inFlight.abortReason = "cart_write_timeout";
728
+ controller?.abort();
729
+ reject(new Error("cart_write_timeout"));
730
+ }, CART_WRITE_TIMEOUT_MS);
731
+ controller?.signal.addEventListener("abort", () => {
732
+ reject(new Error(inFlight.abortReason ?? "cart_write_aborted"));
733
+ }, { once: true });
734
+ });
735
+
736
+ Promise.race([requested.submit(controller?.signal), deadline])
737
+ .then((response) => {
738
+ requested.resolve(requested.generation === latestSyncGeneration
739
+ ? { status: "sent", response }
740
+ : { status: "superseded" });
741
+ })
742
+ .catch((error: unknown) => {
743
+ if (inFlight.abortReason === "cart_write_aborted") {
744
+ requested.resolve({ status: "aborted", reason: "cart_write_aborted" });
745
+ } else if (inFlight.abortReason === "cart_write_timeout") {
746
+ requested.resolve({ status: "timeout", reason: "cart_write_timeout" });
747
+ } else {
748
+ requested.resolve({
749
+ status: "failed",
750
+ reason: error instanceof Error ? error.message : String(error),
751
+ });
752
+ }
753
+ })
754
+ .finally(() => {
755
+ if (timeout !== undefined) clearTimeout(timeout);
756
+ // Publish idle and consume the pending slot synchronously. An enqueue can
757
+ // therefore observe either this in-flight state or an idle writer that it
758
+ // starts itself, never an unowned pending request.
759
+ cartAttributeWriterState = { status: "idle" };
760
+ drainCartAttributeWrites();
761
+ });
762
+ }
763
+
764
+ type CartAttributeWriteOutcome =
765
+ | { status: "sent"; response: Response }
766
+ | { status: "superseded" }
767
+ | { status: "aborted"; reason: "cart_write_aborted" }
768
+ | { status: "timeout"; reason: "cart_write_timeout" }
769
+ | { status: "failed"; reason: string };
770
+
771
+ export async function mintShopwareAttributionEnvelope(input: CartAttributeSyncInput): Promise<void> {
772
+ await requestCommerceAuthorization({ ...input, platform: "shopware" });
773
+ }
774
+
478
775
  async function requestCommerceAuthorization(
479
776
  input: CartAttributeSyncInput,
480
777
  generation?: number,
@@ -486,6 +783,10 @@ async function requestCommerceAuthorization(
486
783
  /** Adoption validated pre-write; the caller applies it only after the cart write is confirmed. */
487
784
  offersAdoptionAssignments?: CartAssignment[];
488
785
  }> {
786
+ // Page-inactive assignments remain attribution state. Keep them visible
787
+ // while reconciling the Worker's cart pin so the same arm does not look
788
+ // like a new commerce-only assignment that needs adoption.
789
+ let authorizedAssignments = input.assignments;
489
790
  const priceWriteRequiresProof = hasActivePriceAuthorizationPlane(input);
490
791
  const offersWriteRequiresProof = hasActiveOffersAuthorizationPlane(input);
491
792
  const configuredOffersExperimentIds = offersExperimentIds(input);
@@ -525,7 +826,7 @@ async function requestCommerceAuthorization(
525
826
  assignments: input.assignments,
526
827
  };
527
828
  }
528
- const endpoint = input.endpoint.replace(/\/+$/, "").replace(/\/events$/, "");
829
+ const endpoint = normalizeEndpoint(input.endpoint);
529
830
  try {
530
831
  const cartToken = await resolveChargeAuthorizationCartToken(input);
531
832
  if (
@@ -541,7 +842,7 @@ async function requestCommerceAuthorization(
541
842
  };
542
843
  }
543
844
  const requestedExperimentIds = [...new Set([
544
- ...input.assignments.map((assignment) => assignment.experimentId),
845
+ ...authorizedAssignments.map((assignment) => assignment.experimentId),
545
846
  ...(hasActivePriceAuthorizationPlane(input) ? input.priceExperimentIds ?? [] : []),
546
847
  ...(hasActiveOffersAuthorizationPlane(input) ? input.offersExperimentIds ?? [] : []),
547
848
  ])];
@@ -557,7 +858,15 @@ async function requestCommerceAuthorization(
557
858
  user_id: input.visitorId,
558
859
  url: input.url,
559
860
  environment: input.environment,
560
- ...(cartToken ? { cart_token: cartToken } : {}),
861
+ platform: input.platform,
862
+ ...(cartToken
863
+ ? {
864
+ cart_token: cartToken,
865
+ // Ask the authenticated Worker to persist its signed decision in
866
+ // the Function-owned cart plane before the client fallback runs.
867
+ persist_function_handoff: true,
868
+ }
869
+ : {}),
561
870
  experiments: requestedExperimentIds,
562
871
  exclusion_group_selections: input.exclusionGroupSelections?.map((selection) => ({
563
872
  group_id: selection.groupId,
@@ -579,7 +888,13 @@ async function requestCommerceAuthorization(
579
888
  const payload = (await response.json()) as {
580
889
  assignments?: unknown;
581
890
  commerce_authorization?: unknown;
891
+ handoff_persisted?: boolean;
892
+ handoff_stale_risk?: boolean;
893
+ attribution_envelope?: unknown;
582
894
  };
895
+ if (typeof payload.attribution_envelope === "string" && payload.attribution_envelope.length <= 4096) {
896
+ input.onAttributionEnvelope?.(payload.attribution_envelope);
897
+ }
583
898
  const token =
584
899
  typeof payload.commerce_authorization === "string" &&
585
900
  payload.commerce_authorization
@@ -602,7 +917,6 @@ async function requestCommerceAuthorization(
602
917
  assignments: input.assignments,
603
918
  };
604
919
  }
605
- let authorizedAssignments = input.assignments;
606
920
  let requiredAssignments = input.requiredAssignments;
607
921
  let resolvedPriceAssignments: ReturnType<
608
922
  typeof resolveAuthoritativePriceAssignments
@@ -627,7 +941,7 @@ async function requestCommerceAuthorization(
627
941
  if (priceExperimentIds.size > 0) {
628
942
  resolvedPriceAssignments = resolveAuthoritativePriceAssignments(
629
943
  payload.assignments,
630
- input.assignments,
944
+ authorizedAssignments,
631
945
  evaluatedPriceExperimentIds,
632
946
  );
633
947
  if (!resolvedPriceAssignments) {
@@ -1099,22 +1413,26 @@ function resolveAuthoritativePriceAssignments(
1099
1413
  priceAssignments.map((assignment) => [assignment.experimentId, assignment]),
1100
1414
  );
1101
1415
  const adoptionRequired = localPriceAssignments.some(
1102
- (assignment) => !authoritativeByExperimentId.has(assignment.experimentId),
1416
+ (assignment) => assignment.pageActive !== false &&
1417
+ !authoritativeByExperimentId.has(assignment.experimentId),
1103
1418
  ) || priceAssignments.some((assignment) => {
1104
1419
  const local = localPriceAssignments.find(
1105
1420
  (candidate) => candidate.experimentId === assignment.experimentId,
1106
1421
  );
1107
1422
  return local?.variationId !== assignment.variationId ||
1108
1423
  local.assignmentEpoch !== assignment.assignmentEpoch ||
1109
- local.commercePriceOnly !== assignment.commercePriceOnly;
1424
+ (local?.pageActive !== false &&
1425
+ local.commercePriceOnly !== assignment.commercePriceOnly);
1110
1426
  });
1111
1427
  const remainingPriceAssignments = new Map(authoritativeByExperimentId);
1112
1428
  const assignments = localAssignments.map((assignment) => {
1113
1429
  if (!evaluatedPriceExperimentIds.has(assignment.experimentId)) return assignment;
1114
1430
  const authoritative = remainingPriceAssignments.get(assignment.experimentId);
1115
- if (!authoritative) return null;
1431
+ if (!authoritative) return assignment.pageActive === false ? assignment : null;
1116
1432
  remainingPriceAssignments.delete(assignment.experimentId);
1117
- return authoritative;
1433
+ return assignment.pageActive === undefined
1434
+ ? authoritative
1435
+ : { ...authoritative, pageActive: assignment.pageActive };
1118
1436
  }).filter((assignment): assignment is CartAssignment => assignment !== null);
1119
1437
  for (const assignment of priceAssignments) {
1120
1438
  if (remainingPriceAssignments.has(assignment.experimentId)) {
@@ -1215,42 +1533,77 @@ export function buildCartAttributeEntries(
1215
1533
  input: CartAttributeSyncInput,
1216
1534
  commerceAuthorization: string | null = null,
1217
1535
  ): CartAttributeEntry[] {
1536
+ // An empty visitor ID marks the identity-free consent-pending stamp. Empty
1537
+ // named keys actively replace identity left by an earlier consented visit.
1538
+ if (input.assignments.length > 0 && !input.visitorId) {
1539
+ return [
1540
+ apexCartAttributeEntry(
1541
+ "apex_assignments",
1542
+ JSON.stringify(input.assignments.map((assignment) => ({
1543
+ e: assignment.experimentId,
1544
+ v: assignment.variationId,
1545
+ ep: assignment.assignmentEpoch,
1546
+ }))),
1547
+ ),
1548
+ apexCartAttributeEntry("apex_assignment_authorization", ""),
1549
+ apexCartAttributeEntry("apex_experiment_id", ""),
1550
+ apexCartAttributeEntry("apex_variation_id", ""),
1551
+ apexCartAttributeEntry("apex_assignment_epoch", ""),
1552
+ apexCartAttributeEntry("apex_visitor_id", ""),
1553
+ apexCartAttributeEntry("apex_session_id", ""),
1554
+ ];
1555
+ }
1556
+ if (input.identityOnly) {
1557
+ return [apexCartAttributeEntry("apex_visitor_id", input.visitorId)];
1558
+ }
1218
1559
  const primaryAssignment = input.assignments[0];
1219
1560
  const entries: CartAttributeEntry[] = [
1220
- {
1221
- key: "apex_assignments",
1222
- value: JSON.stringify(
1561
+ apexCartAttributeEntry(
1562
+ "apex_assignments",
1563
+ JSON.stringify(
1223
1564
  input.assignments.map((assignment) => ({
1224
1565
  e: assignment.experimentId,
1225
1566
  v: assignment.variationId,
1226
1567
  ep: assignment.assignmentEpoch,
1227
1568
  })),
1228
1569
  ),
1229
- },
1230
- {
1231
- key: "apex_assignment_authorization",
1232
- value: commerceAuthorization ?? "",
1233
- },
1570
+ ),
1571
+ apexCartAttributeEntry("apex_assignment_authorization", commerceAuthorization ?? ""),
1234
1572
  ];
1235
1573
  if (!primaryAssignment) {
1236
1574
  return [
1237
1575
  ...entries,
1238
- { key: "apex_experiment_id", value: "" },
1239
- { key: "apex_variation_id", value: "" },
1240
- { key: "apex_assignment_epoch", value: "" },
1241
- { key: "apex_visitor_id", value: input.visitorId },
1242
- { key: "apex_session_id", value: input.sessionId },
1576
+ apexCartAttributeEntry("apex_experiment_id", ""),
1577
+ apexCartAttributeEntry("apex_variation_id", ""),
1578
+ apexCartAttributeEntry("apex_assignment_epoch", ""),
1579
+ apexCartAttributeEntry("apex_visitor_id", input.visitorId),
1580
+ apexCartAttributeEntry("apex_session_id", input.sessionId),
1243
1581
  ];
1244
1582
  }
1245
1583
  return [
1246
1584
  ...entries,
1247
- { key: "apex_experiment_id", value: primaryAssignment.experimentId },
1248
- { key: "apex_variation_id", value: primaryAssignment.variationId },
1249
- {
1250
- key: "apex_assignment_epoch",
1251
- value: String(primaryAssignment.assignmentEpoch ?? ""),
1252
- },
1253
- { key: "apex_visitor_id", value: input.visitorId },
1254
- { key: "apex_session_id", value: input.sessionId },
1585
+ apexCartAttributeEntry("apex_experiment_id", primaryAssignment.experimentId),
1586
+ apexCartAttributeEntry("apex_variation_id", primaryAssignment.variationId),
1587
+ apexCartAttributeEntry(
1588
+ "apex_assignment_epoch",
1589
+ String(primaryAssignment.assignmentEpoch ?? ""),
1590
+ ),
1591
+ apexCartAttributeEntry("apex_visitor_id", input.visitorId),
1592
+ apexCartAttributeEntry("apex_session_id", input.sessionId),
1255
1593
  ];
1256
1594
  }
1595
+
1596
+ /** Build a Storefront API cart-metafield mirror of the canonical attribution stamp. */
1597
+ export function buildCartMetafieldInputs(
1598
+ input: CartAttributeSyncInput,
1599
+ namespace = "apex",
1600
+ ): CartMetafieldInput[] {
1601
+ const normalizedNamespace = normalizeCartMetafieldNamespace(namespace);
1602
+ return buildCartAttributeEntries(input)
1603
+ .filter(({ value }) => value !== "")
1604
+ .map(({ key, value }) => ({
1605
+ key: `${normalizedNamespace}.${key}`,
1606
+ type: key === "apex_assignments" ? "json" : "single_line_text_field",
1607
+ value,
1608
+ }));
1609
+ }