@tiledev/sdk-apptile-cart-hold
v0.4.1
Published
Reservation-backed cart holds for TilePacket apps — claims stock behind a cart line, stamps its expiry, and reports to the Cart Hold ledger. Plugs into @tiledev/sdk-shopify as a CartLineGuard.
Readme
@tiledev/sdk-apptile-cart-hold
Reservation-backed cart holds for TilePacket apps. It reserves the stock behind a cart line for a merchant-set window, so the last unit a shopper adds is theirs to check out — not something that appears and is then taken away.
It plugs into @tiledev/sdk-shopify as a CartLineGuard: one policy object the
Shopify provider runs around every cart write, rather than logic re-threaded through each screen.
npm install @tiledev/sdk-apptile-cart-hold@tiledev/sdk-shopify 0.10 or later is a peer — the two coordinate on the same cart, and Cart Hold
relies on 0.10's guard fixes (the hold stamp kept through a guard's answer and an attribute edit, units
released when an add is refused). react is a peer of the main entry, which re-exports the hook;
react-native is only ever a type.
The shape of the feature
add → claim the units, then stamp the line with when its hold expires
raise → claim the difference (safety net; the cart normally re-adds instead)
lower → hand the units back
remove → hand the whole line back…plus a fire-and-forget report of each change to the Cart Hold manager ledger — what the merchant's "Customer Carts" / "Product in Carts" dashboard reads.
The rules that keep it safe:
- The stamp is the receipt. Only a line carrying
_cart_hold_expiry_timewas ever claimed, so only such a line is released. A write that didn't land is released only when its approved input carries the stamp (an add) or the line it was to grow does (an increase); sdk-shopify 0.9 passes both on therejectedevent. Pre-orders (selling plans) are never claimed and never released. - Unreachable approves; an answer refuses. A reservation service that can't be reached (a
network error, a timeout) must not stop people buying, so the add goes through, unstamped:
nothing was claimed, so nothing may be released for it. An explicit
{ ok: false }, or an HTTP error the service answers with, refuses asUNAVAILABLE. (Before 0.4 this README said every failure approved; the code always refused on an HTTP error, and production relies on that.) enabled: falseis a kill switch: no duration read, no claim, no release, no report. Passfalsein a web preview, which must never claim real stock.- A variant refused as
SOLD_OUTreads as held out for a minute (isHeldOut,useHeldOut). Shopify still counts held units as sellable, so without this a product page keeps offering the add that can't land. - Take what's still free. An add refused as
SOLD_OUTwhose answer says some units are still free (effectiveInventoryabove 0 but below what was asked) claims that many instead, once. If that claim succeeds, the add goes in at that quantity, stamped as usual: 3 asked, 2 free, 2 added. If it is refused too, the add is refused as before. If it can't reach the service, the smaller add goes in unstamped (rule 2). Any add of more than one unit can be cut this way; a single-unit add can't be cut, so its behaviour is unchanged. The caller isn't told about a cut: the line simply holds fewer units. An answer withouteffectiveInventoryis refused as before. Decided 2026-10-04 by the Head of Engineering. - A quiet add says nothing.
beforeAdd(input, { quiet: true })(what sdk-shopify'saddLines(inputs, { quiet: true })passes; Buy again uses it) doesn't callonRefusal, because the caller shows one summary for the whole batch. It decides as usual, and aSOLD_OUTstill marks the variant held out, so the product page's "Fully reserved" stays right. An sdk-shopify without the option never passes it, and every refusal is told as before. - A request that went away isn't an error (0.4, SDK move 6).
onErrorisn't told about a request aborted at its timeout (AbortError), or a response whose blob React Native released as the JS context reloaded ("Unable to resolve data for blob"). The client already falls back on both (an unreachable service approves, unstamped), and reporting them would bury a real fault. Every other failure is reported as before. amore-v2 and amber-v2 filtered these in their ownonErroruntil then; production did the same.
Usage
import AsyncStorage from "@react-native-async-storage/async-storage";
import { configureCartHold, DEFAULT_MESSAGES } from "@tiledev/sdk-apptile-cart-hold";
const cartHold = configureCartHold({
config: {
enabled: true,
appId, // the Apptile ENGINE app id (not the Tile app id)
managerUrl, // apptile-carthold-manager
// reservationUrl defaults to https://cart-hold.apptile.io — pass it only to point elsewhere
},
shop: { shop: storeDomain, countryCode: "US", languageCode: "EN" },
storage: AsyncStorage, // remembers the last hold duration
resolveCustomer: () => currentCustomer(), // labels ledger rows; anonymous if omitted
onRefusal: (reason, { variantId }) => showToast(DEFAULT_MESSAGES[reason], "error"),
});Then hand the guard to the Shopify provider so it wraps every cart write:
<ShopifyProvider config={shopifyConfig} cartGuard={cartHold.guard}>
<App />
</ShopifyProvider>
reservationUrlis not under the manager. Claim/release live on their own deployment —https://cart-hold.apptile.io, which is the default, so most hosts leave the field out. Overriding it with the manager silently 404s every claim, and because the guard fails open, that mistake looks like Cart Hold doing nothing at all.
React helper
Everything ships from the package root, the hook included. There is no ./react subpath: one is
reachable only through the exports map, which a resolver that ignores it (node10, some test runners
and bundlers) cannot see at all.
import { useCartHold } from "@tiledev/sdk-apptile-cart-hold";
function Root() {
const { guard, hasLapsedHold } = useCartHold(cartHold); // primes the duration on mount
// …hand `guard` to ShopifyProvider; use `hasLapsedHold(cart.lines)` on the cart screen
}Reading the cart again after a hold runs out (0.4, SDK move 6). Cart Hold's sweep takes a lapsed line on its own schedule, so the cart screen reads the cart again at 1, 30, 60, 90, 120 and 150 seconds after the next hold runs out (production's schedule):
const { cart, refresh } = useCart();
useRefreshAfterHoldExpiry(cart?.lines, refresh); // { afterSeconds } to change the schedule- The reads follow whichever hold runs out next, and start over each time the lines change (a render with the same lines changes nothing). Leaving the screen cancels them.
- A read that fails is dropped; the next one tries again. No running hold, nothing is scheduled.
- It takes the lines and
refreshfrom the app, so this package still imports nothing of sdk-shopify's at runtime. The times arerefreshDelaysAfterHoldExpiry(lines, now).
Surface
@tiledev/sdk-apptile-cart-hold
| Export | Notes |
| --- | --- |
| configureCartHold(options) | Builds and remembers the session client. Returns a CartHoldClient. |
| getCartHoldClient() | The configured client, or null. |
| CartHoldClient | Class: .guard, .prime(), .holdSeconds(), .claim(), .release(), .report(), .isHeldOut(variantId), .subscribeHeldOut(listener). |
| .guard | A CartLineGuard for ShopifyProvider — beforeAdd(input, { quiet? }) / beforeIncrease / onLanded / onReleased. |
| holdExpiresAt · isHeld · isLapsed · hasLapsedHold · nextHoldExpiry · withStamp | Pure predicates over CartLines (no I/O). |
| refreshDelaysAfterHoldExpiry(lines, now?, afterSeconds?) | Pure: ms from now until each read after the next hold runs out, those already due left out. |
| REFRESH_AFTER_HOLD_EXPIRY_SECONDS | [1, 30, 60, 90, 120, 150]: production's reads after a hold runs out. |
| HOLD_ATTRIBUTE | "_cart_hold_expiry_time" — the platform-fixed line attribute. |
| DEFAULT_RESERVATION_URL | "https://cart-hold.apptile.io" — the reservation base used when reservationUrl is omitted. |
| DEFAULT_MESSAGES | Shopper copy for SOLD_OUT / UNAVAILABLE. |
| type CartHoldOptions, CartHoldConfig, ClaimVerdict, HoldExpiry, KeyValueStorage, … | Config + wire types. |
The React hook — the same root entry
| Export | Notes |
| --- | --- |
| useCartHold(client) | Primes on mount; returns { guard, hasLapsedHold, nextHoldExpiry }. |
| useHeldOut(client, variantId) | true while a claim for the variant was just refused as SOLD_OUT; re-renders when that starts and lapses. A product page shows the variant as unavailable meanwhile. |
| useRefreshAfterHoldExpiry(lines, refresh, { afterSeconds? }) | Reads the cart again (the app's refresh) at 1, 30, 60, 90, 120 and 150 s after the next hold among lines runs out. |
Config shape
interface CartHoldConfig {
enabled: boolean;
appId: string; // Apptile engine app id — x-shopify-app-id header + /users/<id> key
managerUrl: string; // apptile-carthold-manager: hold duration + ledger
reservationUrl?: string; // reservation service: POST /claim, POST /release
// default https://cart-hold.apptile.io
}
interface CartHoldOptions {
config: CartHoldConfig;
shop: { shop: string; countryCode: string; languageCode: string };
storage?: KeyValueStorage; // fallback for the hold duration; sync (localStorage) or async
resolveCustomer?: () => { id?; email? } | null;
onRefusal?: (reason: "SOLD_OUT" | "UNAVAILABLE", context: { variantId: string }) => void; // not for a quiet add
onError?: (error: unknown, context?: Record<string, unknown>) => void; // not for a request that went away (rule 7)
now?: () => number; // clock, for tests
fetch?: typeof fetch; // injectable transport
timeouts?: { claimMs?: number; reportMs?: number };
heldOutMs?: number; // how long SOLD_OUT marks a variant held out; default 60000
}Tests
npm test builds and runs test/client.test.mjs (32 checks, no network): claims and stamps, both
refusals and the held-out memory, an unreachable service, pre-orders, the remembered duration from a
synchronous storage, an unbound fetch, taking what's still free (and every case that doesn't),
quiet adds, increases, every release rule, the ledger payload, requests that went away (rule 7), and
the kill switch. Then test/refresh-after-expiry.test.mjs (10 checks): the read times, and
useRefreshAfterHoldExpiry through a small stand-in for React's hooks (the package has no renderer
among its dev dependencies).
Wire protocol
| Call | Endpoint | Body / result |
| --- | --- | --- |
| hold duration | GET {managerUrl}/users/{appId} | → { expiresAt } seconds (also accepts { data: { expiresAt } }) |
| claim | POST {reservationUrl}/claim | { variantId, quantity } → { ok, reason?, effectiveInventory? } |
| release | POST {reservationUrl}/release | { variantId, quantity } (fire-and-forget) |
| ledger | POST {managerUrl}/cart/update | { updateType, variantId, productId?, quantity, cartId, lineItemId?, userId, userMail, shop, countryCode, languageCode } |
effectiveInventory is read only from a SOLD_OUT refusal (rule 5). No client before 0.4 read it at
all, and what the service sends with a refusal is not written down anywhere: one test on a real
variant (2026-09-02, in another app) saw a claim and a release move it 3 → 2 → 3. Without it in the
answer, the add is refused as before.
{reservationUrl} defaults to https://cart-hold.apptile.io; {managerUrl} has no default, because
the manager is a different service and differs per environment. A base given with a trailing slash is
trimmed, so …apptile.io/ and …apptile.io behave the same.
All ids on the wire are the tail of the GID — a bare numeric id for variants/products; the cart id
keeps its ?key= query, because Shopify will not resolve a cart without it.
Design notes
A guard, not screen code. The reservation logic is one CartLineGuard handed to
ShopifyProvider, so beforeAdd claims and stamps, beforeIncrease catches raises, and onReleased
gives units back — for every cart action at once, including the ones (reorder, bundles, *ById) that
hand-wired code forgets.
One line per add. A decorated add is not merged into an existing line, so every add mints its own expiry. Units added ten minutes apart expire ten minutes apart; sharing one expiry would let later units be swept back while still in the cart.
Self-contained. @tiledev/sdk-shopify is imported type-only, and every ambient dependency
(config, storage, customer, toast, clock, fetch) is injected through CartHoldOptions — so tsc
passes with nothing installed and the runtime never reaches for a global it was not given.
Pairs with @tiledev/sdk-apptile-cart-sync. Cart Hold works on lines (what is reserved); Cart Sync works
on cart identity (which cart you are on). A line arriving by sync already carries whatever stamp its
author gave it, and Cart Hold reads that stamp exactly the same way.
