Work in progressThis site is being built in public — expect rough edges, missing screenshots and drops that change under you.

← the log

Let each component decide whether stock blocks the sale

If your app assembles products out of other products — a kit, a bundle, a build-to-order configuration — something has to decide how many of the assembled thing a shop can sell. The obvious answer is "however many the scarcest component allows". That answer is wrong for every component the shop never actually runs out of, and Shopify's default quietly makes it wrong in the worst direction: inventoryPolicy defaults to DENY, so a component sitting at zero stock makes everything built from it unsellable, without anybody choosing that.

This is a follow-along. By the end, each component declares whether its stock is real scarcity, your buildable-quantity math honours that, and the values in Shopify agree with the values in your database.

If your app sells only what it stocks one-for-one, you don't need any of this.

Before you start

What you're building

Two things, and the order matters:

  1. A backfill that pushes SKU, price, inventory policy and quantity from your database up to Shopify — so the two stop disagreeing.
  2. A rule: only components on DENY limit the build. CONTINUE components never bottleneck, even at zero.

The backfill comes first because of a trap in step 5: if Shopify holds different values from your database, the products/update webhook resolves that disagreement in Shopify's favour, and your careful DB writes vanish on the shop's next product edit.

Step 1 — Read what Shopify currently holds

You cannot send only the differences until you know the current state. One query per batch of product ids:

query ComponentBackfillState($ids: [ID!]!) {
  nodes(ids: $ids) {
    ... on Product {
      id
      title
      variants(first: 1) {
        edges {
          node {
            id
            price
            inventoryPolicy
            inventoryItem {
              id
              sku
              tracked
              inventoryLevels(first: 1) {
                edges {
                  node {
                    quantities(names: ["available"]) { name quantity }
                    location { id }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

available is one of several named quantities — you have to ask for it by name and dig it back out of the array.

Step 2 — Push only what differs

Build the input field by field, and skip the call entirely when nothing changed. That is what makes the backfill re-runnable and an interrupted run safe to just repeat:

const variantInput: Record<string, unknown> = { id: current.variantId };
const inventoryItemInput: Record<string, unknown> = {};
let changed = false;

if (!blank(local.price) && !samePrice(local.price, current.price)) {
  variantInput.price = parseFloat(local.price).toFixed(2);
  changed = true;
}
if (current.policy !== "CONTINUE") {
  variantInput.inventoryPolicy = "CONTINUE";
  changed = true;
}
if (!blank(local.sku) && local.sku !== current.sku) {
  inventoryItemInput.sku = local.sku;          // SKU lives on the inventory item, not the variant
  changed = true;
}
if (current.tracked === false) inventoryItemInput.tracked = true;
if (Object.keys(inventoryItemInput).length > 0) {
  variantInput.inventoryItem = inventoryItemInput;
  changed = true;
}

Then one mutation per product:

mutation ProductVariantsBulkUpdateComponents($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants { id }
    userErrors { field message }
  }
}

Read userErrors on every response. A 200 with a populated userErrors array is the normal way this mutation refuses you.

Step 3 — Quantities need a second mutation

This is the step people lose an hour to. ProductVariantsBulkInput has an inventoryQuantities field, and the schema tells you exactly why you can't use it here:

The inventory quantities at each location where the variant is stocked. […] Supported as input with the productVariantsBulkCreate mutation only.

So quantities go up separately — and that mutation takes an array, which means unrelated products ride along in one call:

mutation InventorySetQuantitiesComponents($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { createdAt reason }
    userErrors { field message }
  }
}
variables: {
  input: {
    name: "available",
    reason: "correction",
    ignoreCompareQuantity: true,
    quantities: chunk,   // [{ inventoryItemId, locationId, quantity }, …], batched at 100
  },
}

ignoreCompareQuantity: true is what makes this a backfill rather than an optimistic update. Leave it out and you must send the quantity you believe is currently there, and every stale read fails the call.

Step 4 — Make the policy the authority in your own math

Now the actual rule. A component on CONTINUE has effectively unlimited supply, so it is skipped entirely — it can never be the bottleneck. Only DENY components contribute floor(available / usage):

for (const [componentId, usage] of usageByComponent) {
  const info = inventory.get(componentId);
  if (!info) continue;

  // CONTINUE = unlimited supply: never gates the build, never the bottleneck.
  if (info.policy === "CONTINUE") continue;

  const buildable = Math.floor(info.available / usage);
  if (buildable < quantity) {
    quantity = buildable;
    bottleneck = componentId;
  }
}

And the case that falls out of it: when every component is CONTINUE, nothing about the assembled product is supply-limited — so it gets CONTINUE too, and its quantity stops meaning anything.

const assembledPolicy = allComponentsUnlimited ? "CONTINUE" : "DENY";

The check that matters, because this is the case the default gets wrong:

it('a CONTINUE component never bottlenecks, even at zero on hand', () => {
  const result = calculateBuildableQuantity({
    usage: { unlimited: 1, scarce: 1 },
    inventory: new Map([
      ['unlimited', { available: 0, policy: 'CONTINUE' }],
      ['scarce', { available: 6, policy: 'DENY' }],
    ]),
  });

  // Without the policy rule the zero-stock component would drag this to 0.
  expect(result.quantity).toBe(6);
});

If your app has two places that compute this — say a background pricing job and a save path — they must learn the rule together. Two implementations that disagree means the answer depends on which ran last.

Where you can see it worked

Run the backfill and read the counts back:

Variants updated: 139. Quantities set: 139. Already correct: 3. Failed: 0.

Run it a second time without changing anything:

Variants updated: 0. Quantities set: 0. Already correct: 142. Failed: 0.

Then go look at the thing that was broken: take a component whose stock is zero, confirm it's on CONTINUE, and open an assembled product that uses it on the storefront. It's buyable. Before the rule, it was silently unsellable and nothing anywhere said why.

Gotchas

  • DENY is the default and it is silent. A variant that never set a policy is on DENY. Nobody chose it; it just blocks.
  • Push to Shopify before you trust your database. If a products/update webhook handler copies Shopify's values down, every value you wrote straight to the DB is one product edit away from being overwritten. After the backfill the handler is a no-op, which is the point.
  • Check for title drift before running. Touching the product fires products/update, and if your handler writes Shopify's title back down, any titles that differ get overwritten as a side effect of a backfill that was never about titles. Count them first and say so.
  • SKU is on inventoryItem, not the variant. It goes in the nested inventoryItem input.
  • A variant with no inventory level has no location id. Skip its quantity rather than guessing a location.
  • Anywhere you create or republish a product, use the same policy. One hardcoded DENY in a publish path undoes the backfill one product at a time.

Docs

Implementation prompt

I have an embedded Shopify admin app (Remix + TypeScript, Admin GraphQL API version 2025-04)
with write_products and write_inventory scopes, and products whose inventory is tracked at a
location. Check these prerequisites first and set up anything missing before you start.

Build two things:

A. A re-runnable backfill service that, for each of my products, reads the current variant
   state from Shopify (price, inventoryPolicy, inventoryItem sku/tracked, and the "available"
   quantity with its location id), compares it to my database, and pushes ONLY the fields that
   differ. It must support a dry run that reports what would change without writing, and
   return counts of updated / already-correct / failed.

B. A pure function `calculateBuildableQuantity` that, given how many of each component an
   assembled product uses and each component's { available, policy }, returns how many can be
   built and which component is the bottleneck. Cover it with unit tests.

Do not invent my data model, my product taxonomy, or my UI. Read the local values from a
repository/service interface you stub, and leave the schema to me.

Constraints — these are the traps:
- inventoryPolicy defaults to DENY. Treat "no policy set" as DENY, and make CONTINUE explicit.
- A CONTINUE component has unlimited supply: skip it entirely in the bottleneck math, even at
  zero available. Only DENY components contribute floor(available / usage).
- If every component used is CONTINUE, the assembled product itself must be set to CONTINUE.
- productVariantsBulkUpdate CANNOT carry inventory quantities on 2025-04 — inventoryQuantities
  is supported on productVariantsBulkCreate only. Use a separate inventorySetQuantities call.
- inventorySetQuantities takes an array: batch it (100 per call) rather than one call per item.
- Pass ignoreCompareQuantity: true on the backfill, or a stale read fails the write.
- sku and tracked live on inventoryItem, not on the variant.
- Read the "available" quantity by name out of the quantities array; it is not a scalar field.
- Every mutation returns userErrors on a 200. Check it on every response and count failures.
- Send only differing fields, so a second run reports zero updates and an interrupted run can
  simply be repeated.
- If a variant has no inventory level, it has no location id: skip its quantity, do not guess.

Acceptance check: run the backfill twice. The first run reports updates; the second reports
zero updated and everything already correct. Then set one component to 0 available with policy
CONTINUE — calculateBuildableQuantity must ignore it entirely, and the assembled product must
stay buyable. Tell me if either fails.

Verified against Admin GraphQL 2025-04 (productVariantsBulkUpdate, inventorySetQuantities) — backfill run on a dev store, 139 products updated, 3 already correct, 0 failed.