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

@tracyai/shopify

v0.5.0

Published

Framework-free Shopify adapter implementing the Tracy PlatformSource contract

Readme

@tracyai/shopify

A framework-free Shopify adapter implementing the PlatformSource contract from @tracyai/core.

Two doors, two factories, one contract: createShopifySource reads through the admin door (an app's Admin API token) and createShopifyContentSource reads through the content door (a Headless channel's private Storefront API token, held by a relay). Each speaks one API. Which door a host uses when a site holds both is the host's decision — the admin door strictly dominates — and is never made here.

Usage (admin door)

import { createShopifySource } from '@tracyai/shopify';

const source = createShopifySource(); // no arguments required

const ctx = {
  storeUrl: 'my-shop.myshopify.com',
  credential: process.env.SHOPIFY_ADMIN_TOKEN!, // request-scoped; never persisted here
  since: new Date('2026-07-01T00:00:00Z'),      // optional: incremental pull
  signal: controller.signal,                    // optional: cancels in-flight bulk operations
};

for await (const resource of source.pullResources(ctx)) {
  console.log(resource.role, resource.canonicalUrl);
}

const surface = await source.pullSurface(ctx);
const bytes = await source.fetchBlob(resource.media[0], ctx);

createShopifySource accepts an optional options bag (fetch, urlGuard, apiVersion, sleep, now, pollIntervals, maxBlobBytes) purely for host and test injection. Nothing is required, and no credential is ever captured at construction time — SourceContext carries it per call.

Design: six bulk operations, five at a time

pullResources runs one bulk operation per role — products, collections, pages, articles, menus and redirects — rather than bundling the small content connections into a single query, so a slow or timing-out connection cannot take the others down with it. A scheduler keeps at most five in flight, matching Shopify's documented limit of five concurrent bulk query operations per app per shop, and yields Resources as each operation's JSONL result is stitched. menus and urlRedirects have no updated_at filter and are therefore always full pulls.

fetchBlob and the bulk result download both go to non-Shopify CDN hosts and deliberately send no headers at all — sending X-Shopify-Access-Token to a third party would leak a merchant's Admin credential.

Usage (content door)

import { createShopifyContentSource } from '@tracyai/shopify';
import type { StorefrontRequest, StorefrontBody } from '@tracyai/shopify';

// The factory never speaks to Shopify itself. In Tracy Desk the transport posts
// each document to the Relay, which holds the private Storefront token; a host
// that holds one can post straight to https://<shop>/api/2026-07/graphql.json
// with `Shopify-Storefront-Private-Token`.
const transport = async ({ document, variables, ctx, signal }: StorefrontRequest): Promise<StorefrontBody> => {
  const response = await fetch(`https://seats.tracy.ai/sites/${siteKey}/content/query`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${ctx.credential}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: document, variables }),
    signal,
  });
  return response.json(); // Shopify's body, verbatim: data / errors / extensions
};

const source = createShopifyContentSource({ transport });

for await (const resource of source.pullResources(ctx)) {
  console.log(resource.role, resource.canonicalUrl);
}
const surface = await source.pullSurface(ctx);
console.log(surface.coverage); // { door: 'shopify:content', gaps: [...] }

transport is the one required option. fetch (for CDN blobs, sent with no headers), urlGuard, sleep, now, maxBlobBytes are injection points as on the admin door; menuHandles names the menus to read (default ['main-menu', 'footer'], because the Storefront API reads a menu by handle and cannot list them).

What the content door returns, and what it does not

The same six Resource roles the admin door returns — products, collections, pages, articles, menus, redirects — with the same identities (Resource.id is derived from the GID, so a product read through either door is the same product), the same canonical-url rule (Shopify's own onlineStoreUrl when present, else constructed from shop.primaryDomain), and SEO, media, vendor, tags and variants carried. Reads are sequential, one paginated listing per role, with nested connections (variants, media, a collection's members) followed past their first page.

Coverage (Surface.coverage, ADR 0092 §3) is what tells a reader how this differs from the admin door. Measured on a development store carrying every product state (2026-08-26): the content door saw 15 of 17 products, losing exactly DRAFT and ARCHIVED; collections, pages and blogs were identical. Products not published to the Online Store remained visible. The door declares:

  • draft and archived products — the Storefront API serves published products only.
  • menus other than main-menu and footer — no way to list menus; only the handles asked for.
  • metafields — returned by namespace and key only; nothing lists which exist. (The schema's word, not a measurement: tracy-desk#609 named only the first gap, and metafields were not measured.)

plus, measured on every pullSurface, a permission gap for any root Shopify refuses (ACCESS_DENIED): Storefront API permissions are shared across every Headless storefront on a store, so a permission tightened for another storefront narrows this door with no notification. pullResources yields nothing for a refused role rather than failing the pull; the refusal is said out loud in Coverage. The two calls are separate and stateless, so read pullSurface on the same Sync as pullResources — a host that reads only the latter never learns a role was refused.

Absent rather than faked, because the Storefront API does not have them: attributes.status on a product, inventoryQuantity on a variant (quantityAvailable needs a third permission), sortOrder on a collection, isPublished / publishedAt / templateSuffix on a page, isDefault on a menu — and remoteUpdatedAt on an article, since the Storefront Article has no updatedAt (drift detection loses its fallback for articles read this way; accepted).

pullSurface on this door has no theme, no script tags and no app inventory — each is stated as a platform-limit gap in inventory.gaps naming the door that has it, never left as a blank.

Narrow in 0.1.1

The Usage section above and this scope note landed after the 0.1.0 tarball was cut, so neither reached readers of the published 0.1.0. They ship from 0.1.1 onward. The behaviour they describe is unchanged — this is a documentation gap being closed, not a change in what pullSurface returns.

pullSurface is deliberately incomplete in this release. The following are not queried, so an empty value is never a finding about the shop:

  • appEmbeds, appBlocks — need theme file bodies / template JSON parsed. Empty array.
  • appMetafieldNamespaces — needs metafieldDefinitions per owner type. Empty array.
  • markets — needs markets { webPresences { … } }. Empty array.
  • structuredDataEmitters — only observable from rendered storefront HTML. Empty array.
  • apps — hardcoded { available: false, reason: 'read_apps not granted' }. appInstallations is never queried; read_apps is effectively unobtainable for a custom app. This string is a stub, not the result of a check.
  • sitemapUrl, robots — undefined. Both would mean reaching the storefront, not the Admin API.

What pullSurface does return: the MAIN theme's id, name and file list, and scriptTags. Both degrade to empty on a missing scope rather than failing the pull. An empty scriptTags list is the normal result on an Online Store 2.0 shop.

Constraints

  • Framework-free. No NestJS, no Electron, no decorators, no DI container. This package does not depend on anything outside @tracyai/core and the platform runtime it's embedded in.
  • fetch only. No undici, no other HTTP client. Node built-ins are avoided wherever a web standard exists, so the package can eventually run on Cloudflare Workers.
  • Bulk-only on the admin door. Admin API data is pulled through bulk operations rather than paginated calls, to stay within rate limits at scale. Bulk operations are Admin API only, so the content door pages the Storefront API instead (at most 250 per page, nested connections followed past their first page).

Development

Install dependencies and build:

yarn install
yarn build

Running tests

This machine has a history of OOM'ing when the test runner is left unbounded. Always run vitest with the capped flags below, one file at a time:

yarn vitest run --pool=forks --poolOptions.forks.maxForks=1 --no-file-parallelism <one file>

Never run the full suite unbounded.

Known limitations

Every item below is a deliberate, accepted trade-off in 0.1.1, not an unfinished edge. Read this section before assuming an empty or surprising value is a bug.

  • pullSurface is partial. appEmbeds, appBlocks, appMetafieldNamespaces, markets and structuredDataEmitters are never queried and always come back empty; sitemapUrl and robots are always undefined; apps is a hardcoded { available: false, … } stub rather than the result of a check. See Narrow in 0.1.1 for the per-field reasons. An empty value from any of these is not a finding about the shop.
  • Theme file listing truncates silently past ~10,000 files. readThemeFiles walks at most 40 pages of 250, then returns what it has with no error and no marker. A theme larger than that yields a partial Surface.theme.files that looks complete. The cap exists to bound an unpaginated walk; raise MAX_THEME_FILE_PAGES in src/source.ts if you need more.
  • Theme files and scriptTags degrade to empty on a missing scope rather than failing the pull, so empty can mean "not granted" as easily as "none exist". An empty scriptTags is the normal result on an Online Store 2.0 shop.
  • Resource.structuredData is left undefined — the Admin API does not expose the JSON-LD a storefront emits, and fetching storefront HTML is out of scope for sub-project 1.
  • Deletions are invisible to since pulls. An incremental pull returns what changed, never what was removed; reconciling deletions is the host's job.
  • menus and urlRedirects are always full pulls — neither connection supports an updated_at filter, so since does not narrow them.
  • Product.updatedAt moves on inventory changes, so an incremental pull can return products whose content did not change. Resource.contentHash excludes syncedAt/remoteUpdatedAt precisely so a host can tell the difference.
  • ThemeFileRef.hash is an MD5 checksum supplied by Shopify (checksumMd5), not a sha256 of bytes this package downloaded — the only per-file digest obtainable without fetching every theme file body. It is '' for files where Shopify reports no checksum. This is deliberately unlike MediaRef.hash, which is a sha256 of the blob's bytes: different fields, different algorithms, different semantics. A host that verifies sha256 for blobs must not apply that check to theme files. This divergence was reviewed and accepted — do not "make them consistent". Blob sha256 verification never touches theme files, so the two never meet; unifying them would mean downloading every theme file body on every pull.
  • MediaRef.hash is a PLACEHOLDER until the blob is fetched. At map time only the CDN url is known, so the field is seeded with sha256(sourceUrl). After downloading a blob the host must overwrite ref.hash with sha256(bytes) and then recompute the owning Resource's contentHash — contentHash covers media[].hash, so skipping the recompute leaves a stale hash and silently breaks change detection. The placeholder is the accepted design: hashing at map time would put a network fetch in the pure mapping layer and download every image during a metadata-only pull.
  • The content door's Coverage is declared, not detected, for its designed limits. Draft and archived products, unlisted menus and metafields are stated because the Storefront schema and a measured store say so; only the permission gaps are probed at run time. A future Shopify change that widens the Storefront API would need the declaration updated by hand.
  • Admin-door test fixtures are hand-constructed from the shapes documented in docs/shopify-api-verification.md — they are NOT recorded from a live API, because there was no dev store behind this package when they were written, so they are only as accurate as the published docs. Assumptions a live introspection run would settle are marked UNVERIFIED(§N.M) in the source, naming the section they rest on; ✅VERIFIED(§N.M) marks what the docs state outright. Treat UNVERIFIED behaviour as a hypothesis, not a guarantee.
  • Content-door fixtures are half measured, half constructed. The default fixture is the 2026-08-26 view of tracy-desk-dev through a Headless storefront (15 products, 3 collections, 2 pages, 1 blog, every Product.onlineStoreUrl null); its articles, redirects and nested-pagination shapes were not on that store and are hand-constructed from the 2026-07 Storefront schema, marked UNMEASURED in the test. The documents themselves are validated against that schema, so a field name cannot be wrong; a runtime behaviour can.