@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.0tarball was cut, so neither reached readers of the published0.1.0. They ship from0.1.1onward. The behaviour they describe is unchanged — this is a documentation gap being closed, not a change in whatpullSurfacereturns.
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— needsmetafieldDefinitionsper owner type. Empty array.markets— needsmarkets { webPresences { … } }. Empty array.structuredDataEmitters— only observable from rendered storefront HTML. Empty array.apps— hardcoded{ available: false, reason: 'read_apps not granted' }.appInstallationsis never queried;read_appsis 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/coreand the platform runtime it's embedded in. fetchonly. Noundici, 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 buildRunning 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.
pullSurfaceis partial.appEmbeds,appBlocks,appMetafieldNamespaces,marketsandstructuredDataEmittersare never queried and always come back empty;sitemapUrlandrobotsare alwaysundefined;appsis 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.
readThemeFileswalks at most 40 pages of 250, then returns what it has with no error and no marker. A theme larger than that yields a partialSurface.theme.filesthat looks complete. The cap exists to bound an unpaginated walk; raiseMAX_THEME_FILE_PAGESinsrc/source.tsif you need more. - Theme files and
scriptTagsdegrade to empty on a missing scope rather than failing the pull, so empty can mean "not granted" as easily as "none exist". An emptyscriptTagsis the normal result on an Online Store 2.0 shop. Resource.structuredDatais 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
sincepulls. An incremental pull returns what changed, never what was removed; reconciling deletions is the host's job. menusandurlRedirectsare always full pulls — neither connection supports anupdated_atfilter, sosincedoes not narrow them.Product.updatedAtmoves on inventory changes, so an incremental pull can return products whose content did not change.Resource.contentHashexcludessyncedAt/remoteUpdatedAtprecisely so a host can tell the difference.ThemeFileRef.hashis 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 unlikeMediaRef.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.hashis a PLACEHOLDER until the blob is fetched. At map time only the CDN url is known, so the field is seeded withsha256(sourceUrl). After downloading a blob the host must overwriteref.hashwithsha256(bytes)and then recompute the owningResource'scontentHash—contentHashcoversmedia[].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
permissiongaps 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 markedUNVERIFIED(§N.M)in the source, naming the section they rest on;✅VERIFIED(§N.M)marks what the docs state outright. TreatUNVERIFIEDbehaviour 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-devthrough a Headless storefront (15 products, 3 collections, 2 pages, 1 blog, everyProduct.onlineStoreUrlnull); its articles, redirects and nested-pagination shapes were not on that store and are hand-constructed from the 2026-07 Storefront schema, markedUNMEASUREDin the test. The documents themselves are validated against that schema, so a field name cannot be wrong; a runtime behaviour can.
