@drip-apex/sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19262 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19246 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16621 -13539
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +27 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2070 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +138 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
package/src/shopify-cart.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
+
}
|