@tiledev/sdk-apptile-cart-hold
v0.2.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 @apptile/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 @apptile/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@apptile/sdk-shopify is a peer — the two coordinate on the same cart. 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.
Two rules 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. Pre-orders (selling plans) are never claimed and never released — no "is this a pre-order?" threaded through every call site. - Failing open beats failing shut. A reservation service that is down must not stop people buying, so a transport error approves the add. Only an explicit refusal blocks it.
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) => 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
}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(). |
| .guard | A CartLineGuard for ShopifyProvider — beforeAdd / beforeIncrease / onLanded / onReleased. |
| holdExpiresAt · isHeld · isLapsed · hasLapsedHold · nextHoldExpiry · withStamp | Pure predicates over CartLines (no I/O). |
| 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 }. |
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
resolveCustomer?: () => { id?; email? } | null;
onRefusal?: (reason: "SOLD_OUT" | "UNAVAILABLE") => void;
onError?: (error: unknown, context?: Record<string, unknown>) => void;
now?: () => number; // clock, for tests
fetch?: typeof fetch; // injectable transport
timeouts?: { claimMs?: number; reportMs?: number };
}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 } |
{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. @apptile/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.
