npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. The stamp is the receipt. Only a line carrying _cart_hold_expiry_time was 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 the rejected event. Pre-orders (selling plans) are never claimed and never released.
  2. 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 as UNAVAILABLE. (Before 0.4 this README said every failure approved; the code always refused on an HTTP error, and production relies on that.)
  3. enabled: false is a kill switch: no duration read, no claim, no release, no report. Pass false in a web preview, which must never claim real stock.
  4. A variant refused as SOLD_OUT reads 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.
  5. Take what's still free. An add refused as SOLD_OUT whose answer says some units are still free (effectiveInventory above 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 without effectiveInventory is refused as before. Decided 2026-10-04 by the Head of Engineering.
  6. A quiet add says nothing. beforeAdd(input, { quiet: true }) (what sdk-shopify's addLines(inputs, { quiet: true }) passes; Buy again uses it) doesn't call onRefusal, because the caller shows one summary for the whole batch. It decides as usual, and a SOLD_OUT still 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.
  7. A request that went away isn't an error (0.4, SDK move 6). onError isn'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 own onError until 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>

reservationUrl is 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 refresh from the app, so this package still imports nothing of sdk-shopify's at runtime. The times are refreshDelaysAfterHoldExpiry(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.