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-sync

v0.6.0

Published

Shared-cart sync ("Cart Assist") for TilePacket apps — reports which cart a device is on and adopts the customer's shared cart across devices and the web store. Coordinates with @tiledev/sdk-shopify and @tiledev/sdk-apptile-cart-hold.

Readme

@tiledev/sdk-apptile-cart-sync

The shopper's cart, shared — across their devices and the web store. (This is the platform's "Cart Assist"; the package is named for what it does.)

Apptile keeps a pointer to a customer's cart on the customer record, so the same cart can be seen and edited elsewhere — a support agent in the dashboard, or the shopper on the web store, which reads the identical metafield. This SDK reconciles the two directions:

up   — report which cart this device is on   (makes the backend write the pointer)
down — when the pointer names a different cart, adopt it, folding this device's cart in first
npm install @tiledev/sdk-apptile-cart-sync

Peers, all required: @tiledev/sdk-shopify 0.10 or later (the two coordinate on the same cart, and the Shopify host reads its hooks and its Customer Account sign-in), react and react-native (the host's foreground trigger). One entry exports everything; there are no subpaths (The Shopify host).

Signed-in shoppers only — the shared cart hangs off the customer. Every step degrades to "keep the local cart": this is a convenience layer, and a shopper losing their cart to a failed sync is far worse than a sync that quietly did nothing. Since 0.6.0 that holds: the device switches to the shared cart only once this cart's lines are known to be in it (or there were none). A merge that fails with nothing carried, or a cart that can't be read, leaves the device on its own cart and the pointer where it was, and onError says why (How a sync decides).

The Shopify host

Problem. Every app on @tiledev/sdk-shopify wired Cart Assist by hand, the same way and with the same traps, in about 140 lines of its own. Each one had to learn that a sync must wait for the stored cart, the session and the profile (an iPhone lost a guest's item at sign-in, 2026-10-05), that only a Shopify sign-in has a shared cart, that the cart adapters work by cart id (not on the phone's current cart), that the app returning to the foreground should sync, and that the client's fetch had to be passed bound. Since 0.6 that is ShopifyCartSyncHost.

Where it lives: "C · Main entry" (decided 2026-10-06, Head of Engineering). The host ships from the package's one entry, beside the client and the triggers. The rule is one entry, no subpaths:

  • a subpath (./shopify) is reachable only through the exports map, which a resolver that ignores it (node10, some test runners and bundlers) cannot see; that is why ./react was dropped (2026-08-19);
  • apps test an unpublished SDK through a src/__local__/<pkg> copy, and the Metro and Jest redirects that send it to node_modules on a phone match a package's main entry only;
  • a subpath would not have made @tiledev/sdk-shopify optional: the client already needs it at run time for the attribution helpers.

The cost: this entry imports react, react-native and @tiledev/sdk-shopify, so all three are required peers. Every Tile app has them.

What the app keeps: its settings (configureCartSync with its endpoints, app id, store, storage and error sink) and how it tells the shopper (a toast). Everything else is the host's:

import AsyncStorage from "@react-native-async-storage/async-storage";
import {
  configureCartSync,
  requestCartSync,
  signedInShopifyCustomer,
  ShopifyCartSyncHost,
  useMarkCartCheckedOut,
} from "@tiledev/sdk-apptile-cart-sync";

export const cartSync = configureCartSync({
  config: { enabled: true, apiUrl, mergeApiUrl },
  appId,                                                   // Apptile ENGINE app id (shared with Cart Hold)
  shop: storeDomain,
  customerAccountRequest: signedInShopifyCustomer.request, // the shopper the host sees
  getAccessToken: signedInShopifyCustomer.getAccessToken,
  storage: AsyncStorage,
  onError: (error, context) => captureError(error, { at: "cartSync", ...context }),
});

// Once, inside <ShopifyProvider>:
<ShopifyCartSyncHost client={cartSync} notify={(message) => showToast(message)} />

// A screen where a stale cart shows (the Cart, each time it shows):
requestCartSync();

// Checkout, when the order is placed (before sdk-shopify's reportOrderPlaced resets the cart):
const markCheckedOut = useMarkCartCheckedOut(cartSync);
await markCheckedOut(cart.id);

| Export | What it does | | --- | --- | | <ShopifyCartSyncHost client notify? /> | Mount once inside ShopifyProvider; renders nothing. Syncs once cartSyncReady, on a Shopify sign-in, when the cart id changes, when the app comes back to the foreground, and on requestCartSync(). Passes sdk-shopify's adopt/refresh/reset and the adapters below. Keys the shopper by the numeric id of useCustomer().customer, read when the sync decides. | | requestCartSync(): void | Sync now, if a host is mounted; otherwise nothing. | | useMarkCartCheckedOut(client) | Returns (cartId) => Promise<void>: marks the cart spent on the phone, then on Apptile's side for the signed-in shopper. Call it from the app's order-placed signal. | | signedInShopifyCustomer | { request, getAccessToken } for configureCartSync: the Customer Account API of the shopper signed in with Shopify, as the host last saw them. request rejects "Not signed in" and getAccessToken resolves null otherwise, or while no host is mounted. | | cartSyncReady({ cartLoaded, restoring, profileLoaded }) | The readiness gate: the stored cart has loaded, the session isn't still being read back, and the profile is in. | | signedInWithShopify(customer) | loggedIn && sessionKind === "shopify". An email-and-password session has no Customer Account API, so no shared cart. | | readCartLines · addCartLines · readCartAttributes · writeCartAttributes | The cart adapters, by cart id, over sdk-shopify's shopify.cart and toLineSnapshot. | | syncWhenAppComesBack(appState, sync) | Runs sync on each active; returns how to stop. The host passes React Native's AppState. |

Signed in with email and password (App Store review), the host does nothing: the shared cart lives on the Customer Account API. On the web it does nothing either (On the web).

Usage without the host

The client and the triggers, for an app that isn't on @tiledev/sdk-shopify's sign-in:

import AsyncStorage from "@react-native-async-storage/async-storage";
import { configureCartSync } from "@tiledev/sdk-apptile-cart-sync";

const cartSync = configureCartSync({
  config: { enabled: true, apiUrl, mergeApiUrl },
  appId,                         // Apptile ENGINE app id (shared with Cart Hold)
  shop: storeDomain,
  customerAccountRequest,        // host's authed Customer Account API transport
  getAccessToken,                // customer access token, for the report header
  storage: AsyncStorage,         // remembers locally-checked-out carts
});

await cartSync.sync({
  cartId: () => currentCartId,   // this device's cart GID — a function, read when the sync decides
  resolveCustomerId,             // async → numeric customer id (guest ⇒ null ⇒ no-op)
  adopt,                         // switch to a shared cart GID
  refresh,                       // re-read the current cart in place
  reset,                         // abandon a spent cart, start fresh
  notify,                        // shopper-facing toast
  readLines,                     // a cart's lines by GID (null = no such cart) — decides by contents
  addLines,                      // add lines to a cart by GID — carries this cart's lines across
});

// On checkout:
await cartSync.markCheckedOut(cartId, numericCustomerId);

React triggers

Everything ships from the package root, hooks included (one entry: The Shopify host). ShopifyCartSyncHost is built on these; use them directly only when the host doesn't fit.

import { useCartSync, useCartSyncCheckedOut } from "@tiledev/sdk-apptile-cart-sync";

function Root() {
  const { cart, adopt, refresh, reset } = useCart();       // from @tiledev/sdk-shopify
  const { isLoggedIn } = useAccount();
  const { ready } = useShopify();

  const { sync } = useCartSync({
    client: cartSync, isLoggedIn, ready,
    cartId: cart?.id ?? null,
    resolveCustomerId, adopt, refresh, reset, notify,
    readLines, addLines,                                   // by cart GID — see SyncCartArgs
  });

  // Attach `sync` to screen focus / app foreground yourself (host owns react-navigation & AppState).
}

The hook syncs on sign-in, on the SDK becoming ready, and on the cart id changing; it returns a stable sync() for the focus/foreground triggers, which the caller attaches (the Shopify host does both). It hands the client its cartId as a function over its newest options, so a run reads the cart id when it decides, not when it started.

Surface

@tiledev/sdk-apptile-cart-sync

| Export | Notes | | --- | --- | | configureCartSync(options) | Builds and remembers the session client. Returns a CartSyncClient. | | getCartSyncClient() | The configured client, or null. | | CartSyncClient | Class: .sync(args), .reportCart(), .markCheckedOut(), .recordCheckedOut(). | | toCartToken · toCartGid · toNumericCustomerId · isMergeable | Pure id helpers. | | CART_SYNC_NAMESPACE · ACTIVE_CART_KEY · CHECKED_OUT_CARTS_KEY · POINTER_QUERY | The metafield pointer + its query. | | DEFAULT_MESSAGES | Shopper copy for ADOPTED / SPENT. | | type CartSyncOptions, CartSyncConfig, SyncCartArgs, SharedCart, KeyValueStorage, … | Config + wire types. |

React triggers — the same root entry

| Export | Notes | | --- | --- | | useCartSync(options) | Mount-once triggers; returns { sync } for focus/foreground. | | useCartSyncCheckedOut(client, resolveCustomerGid) | Returns (cartId) => markCheckedOut. |

The Shopify host — the same root entry

ShopifyCartSyncHost, useMarkCartCheckedOut, requestCartSync, signedInShopifyCustomer, cartSyncReady, signedInWithShopify, the four cart adapters and syncWhenAppComesBack: see The Shopify host.

Config shape

interface CartSyncConfig {
  enabled: boolean;
  apiUrl: string;       // app-proxy on apptile-server: POST /update, GET /clear
  mergeApiUrl: string;  // server-side cart merge
}

interface CartSyncOptions {
  config: CartSyncConfig;
  appId: string;                          // Apptile engine app id → x-shopify-app-id
  shop: string;                           // Shopify store domain
  customerAccountRequest: <T>(query, variables) => Promise<T>;
  storage?: KeyValueStorage;              // locally-recorded spent carts
  getAccessToken?: () => Promise<string | null>;
  onError?: (error, context?) => void;
  fetch?: typeof fetch;                   // called unbound since 0.6: pass the global as is, or leave out
  timeoutMs?: number;                     // default 8000
  debug?: boolean;
}

interface SyncCartArgs {
  // A function is called when the sync decides — after the customer id and the pointer are read — so
  // a cart the app restored meanwhile is seen. A plain value (older hosts) is used as given.
  cartId: string | null | (() => string | null);
  resolveCustomerId: () => Promise<string | null>;
  adopt: (cartGid: string) => Promise<unknown | null>;
  refresh: () => Promise<unknown>;
  reset: () => Promise<void>;
  notify?: (message: string) => void;
  // With `readLines`, the sync decides by what each cart holds (below). Resolve null for a cart that
  // doesn't exist; throw when you can't tell — a throw keeps the local cart and changes nothing.
  readLines?: (cartGid: string) => Promise<CarriedLine[] | null>;
  // Carries this cart's lines into the shared one, attributes verbatim, when the merge service can't
  // take the cart (no `?key=`) or the merge failed.
  addLines?: (cartGid: string, lines: CarriedLineInput[]) => Promise<unknown>;
  // With both, the local cart's `_apptile_attribution` counts are merged into the shared cart once its
  // lines are carried (before adopting). Every other cart attribute is written back unchanged.
  readAttributes?: (cartGid: string) => Promise<{ key: string; value: string }[] | null>;
  writeAttributes?: (cartGid: string, attributes: { key: string; value: string }[]) => Promise<unknown>;
}

How a sync decides

A run reads the customer id, then the shared pointer and the spent carts, and only then this device's cart id. A spent local cart is reset first (and counts as missing); a spent pointer counts as none.

Same cart, or no pointer. Refresh this cart in place and report it, so a cart made here becomes the shared one.

A different shared cart, host gives readLines — decided by what each cart holds:

| This cart | Shared cart | What happens | | --- | --- | --- | | missing or empty | anything | Adopt the shared cart. | | has lines | missing (readLines → null) or empty | Keep this cart. No merge, no adopt. Refresh it and report it (/update), so the pointer moves here. Its Cart Hold claims stay on the cart they were made for. | | has lines | has lines | Merge server-side when the token has its ?key=. Merge succeeded → adopt. Otherwise (no key, or merge failed) read the shared cart again — a failed merge may have partly landed — and carry with addLines only the lines it lacks (same item = same variant and selling plan), attributes verbatim. Nothing lacking → adopt without adding. At least one landed → adopt. | | has lines | has lines, nothing got across (or no addLines) | Keep this cart. No adopt, the pointer is not moved, nothing is announced; onError gets { at: "cartSync.keepLocalCart", reason }. | | readLines throws for either cart (including the re-read before the carry) | | Keep this cart. Nothing changes; onError gets the thrown error. |

A different shared cart, no readLines (older hosts) — 0.5 behaviour: merge when the token has its ?key=, then adopt; a cart without a key is left behind and the shared one adopted. One change: a failed merge keeps this cart (no adopt) and reports cartSync.keepLocalCart.

After an adopt the shopper is told once (DEFAULT_MESSAGES.ADOPTED) and the shared cart is reported. An adopt that resolves null (the shared cart has expired) does nothing more.

Overlapping triggers. A sync() while a run is in progress is not dropped: it queues one more run that starts when the current one ends. Later calls during the same run replace the queued args, so one run follows, with the newest. Every caller's promise resolves after that run too. (0.5 dropped them, and one of them carried the restored cart id a sign-in needed.)

Loop guards. An adopt changes the cart id, which re-triggers the host — the run that follows sees the same cart on both sides and stops. The report and the announcement are each sent once per cart, and a carry already made for a pair of carts is not made again if the adopt after it failed.

Merge contract

POST {mergeApiUrl} with { cartId1: <this cart's GID>, cartId2: <shared cart's GID>, shop } and the x-shopify-app-id header. Verified 2026-10-05:

  • Success is HTTP 2xx and merged: true in the body: 200 {"merged":true,"mergePolicy":"max","shop":…,"appId":…,"operations":{<gid>:{"added":n,"updated":n},…}}. Anything else — another status, merged not true, a body that isn't JSON, no answer — is a failed merge, reported as { at: "cartSync.mergeCarts", status, body: <first 300 chars> }.
  • It merges both ways: each cart ends up with both carts' lines.
  • An item in both carts keeps the larger quantity, on the shared cart's line.
  • Line attributes such as _cart_hold_expiry_time survive on the lines it adds.
  • It can be slow (3.6 s measured); the request shares the client's timeoutMs (default 8000). A timeout or a 5xx is not proof that nothing moved, so the carry that follows re-reads the shared cart and skips what is already there — it never doubles a line the merge did move.

On the web

Metro resolves a web stand-in for the two triggers, so useCartSync and useCartSyncCheckedOut do nothing in a browser build, and nor does ShopifyCartSyncHost or useMarkCartCheckedOut, which are built on them. Cart Assist reports which device a cart is on and adopts the shared cart in its place; running that from a preview would move the pointer on the shopper's real customer record. The client is untouched — configureCartSync and CartSyncClient are plain fetch, so a host that means to report from a browser can still drive them directly.

Wire protocol

| Call | Endpoint | Body | | --- | --- | --- | | report cart | POST {apiUrl}/update | { cartId: token, customerId, customerAccessToken } | | mark checked out | GET {apiUrl}/clear?cid={customerId}&shop={domain} | — | | merge carts | POST {mergeApiUrl} (header x-shopify-app-id) | { cartId1: gid, cartId2: gid, shop } → { merged: true, … } | | read pointer | Customer Account GraphQL | metafields in Apptile-CartAssist (smart_cart_id, smart_cart_checkedout_ids) |

Cart tokens on the wire keep their ?key= query — Shopify cannot read or mutate a cart without it. Customer ids are the numeric id, not the GID.

Design notes

Kept clear of Cart Hold. Cart Sync works on cart identity; @tiledev/sdk-apptile-cart-hold works on lines. Adoption is a read plus a state swap, and the merge is server-side. The one line write is the carry (when the merge can't run or failed): this cart's lines are re-added into the shared cart, attributes verbatim, outside Cart Hold's guard — the same as a merged line. A line arriving by merge, carry or adoption already carries whatever _cart_hold_expiry_time its author gave it. Claims for a cart we then leave are not released here; those units moved into the adopted cart or are still in the manager's ledger, whose sweep releases them. When the shared cart is empty, the device keeps its cart, so its claims stay where they were made.

One run at a time, none dropped. Focus, foreground, sign-in and cart-change all trigger the sync and overlap constantly; a call during a run queues one more run with the newest args (see How a sync decides), and the report and the announcement are guarded so an adopted cart is neither re-reported nor re-announced.

Injected, then wired once. The client imports only sdk-shopify's pure attribution functions, and useCartSync receives the cart controls (adopt/refresh/reset) as arguments; every other dependency (config, storage, customer-account transport, access token, fetch) is injected through CartSyncOptions. ShopifyCartSyncHost is the one place that reads sdk-shopify's hooks to fill them in, so an app on sdk-shopify writes none of it.

fetch is called unbound (0.6). The client used to keep the global and call it as its own method, which a browser refuses ("Illegal invocation"), so apps passed (input, init) => fetch(input, init). It now calls whatever it was given, or the global, as a plain function, as Cart Hold does.