Shop & Variant Quantity Limits
UberLotto v2 enforces quantity limits at two levels — shop-wide (global and per-product-type) and per-variant. The system uses a dual-namespace approach (cart.* and limits.*) with automatic fallback, plus integration with Shopify's native quantityRule.
Architecture
Implementation
The quantity limits system is implemented in app/lib/shop-limits.server.ts and queried via the Storefront API GraphQL in app/graphql/game-detail/GameDetailQuery.ts.
Interfaces
// app/lib/shop-limits.server.ts
interface ShopLimits {
global?: number;
lottery?: number;
scratchCard?: number;
game?: number;
}The ShopLimits interface represents the resolved limits after the fallback chain is applied. All fields are optional — if no metafield is set, the limit is undefined (no restriction).
Dual-Namespace System
The system maintains two parallel sets of metafields:
| Purpose | Current Namespace | Legacy Namespace |
|---|---|---|
| Standard | cart.* | limits.* |
| Priority | Checked first | Fallback only |
This exists for backward compatibility. New configurations should use the cart.* namespace.
Shop-Level Metafields
Current (cart.*)
| Metafield | Description |
|---|---|
cart.max_quantity_global | Maximum quantity across all product types |
cart.max_quantity_lottery | Maximum quantity for lottery products |
cart.max_quantity_scratch_card | Maximum quantity for scratch card products |
cart.max_quantity_game | Maximum quantity for game products |
Legacy (limits.*)
| Metafield | Description |
|---|---|
limits.global_limit | Legacy global maximum |
limits.lottery_limit | Legacy lottery maximum |
limits.scratch_card_limit | Legacy scratch card maximum |
limits.game_limit | Legacy game maximum |
Variant-Level Metafields
| Metafield | Status | Description |
|---|---|---|
cart.max_quantity | Current | Per-variant maximum quantity |
limits.max_quantity | Legacy | Per-variant maximum (fallback) |
Priority Chain
Shop-Level Resolution
For each product type, the system resolves limits in this order:
1. cart.max_quantity_{type} (e.g., cart.max_quantity_lottery)
2. limits.{type}_limit (e.g., limits.lottery_limit)
3. cart.max_quantity_global (global fallback)
4. limits.global_limit (legacy global fallback)Implementation in getShopLimitByType():
function getShopLimitByType(
shopLimits: ShopLimits,
productType: string,
): number | undefined {
switch (productType?.toLowerCase()) {
case 'lottery':
return shopLimits.lottery ?? shopLimits.global;
case 'scratch-card':
return shopLimits.scratchCard ?? shopLimits.global;
case 'game':
return shopLimits.game ?? shopLimits.global;
default:
return shopLimits.global;
}
}TIP
The parseWithFallback() helper in shop-limits.server.ts resolves cart.* → limits.* for each category before the type-to-global fallback runs. This means cart.max_quantity_lottery takes priority over limits.lottery_limit, which takes priority over cart.max_quantity_global.
Variant-Level Resolution
For individual variants, the priority is:
1. Shopify native quantityRule.maximum (highest priority)
2. cart.max_quantity (variant metafield)
3. limits.max_quantity (legacy variant metafield)The native Shopify quantityRule is set in the variant's inventory settings and is the recommended approach for new configurations.
Shopify Native quantityRule
Shopify's built-in quantity rules provide:
| Property | Type | Description |
|---|---|---|
maximum | Integer | Maximum purchasable quantity |
minimum | Integer | Minimum purchasable quantity |
increment | Integer | Quantity step (e.g., buy in multiples of 5) |
How to Set
- Go to Shopify Admin > Products > [Product] > Variants
- Edit the variant
- Under Inventory, set Quantity rules:
- Minimum:
1 - Maximum:
10(or desired limit) - Increment:
1
- Minimum:
GraphQL Response
variants(first: 250) {
nodes {
quantityRule {
maximum # null if not set
minimum # defaults to 1
increment # defaults to 1
}
}
}WARNING
If quantityRule.maximum is set, it always takes precedence over metafield-based limits. This is by design — native Shopify rules are the most reliable enforcement mechanism.
How to Configure
Setting Shop-Level Limits
Shop metafields must be set via the Shopify Admin API or a metafield editor app, as the Shopify Admin UI does not expose shop-level metafields directly.
Via Shopify Admin API:
# Set global limit to 20
POST /admin/api/2024-01/metafields.json
{
"metafield": {
"namespace": "cart",
"key": "max_quantity_global",
"value": "20",
"type": "number_integer"
}
}# Set lottery-specific limit to 10
POST /admin/api/2024-01/metafields.json
{
"metafield": {
"namespace": "cart",
"key": "max_quantity_lottery",
"value": "10",
"type": "number_integer"
}
}Setting Variant-Level Limits
Option 1: Native quantityRule (Recommended)
- Edit the variant in Shopify Admin
- Set quantity rules under Inventory
Option 2: Metafield
- Edit the variant in Shopify Admin
- Scroll to Metafields
- Set
cart.max_quantityto the desired integer value
Example Configuration
A typical lottery product setup:
| Level | Metafield | Value | Effect |
|---|---|---|---|
| Shop | cart.max_quantity_global | 50 | No customer can add more than 50 of any single item |
| Shop | cart.max_quantity_lottery | 10 | Lottery products capped at 10 |
| Variant | quantityRule.maximum | 5 | This specific variant capped at 5 |
The effective limit for this variant would be 5 (the most restrictive applicable limit).
Caching
Shop limits are fetched using Shopify's CacheLong() strategy:
const {shop} = await storefront.query(SHOP_LIMITS_QUERY, {
cache: storefront.CacheLong(),
});This means changes to shop-level metafields may take time to propagate. To force a refresh, clear the Hydrogen cache or wait for the cache TTL to expire.
Parsing Behavior
The parseMetafieldValue() function handles edge cases:
nullorundefinedmetafield → returnsnull(no limit)- Empty string → returns
null "0"or negative values → returnsnull(treated as no limit)- Valid positive integer string → returns the parsed number
function parseMetafieldValue(metafield?: ShopMetafield | null): number | null {
if (!metafield?.value) return null;
const parsed = parseInt(metafield.value, 10);
return isNaN(parsed) || parsed <= 0 ? null : parsed;
}Migration Path
If you're migrating from the legacy limits.* namespace:
- Set the new
cart.*metafields with the same values - Leave the
limits.*metafields in place as fallback - Verify the application reads the correct values (check debug logs)
- Optionally remove
limits.*metafields after confirming
TIP
The system logs which source was used for each limit at debug level. Check server logs for messages like Set lottery limit: 10 (source: primary) to confirm the correct namespace is being read.