on Health Validation
Before migrating production workloads, establish a validation layer that compares row counts across API versions. This detects exposure and quantifies the size of the filtering gap.
import { GraphQLClient } from 'graphql-request';
type DiscountCountResult = {
version: string;
count: number;
delta: number;
};
async function validateDiscountVisibility(
shopDomain: string,
accessToken: string
): Promise<DiscountCountResult[]> {
const client = new GraphQLClient(`https://${shopDomain}/admin/api/2026-01/graphql.json`, {
headers: { 'X-Shopify-Access-Token': accessToken }
});
const clientNew = new GraphQLClient(`https://${shopDomain}/admin/api/2026-07/graphql.json`, {
headers: { 'X-Shopify-Access-Token': accessToken }
});
const countQuery = `
query GetDiscountCount($first: Int!) {
discountNodes(first: $first) {
count
}
}
`;
const [oldResult, newResult] = await Promise.all([
client.request(countQuery, { first: 250 }),
clientNew.request(countQuery, { first: 250 })
]);
const oldCount = oldResult.discountNodes.count;
const newCount = newResult.discountNodes.count;
return [
{ version: '2026-01', count: oldCount, delta: 0 },
{ version: '2026-07', count: newCount, delta: newCount - oldCount }
];
}
Rationale: Running parallel queries isolates the filtering gap without altering production behavior. The delta directly represents the number of market-scoped discounts invisible to older versions. This metric should feed into monitoring dashboards and trigger upgrade workflows when thresholds are exceeded.
Step 2: Restructure Queries with Explicit Market Context
Once upgraded to 2026-07, blind enumeration becomes inefficient and error-prone. The API supports marketIds filtering and returns market metadata alongside discount nodes. Queries should explicitly request market context and handle pagination with version-aware cursor management.
interface MarketScopedDiscount {
id: string;
title: string;
status: string;
marketIds: string[];
marketType: 'REGION' | 'B2B_COMPANY' | 'RETAIL_LOCATION';
}
async function fetchMarketScopedDiscounts(
client: GraphQLClient,
targetMarkets: string[] = []
): Promise<MarketScopedDiscount[]> {
const query = `
query FetchDiscountCatalog($first: Int!, $after: String, $marketIds: [ID!]) {
discountNodes(first: $first, after: $after, marketIds: $marketIds) {
edges {
node {
id
discount {
... on DiscountCodeBasic {
title
status
marketIds
marketType
}
... on DiscountAutomaticBasic {
title
status
marketIds
marketType
}
}
}
cursor
}
pageInfo {
hasNextPage
}
}
}
`;
const allDiscounts: MarketScopedDiscount[] = [];
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const response = await client.request(query, {
first: 50,
after: cursor,
marketIds: targetMarkets.length > 0 ? targetMarkets : undefined
});
const edges = response.discountNodes.edges;
for (const edge of edges) {
const discountData = edge.node.discount;
allDiscounts.push({
id: edge.node.id,
title: discountData.title,
status: discountData.status,
marketIds: discountData.marketIds || [],
marketType: discountData.marketType
});
}
hasMore = response.discountNodes.pageInfo.hasNextPage;
cursor = edges.length > 0 ? edges[edges.length - 1].cursor : null;
}
return allDiscounts;
}
Rationale: Explicit marketIds filtering prevents over-fetching and aligns queries with actual merchant scope. Pagination cursors remain stable across version upgrades, but the underlying dataset changes. Requesting marketType alongside marketIds enables downstream inheritance resolution without additional API calls.
Step 3: Resolve Market Inheritance Rules
Market eligibility follows strict inheritance patterns that must be modeled explicitly. Regional markets cascade to sub-markets, but market types do not inherit across boundaries. A discount assigned to a regional market does not automatically apply to B2B company locations or retail locations.
function resolveEffectiveMarkets(
assignedMarketIds: string[],
marketType: 'REGION' | 'B2B_COMPANY' | 'RETAIL_LOCATION',
marketRegistry: Map<string, { parentId: string | null; type: string }>
): string[] {
const effectiveMarkets = new Set<string>(assignedMarketIds);
if (marketType === 'REGION') {
for (const marketId of assignedMarketIds) {
const registry = marketRegistry.get(marketId);
if (registry?.parentId) {
// Regional assignments cascade downward; sub-markets inherit automatically
// No need to enumerate children; the API handles inheritance server-side
effectiveMarkets.add(marketId);
}
}
}
// B2B and retail markets require explicit assignment; no cross-type inheritance
return Array.from(effectiveMarkets);
}
Rationale: Attempting to enumerate leaf markets or flatten inheritance trees causes over-application or under-application. The API resolves regional cascades server-side; client code should only track explicitly assigned IDs and respect type boundaries. This prevents reconciliation loops from recreating discounts that already inherit correctly.
Pitfall Guide
1. Treating null as Deletion
Explanation: Fetching a discount by ID on pre-2026-07 returns null for market-scoped discounts. Integration code that interprets null as a tombstone will delete active discounts from local state.
Fix: Implement version-aware null handling. If running on an older version, treat null as ambiguous. Queue the ID for re-validation against 2026-07 before triggering destructive operations.
2. Flat Market Enumeration Loops
Explanation: Migration scripts that iterate through all leaf markets and assign discounts individually ignore inheritance rules. This duplicates assignments for sub-markets and misses cross-type boundaries.
Fix: Assign discounts to parent regional markets only. Let the API handle regional cascades. For B2B and retail contexts, maintain separate assignment lists per market type.
3. Bulk Operation Version Blindness
Explanation: bulkOperationRunQuery executes using the app's configured API version. Nightly exports on older versions silently omit market-scoped discounts, corrupting downstream analytics.
Fix: Pin bulk operations to 2026-07 explicitly. Validate JSONL row counts against the dual-version health check before loading into data warehouses.
4. Cross-Market Inheritance Assumptions
Explanation: Assuming a regional discount automatically covers B2B or retail locations leads to incorrect scope mapping and merchant confusion.
Fix: Model market types as disjoint sets. Query marketType alongside marketIds and enforce type-specific routing in sync pipelines.
5. Reconciliation Jobs Driving Destructive Actions
Explanation: Sync loops that compare local state to API state will perceive missing rows as deletions. On older versions, this triggers false recreations or removals.
Fix: Decouple reconciliation from destructive actions. Use a staging table for API diffs, apply version filters, and require explicit merchant confirmation before tombstoning.
Explanation: Switching API versions mid-pagination can cause cursor misalignment if the underlying dataset changes size or order.
Fix: Restart pagination from the beginning after version upgrades. Cache cursors per version string to prevent cross-version cursor reuse.
7. Skipping Delta Validation Post-Upgrade
Explanation: Upgrading to 2026-07 without validating the row count delta leaves integration pipelines unaware of previously hidden discounts.
Fix: Run the dual-version count check immediately after upgrade. Reconcile the delta against local state before resuming normal sync operations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
App pinned to 2025-10 or earlier | Immediate version upgrade + dual-version validation | Pre-2026-07 versions silently drop market-scoped discounts | Low (schema-compatible upgrade) |
| High-volume sync pipeline (>10k discounts) | Batched marketIds filtering with cursor restart | Prevents over-fetching and ensures complete dataset capture | Medium (increased query complexity) |
| Merchant uses mixed market types (Region + B2B + Retail) | Type-disjoint assignment mapping | Cross-type inheritance does not exist; flat mapping causes scope drift | Low (requires type-aware routing) |
| Legacy reconciliation job driving deletions | Staging table + version-ambiguous null handling | Prevents garbage collection of active market-scoped discounts | Medium (pipeline refactoring) |
| Bulk export for analytics/reporting | Explicit 2026-07 pinning + row count validation | JSONL outputs inherit version filtering; validation catches gaps | Low (configuration change) |
Configuration Template
// shopify-discount-sync.config.ts
export const DiscountSyncConfig = {
apiVersion: '2026-07',
pagination: {
batchSize: 50,
maxRetries: 3,
cursorResetOnVersionChange: true
},
marketHandling: {
enableExplicitFiltering: true,
resolveInheritanceServerSide: true,
enforceTypeBoundaries: true
},
reconciliation: {
treatNullAsAmbiguous: true,
requireDeltaValidation: true,
stagingTablePrefix: 'stg_discount_'
},
monitoring: {
alertOnRowDelta: true,
deltaThreshold: 0,
logVersionMismatch: true
}
};
Quick Start Guide
- Initialize dual-version validation: Deploy the count comparison script against a staging shop. Record the delta between
2026-01 and 2026-07 to establish baseline exposure.
- Upgrade API version: Update your GraphQL client configuration to target
2026-07. Ensure all discount read paths inherit this version string.
- Implement market-aware querying: Replace blind
discountNodes enumeration with explicit marketIds filtering. Request marketType alongside discount metadata.
- Adjust reconciliation logic: Modify null-handling routines to treat missing IDs as ambiguous on older versions. Route all destructive actions through a staging validation layer.
- Validate and monitor: Run a full sync cycle. Compare row counts against the dual-version baseline. Enable monitoring alerts for future delta spikes and cursor misalignment events.