Rp Source Shopify
WixOfficialPublished by Wix
- Skill
- Tool integration
- Automation and integrations
- MIT
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The rp-source-shopify skill acts as a specialized source adapter designed to extract private data from a Shopify store. It leverages the Shopify CLI to perform discovery and bulk data extraction, ensuring a read-only process that never modifies the source store. The skill handles complex data structures like bundles, discounts, and nested order information, while providing robust error handling and session management to ensure data integrity during the migration process.
What it does
- Performs discovery of store entities including orders, customers, products, and gift cards.
- Executes bulk data exports using the Shopify CLI for efficient, large-scale data retrieval.
- Manages session authentication via a dedicated staff account with read-only permissions.
- Handles complex nested data reads for orders, refunds, and fulfillment details.
- Records source coverage and identifies withheld fields to ensure data transparency.
- Validates regional settings and currency data to maintain consistency during migration.
When to use it
- When migrating data from a Shopify store using the Admin API (private data mode).
- When you need to perform a comprehensive, read-only extraction of store content.
- When you require a structured discovery process to assess store data before full migration.
- When dealing with complex Shopify entities like bundles, discounts, and multi-currency orders.
Advantages
- Ensures store safety through a strictly read-only implementation.
- Provides granular control over data extraction with resumable step sequences.
- Automates the discovery of store configuration, locales, and entity counts.
- Includes built-in checks for session validity and required Shopify CLI versions.
- Handles complex Shopify-specific data relationships and nested connections automatically.
Usage examples
- “Run the discovery process for my Shopify store.”
- The agent executes the discovery scripts, validates the session, and generates a report of all store entities and their coverage.
- “Extract all store data for migration.”
- The agent initiates the bulk export process, reading all entities into NDJSON files while managing nested data dependencies.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/wix/rp-source-shopify/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip rp-source-shopify.zip -d .agents/skills
Documentation by Wix
rp-source-shopify
Shopify source adapter for private data: orders, customers, gift cards, discounts, draft and
archived products, content, navigation, locations, shipping, metaobjects, files and redirects.
It follows the same contract as rp-source-wordpress and rp-source-csv: it discovers what the
store holds, writes the standard discovery files, records everything it could not capture as a
source-coverage row, and hands over to the general mapping, approval, import and reporting
steps. Nothing it reads is written back; the store is never changed.
When this skill is used
rp-discoveryruns the Discovery section when the Shopify acquisition-mode answer wasAdmin API(sourceMode=private_data).rp-import-codegenreads the Read contract section.
Access path: the Shopify CLI, read-only
Every call is store execute --store <shop>.myshopify.com --query <query> --json --version 2026-10, run through lib/shopify-cli.js by the adapter's own Shopify CLI install, using the
session the operator approved in the browser on their machine. No token is stored by this
adapter.
Read-only by construction. A mutation document is refused before any process starts. The one exception is the bulk extract's start: Shopify classes
bulkOperationRunQuery(a read job) as a mutation, sostartBulkOperationsends that one fixed document with--allow-mutations. No other call can carry the flag, and operations are never cancelled.A GraphQL error is data about the store, returned to the caller: discovery turns it into a
capture-failedcoverage row. A read Shopify throttles on all 6 calls (1, 2, 4, 8, 16 s between them) isshopify_throttled, also acapture-failedrow at discovery. Any other CLI failure stops the step (exit 2); it is never retried in a loop and never read as "no records".Shopify's terms forbid automating the store admin in a browser. Never do it.
The connection: one dedicated staff account, checked before every read
Inputs: SHOPIFY_SHOP_DOMAIN=<shop>.myshopify.com in config/source.shopify.env (not a
secret). The operator's Node.js must be 22.12 or later.
The store owner creates a staff account for the migration (Administrator role, or a custom role with app and channel management) and gives it to the import operator. The owner's own account is not an allowed fallback.
Run the session check from this skill directory:
node scripts/shopify-session-check.js --project <migrations-root>/<project>It checks, in order and stopping at the first failure: Node.js ≥ 22.12; the adapter's own Shopify CLI at exactly 4.8.3 in
~/.replatform/tools/shopify-cli-4.8.3; the connection and Shopify's scope readback (every scope the reads declare); and that Shopify lists the pinned Admin API version2026-10as supported. Then it writesdata/shopify-discovery/session-receipt.json(no secret, no personal data).The first time, it asks the first-connection question (a discovery
questionaction): was the connection approved from the dedicated migration staff account? Answerdedicated-accountonly when it was.other-accountstops: discovery does not start and the question is asked again after the owner provides a dedicated account.Follow its stop codes:
| Code | What the operator does |
|---|---|
shopify_cli_node_unsupported | Requires Import Operator. Switch this terminal to Node.js 22.12 or later (for example the current LTS) with the usual installer or version manager, check node --version, run the check again. Nothing was installed or read |
shopify_cli_not_installed / shopify_cli_version_changed | Ask the operator; with their consent run the check again with --install (installs or reinstalls exactly 4.8.3 into the adapter folder, about 52 MB, nothing global) |
shopify_session_missing / shopify_scopes_missing | Run the check with --connect and approve in the browser while signed in to the store as the dedicated staff account. --connect requests the full derived scope list |
shopify_cli_incompatible | The adapter install is damaged or moved; reinstall with --install |
shopify_api_version_unsupported | Stop and report: moving the pin is a tested code change, never a workaround |
Never type shopify store auth or any other bare shopify command. --connect starts the
browser approval itself through the adapter's CLI with Shopify's self-update switched off; a
bare command would run another Shopify CLI and could upgrade a machine-wide installation.
Never run npm -g, shopify config autoupgrade, or change any Shopify CLI setting.
Exit codes: 0 passed and confirmed; 3 the first-connection question is pending; 2 stopped (the printed code says why); 1 usage error.
Run the check before discovery and before each extract. Discovery refuses to start
(shopify_session_not_checked, shopify_first_connection_unconfirmed) until the latest check
passed for this store and the first-connection answer is dedicated-account.
When the connection expires
A read whose CLI session expired stops the step with source_auth_expired and creates a
manual_action user action for the stage (discovery or execution); the run holds at
awaiting_user_action. Requires Import Operator: run the session check (with --connect
if asked), then answer the request and run the same step again. Only the receipt changes, so the
step resumes where it stopped and no finished read is repeated.
The staff account after cutover
The first passing check after a dedicated-account answer records the account in
data/shopify-discovery/staff-access.json. From then on every completion report lists
shopify-staff-account-removal (high) until the owner's removal is recorded:
node scripts/shopify-session-check.js --project <dir> --confirm-staff-removal owner-removed-accountRemove the account only after cutover and after the last read from Shopify (including any
catch-up). If the confirmation reports the record unreadable or missing, run
--repair-staff-access first: it keeps the damaged file aside, rebuilds a pending entry and
never writes confirmed. Nothing else closes the action; never edit the file by hand.
Discovery
Run from this skill directory:
node scripts/shopify-discovery.js --project <migrations-root>/<project> --store <shop>.myshopify.comThe steps run in this order. Each records its result in
data/shopify-discovery/discovery-steps.json before the next starts, so a restart runs only
the steps not yet recorded and reads no entity it already captured:
| Step | What it does | Writes |
|---|---|---|
capture | One count (where the API has one) and up to 3 sample records per entity, plus the shop and its policies | entities/<entity>.json |
withheld-details | Eight ordinary 3-record probes classify withheld name, email, phone and address kinds for customers and orders | kind-probes.json |
gift-card-census | Exact-count-gated gift-card census; every positive balance is owed | bulk-manifest.json, gift-card-census.json |
order-window | Orders older than the CLI's ~60-day order-history window (below) | order-window.json |
locale | The store's currency, time zone, language and country (below) | source-locale.json |
questions | Asks the merchant what earlier steps could not read, as discovery question user actions | answered orchestration/user-actions/*.json |
coverage | Builds the coverage rows and writes the raw-capture index | source-coverage.json, README.md |
⚠️ When this bundle does not ship a step's implementation, discovery stops at that step with
shopify_discovery_step_unavailable (exit 2) after recording the steps before it. Do not work
around it: a discovery that skipped the check would look complete while missing data. Report it
to the user.
Exit codes: 0 complete; 3 a merchant question is pending — the run holds at
awaiting_user_action; answer it, then run the same command again (nothing is read again);
2 stopped, with the reason code in the printed JSON; 1 usage error.
To re-read entities after their access is fixed, add --reread <entity> (repeatable). Only those
entities are read again; the coverage file is rebuilt.
One store per capture. The store is recorded in discovery-steps.json and in every
entities/<entity>.json before anything is read. A run for any other --store stops with
shopify_discovery_store_mismatch (exit 2) before reading or changing anything, so no artifact
ever combines two stores. --restart discards the whole data/shopify-discovery/ capture
(steps, entities, every technical step's files, coverage file, index) and reads from scratch; it
keeps the connection records (session-receipt.json, staff-access.json). It
is refused with shopify_restart_blocked_handoff when this project already hands a different
store to mapping — source-profile.md, source-schema.json or the promoted
discovery/source-coverage.json exists — because those would keep presenting the first store:
start a new migration project for the other store instead. It also refuses with
shopify_restart_blocked while a merchant question from the previous capture is
still unanswered. Never delete files by hand to get past a mismatch.
Regional settings
| Neutral field | Shopify source | Rule |
|---|---|---|
paymentCurrency | shop.currencyCode (the shop's base currency) | order amounts are written from the shop-money set, never the presentment set |
timeZone | shop.ianaTimezone | stored exactly as Shopify names it |
language, locale | primary shopLocales value plus shop.billingAddress.countryCodeV2 | needs read_locales; denied → unknown / auth-gated |
The locale step writes data/shopify-discovery/source-locale.json, which
promote-source-coverage.js copies to discovery/source-locale.json and refuses to promote
without. Currency (shop.currencyCode) and time zone (shop.ianaTimezone) come from the shop
capture, with no extra read; the time zone is stored exactly as Shopify names it. Two small reads
add the language (shopLocales { locale primary published }, evidence shopLocales[primary]) and the
country (shop.billingAddress.countryCodeV2). They are separate queries that declare no scope: the
locales read needs read_locales, which discovery does not require, so an access-denied answer
records language and locale as unknown with reason auth-gated and discovery
continues. A value Shopify does not return, or one that fails validation (for example a
three-letter language), is unknown too; nothing is guessed or defaulted. An unknown value is not a coverage row.
Shop-money and presentment currency counts remain Shopify adapter evidence and policy; they are not fields in the neutral locale record. Field names come from the Admin API schema.
When orders are imported, write every order amount from its shopMoney set (the store's base
currency) and never from the presentment set; nothing is converted. Count the orders whose
presentmentCurrencyCode differs from the shop currency (tallyOrderCurrencies in
lib/shopify-regional.js) and report the count on the orders-presentment-currency-differs line
of the currency report, so the merchant knows how many orders were shown to shoppers in another
currency. The import gate sees a single currency in that case and passes.
Order-history window
Through the CLI Shopify shows only the last ~60 days of orders, on every read path. The
order-window step reads shop.createdAt:
shop older than the window, or
createdAtunreadable → rowshopify:orders-before-window;shop younger than the window → it reads the visible order with the earliest
processedAt; one processed before the shop existed (imported history) → the row, no question; otherwise it asks the merchant "Were orders imported into this Shopify store from another system?" (no→ no row;yesorunknown→ the row).
Never use the visible orders' dates as proof of completeness. The row is auth-gated /
requires-development, holds the run at mapping until it has a disposition, and loss is
accepted only through a loss-accepted gate.
Withheld customer and order details
The withheld-details step sends one ordinary query per entity and protected kind. A kind is
withheld only when Shopify returns an error path naming one of that kind's fields. A blank with no
error is the merchant's blank; the accepted policy deliberately adds no full-store census. A
pathless ACCESS_DENIED, an unrelated path or another failure produces shopify:<entity>
capture-failed, never a guessed kind. A pathless denial first rechecks the session and permits at
most one reapproval.
kind-probes.json records only outcomes, paths and error codes, plus the per-entity projection;
it never records customer values. Each completed probe is atomic, so restart sends only missing
probes. --reread customers or --reread orders also clears that entity's probes.
The extract removes exactly the withheld kind's fields from customer and order queries. Name is
first/last name; address is address lines, zip and geolocation. Company, city, province, country
and every other explicitly unprotected field remain. A customer is written to
customers-withheld-identity.ndjson, not customers.ndjson, only when an identity kind is
withheld and no readable name, email or phone remains. The manifest records
withheldIdentityCount, and the count check includes the sidecar.
Gift cards: codes never move, balances remain owed
Shopify exposes only lastCharacters and maskedCode, never a reusable full code. Never create
a spendable Wix gift card from this read. The census and every extract run the same bulk query,
then gate it before done with giftCardsCount(limit: null) bound to the read's createdAt.
Only precision: EXACT with at least that many read cards passes. A short or inexact count, a
rejected filter, or an invalid plain-decimal amount fails the entry and records
shopify:gift-cards; the extract remains stopped until --retry-failed starts a new read.
Every positive balance is owed, including disabled cards and cards carrying an expiry date.
Money is summed exactly per currency without floating point or conversion. The census keeps only
counts, per-currency totals, disabled/expired counts and read evidence; it stores no card id,
masked code or customer value. A nonempty census emits shopify:gift-card-codes. If cards first
appear during extract, that row is added and the extract stops so mapping can decide it.
Entities read
products (all statuses, with variants), collections, orders (with payment transactions,
tax inclusion, refunds and fulfillments), customers, gift-cards, discounts (code and
automatic, every type, with scope, eligibility, limits and configuration), pages, blogs, articles, menus,
locations, inventory-items, delivery-profiles, metaobjects (listed through their
definitions, each with its count), files, url-redirects, and the singletons shop and
shop-policies. lib/discovery-reads.js holds each named query.
Synthesize, then promote
After the script exits 0, synthesize source-profile.md and the project-root
source-schema.json from data/shopify-discovery/entities/*.json, following
source-schema.example.json in this directory. Per entity:
rawFileisentities/<entity>.json;recordCountis itscountonly when itscountExactis true (Shopify answeredprecision: EXACT). OtherwiserecordCountis null: where the API has no count, say so; where the count is a lower bound (countPrecision: "AT_LEAST"), put it inrecordCountAtLeastand never write it asrecordCount. Never invent a count.inUseis true when samples were returned.An entity whose file carries
errors[]is not synthesized as an empty entity: it is a coverage row already.Record in
source-profile.md: access is the operator's Shopify CLI session, read-only; the CLI only shows recent orders; the store's currency and timezone from theshopsingleton.
Then promote, from rp-discovery:
node scripts/promote-source-coverage.js <migrations-root>/<project> --adapter shopifyExtract
The full store is read through Shopify's server-side bulk export, one dataset at a time, never page by page. Run from this skill directory, after the session check:
node scripts/shopify-extract.js --project <migrations-root>/<project> [--retry-failed] [--catch-up]The store comes from config/source.shopify.env (or --store). It writes
data/source-extract/<entity>.ndjson — products, collections, customers, orders (the
three order queries joined by order id: core, refunds, fulfillments), gift-cards-codeless, discounts
(the core query plus five item-scope queries, joined by discount id),
delivery-profiles, inventory-items, pages, blogs, articles, menus, url-redirects,
locations, metaobjects, files — plus the optional
customers-withheld-identity.ndjson — and data/source-extract/extract-manifest.json. The 23
bulk queries, eight probe-*.graphql ordinary queries and four read-*.graphql ordinary queries
(below) each start with a # scopes: line the session check derives the required scopes from. Orders and customers are filtered to the
manifest's snapshot time T.
Bundles. Product variants carry
requiresComponents; for each variant where it is true,read-variant-components.graphqlreads itsproductVariantComponents(component variant, its product, quantity) before the products dataset is done. During canonical mapping calllib/shopify-bundles.js→canonicalBundleFromShopifyProduct(product): whenhasBundleFieldsis true, set canonicalproduct.bundleto itsbundle, or keep itsunreadableReason. What Wix does with a bundle is the target's rule (rp-target-wixstores/product-bundle).Order payment evidence.
ordersrows carrytaxesIncludedand atransactionslist on the order itself (kind, status, gateway, amounts,processedAt,createdAt,errorCode,test,manualPaymentGateway, the parent transaction's id); refund transactions stay underrefunds[].transactions. It is a plain list, not a connection, so it adds no bulk connection.Discount rows.
discountsholds one row per discount node and every discount type: codes and their usage counts, status and dates,combinesWith, who may use it (context: all buyers, customer ids, segments, or a market count), usage and per-customer limits, recurring-cycle limit, minimum requirement, free-shipping destinations and maximum shipping price, buy-X-get-YcustomerBuys/customerGetsquantities and values,usesPerOrderLimit, and an app discount's type. The products, variants and collections an item scope names are connections under every type and do not fit one query's five-connection limit, so five further queries (discounts-gets-products|variants|collections,discounts-buys-items|variants) each read one role and one kind of item; the final join setsproducts,productVariantsorcollectionsondiscount.customerGets.items/discount.customerBuys.itemsonly where that scope owns them ([]is an empty list read; absent means not read, never an empty scope). An item query row for a discount the core query never listed is dropped and counted indroppedOrphansByQuery. Market restrictions record only their count, not the markets.No connection inside a list field. Shopify refuses a bulk query that has one ("a connection field within a list field"). Three datasets have such connections, so their bulk query carries only the scalars and, before the dataset is marked
done,lib/shopify-nested-reads.jsreads the connections with ordinary read-only queries and writes them into the same rows:orders-refunds(refunds[].refundLineItems,refunds[].transactions, by refund id),orders-fulfillments(fulfillments[].fulfillmentLineItems, by fulfillment id) anddelivery-profiles(profileLocationGroups[].locationGroupZones[].methodDefinitions, one zone at a time, rates paged). A refused query cost halves the batch and then the page size; a connection that reports another page is read to its last page. The reads are serial and paced: 4 refund ids or 10 fulfillment ids per call and a 250 ms pause after each, so the pauses alone are about 1 minute per 1,000 refunds and 0.4 minute per 1,000 fulfillments before network time (estimateReadsinlib/shopify-nested-reads.jscomputes it; 100,000 of each is over 2 hours). The cost recurs on every full extract and catch-up, so state it before starting one on a large store. Finished chunks of 50 rows are kept in the staging directory (<dataset>.nested-progress.*), so a later failure does not repeat them:A transport or session failure stops the run as any read does. The dataset stays
downloaded; a rerun continues after the last finished chunk.A GraphQL error fails the dataset (
shopify_nested_read_failed, acapture-failedrow, the rows already read in its evidence). An ordinary rerun stops again at once; recovery is--retry-failed, which reads that dataset's bulk export again and then its nested reads. Those reads continue after the kept chunks only when the new download holds exactly the same rows; otherwise they start from the first row.
A dataset with no refunds, fulfillments or profiles sends no read. The contract test rejects any bulk query that nests a connection under a list field. A manifest written before a catalog query existed (for example the five discount item queries) gains the missing queries as pending datasets when it resumes, so its final files are never reported complete with that data absent; a manifest already
completeis not changed and needs--catch-up.One operation at a time, in the table order. Each state change is one atomic manifest write (
pending → starting → running → downloaded → done, orsettling → failed | denied), so a restart resumes at the exact state and a finished dataset is never read again. A dataset whose start was interrupted is simply started again; the first job's result is never read.Only a complete download counts. A file is
downloadedonly for HTTP 200, exactlyfileSizebytes, ending in a newline, every line one JSON object. An expired link (HTTP 400 orre-runs the dataset (at most 2 times); other failures download again (3 tries), then one re-run, then
failed.
A failed, expired, cancelled or partial operation is re-run once, then
failed(shopify_bulk_failed). Afailedextract dataset stops the extract now and on every later start.ACCESS_DENIEDfirst runs the session check; a refusal with a valid session is final for that dataset (customer and order queries go to the withheld-detail attribution of the customer/order spec; every other dataset isfailed).Customer/order denial attribution. A valid-session bulk denial enters
settling, then runs that entity's four ordinary probes. A named, newly withheld kind is upserted and promoted as its own coverage row and stops withshopify_protected_fields_withheld; after mapping, the denied query resets with the narrower projection. An all-readable, pathless or unrelated diagnostic isunattributed, leaves the projection unchanged and stops every start withshopify_bulk_faileduntil--retry-failed. Each diagnostic is keyed by query andsettlingAt, so handler replay never sends a finished probe or duplicates a row.settlingmakes consequences recoverable: the verbatim error is written first, then the outcome's idempotent handler (the coverage row), then the terminal state. A restart reruns the handler of anysettlingdataset before anything else.Overdue. A job still preparing 4 hours after it started creates one
questionuser action (shopify_bulk_overdue, choiceswait/abort) and the step holds atawaiting_user_action(exit 3). Answer it and run the same command again.abortmakes the datasetfailed(shopify_bulk_overdue_aborted); Shopify's operation is not cancelled.Count check. After the read, customers (plus the withheld-identity file) and orders must reach Shopify's own exact count at
T. Fewer, or a count Shopify gives only asAT_LEAST, is acapture-failedrowshopify:<entity>(throughlib/import-findings.jsrules) and the stopshopify_extract_count_short/shopify_extract_count_inexact; the run goes back through mapping. A read is a picture of the store while it ran, not a frozen snapshot.Gift-card gate. While
gift-cardsis stilldownloaded, its exact count and every decimal amount are checked. Only a pass writes the codeless per-read file named bycardsFile, records owed counts and balances in the manifest, and permitsdone. Files nodoneentry names are ignored.gift-cards-codeless.ndjsoncontains nomaskedCodeand is never a Wix gift-card import file.--retry-failed(Import Operator): sends every stopping dataset — eachfailedentry, each unattributeddeniedentry, and the datasets whose count check stopped the extract — back topendingand reads them again. Nothing else is reset.--catch-up(the source catch-up rerun): archives acompletemanifest asextract-manifest.<T_prev>.jsonand reads the whole store again in full with a newT. A manifest that is not complete is resumed instead. Without the flag acompletemanifest with its final files present reads nothing.
Exit codes: 0 complete; 3 an overdue question is pending; 2 stopped (the printed
code says why; finished datasets are kept); 1 usage error. Stop codes are in
rp-execute-import. A manifest bound to another store stops with shopify_extract_store_mismatch.
Cutover — final re-read. Every import leaves one task: after the merchant's Shopify activity
stops (and before the staff account is removed), run the session check, then the rerun for new
and modified records, which runs shopify-extract.js --catch-up. Check that
extract-manifest.json says complete, every entry's readAt is after activity stopped, and the
rerun completed. Requires Import Operator.
Cutover — export withheld details. Any withheld-kind or hollow-customer row also keeps the
high shopify-withheld-details-export completion action open, whatever its mapping disposition.
After the final catch-up and after the merchant exports customers and orders from Shopify Admin,
record the attestation in the original migration environment:
node scripts/shopify-discovery.js --project <migrations-root>/<project> --confirm-withheld-export owner-exportedRegenerate the completion report and check the action is gone. A later Shopify read or a new withheld row reopens it. This is an operator attestation, not a Shopify verification. The future 0197/0198 catch-up/checklist integration is not wired; the completion report is the durable handoff. Requires Import Operator.
Cutover — owed gift-card balances. The completion report always reevaluates the high-severity
shopify-gift-card-codes-not-migrated action whenever shopify:gift-card-codes exists, even
after its loss was accepted. Requires Import Operator:
node scripts/shopify-gift-card-remedy.js export --project <project>
node scripts/shopify-gift-card-remedy.js activity-stopped --project <project> --at <ISO-time> --by <name>
# Run the final --catch-up read here.
node scripts/shopify-gift-card-remedy.js record-replacements --project <project> --evidence <file.csv> --by <name>
# Or record the still-open date on which Shopify will stop redeeming them:
node scripts/shopify-gift-card-remedy.js keep-redeeming --project <project> --until <YYYY-MM-DD> --by <name>export writes data/shopify-cutover/gift-cards-owed.csv with id, last characters, balance,
currency, enabled, expiry and current replaced state — no code or customer column. A card closes
only when the current manifest has a successful read started after activity-stopped, and its
CSV evidence names a Wix replacement worth at least that card's balance in the same currency
plus a non-future holder-notification date and confirmer. A newer card or higher balance reopens
that card; a pending/failed read or newer activity-stop record reopens all. Keeping Shopify
redeeming is never closure.
Coverage rows
The adapter always writes data/shopify-discovery/source-coverage.json (platform: "shopify",
adapter: "shopify"), with rows: [] when there is nothing to report. Rows are decided at
mapping like any source's rows.
coverageId | When | reason | recoverability |
|---|---|---|---|
shopify:<entity> | A discovery read of that entity failed | capture-failed | import-operator |
shopify:<entity> | A throttled read, an unattributed protected-field probe, a failed or overdue-aborted extract dataset, or a full read short of Shopify's count | capture-failed | import-operator |
shopify:orders-before-window | Orders older than the CLI window may exist (order-window step) | auth-gated | requires-development |
shopify:customer-<kind>, shopify:order-<kind> (name, email, phone, address) | An ordinary probe error path names that withheld kind | auth-gated | requires-development |
shopify:customers-withheld-identity | At least one extracted customer has no readable identity after projection | auth-gated | requires-development |
shopify:gift-card-codes | Gift-card codes are never readable | route-unsupported | not-recoverable |
capability is the registry string where one exists (commerce.orders, crm.contact,
commerce.gift-cards, commerce.catalog-product, …); otherwise null with
capabilityUnregistered: true. ⚠️ A row carries no count field; counts stay in the entity files
and the row's evidence points at them.
Read contract
For rp-import-codegen when source-schema.json.platform === "shopify":
Vendor
lib/shopify-cli.jsand read only through it, with its defaults (the adapter's CLI,CI=1,--version 2026-10); never passcommandorenv. No direct HTTP, no stored token, never--allow-mutationsfor a read. The discovery samples and counts page withreadAllPages(first: 250, 500 ms apart); a full-store read never does.Run the session check before each extract. On
source_auth_expiredcalllib/shopify-session.jsrecordAuthExpired(projectDir, { stage: 'execution', store })and stop; the extract resumes from its manifest after re-approval.The full-store extract is the adapter's bulk extractor (
scripts/shopify-extract.js, above), not a generated per-record pager: generate the importer to readdata/source-extract/<entity>.ndjsononly whenextract-manifest.jsonsayscomplete. If this bundle does not ship it, stop codegen and report it.A gap first found during the extract is recorded with
lib/import-findings.jsstopWithFinding(projectDir, { row, code }): the row is added to the raw coverage file bycoverageId(replacing a row with the same id), the file is re-promoted, and the extract stops withcode. From there the run goes back through mapping by the pipeline's general path, the same as for every source.
Not in this adapter
Installed apps and their data (reviews, subscriptions, loyalty points) are not inventoried here.
The merchant's own CSV exports go through
rp-source-csv, a separate, degraded route.The final catch-up remains the source-catch-up task shared with every source. Withheld-detail export and staff-account removal are tracked here (above) until the cutover checklist carries them.
Files
- ATTRIBUTION.mdAdded by AutoGineer846 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md32.0 KiB
- lib/discovery-reads.jsScript8.6 KiB
- lib/import-findings.jsScript1.4 KiB
- lib/shopify-bulk-sqlite-worker.jsScript1.4 KiB
- lib/shopify-bulk.jsScript74.8 KiB
- lib/shopify-bundles.jsScript2.5 KiB
- lib/shopify-cli.jsScript20.3 KiB
- lib/shopify-gift-cards.jsScript21.1 KiB
- lib/shopify-nested-reads.jsScript14.4 KiB
- lib/shopify-protected-fields.jsScript38.1 KiB
- lib/shopify-regional.jsScript5.9 KiB
- lib/shopify-session.jsScript22.1 KiB
- queries/articles.graphql380 B
- queries/blogs.graphql266 B
- queries/collections.graphql816 B
- queries/customers.graphql1.8 KiB
- queries/delivery-profiles.graphql505 B
- queries/discounts-buys-items.graphql836 B
- queries/discounts-buys-variants.graphql693 B
- queries/discounts-gets-collections.graphql979 B
- queries/discounts-gets-products.graphql949 B
- queries/discounts-gets-variants.graphql1.0 KiB
- queries/discounts.graphql8.4 KiB
- queries/files.graphql515 B
- queries/gift-cards.graphql356 B
- queries/inventory.graphql746 B
- queries/locations.graphql476 B
- queries/menus.graphql435 B
- queries/metaobjects.graphql728 B
- queries/orders-core.graphql4.8 KiB
- queries/orders-fulfillments.graphql747 B
- queries/orders-refunds.graphql632 B
- queries/pages.graphql286 B
- queries/probe-customers-address.graphql262 B
- queries/probe-customers-email.graphql180 B
- queries/probe-customers-name.graphql234 B
- queries/probe-customers-phone.graphql231 B
- queries/probe-orders-address.graphql407 B
- queries/probe-orders-email.graphql190 B
- queries/probe-orders-name.graphql341 B
- queries/probe-orders-phone.graphql318 B
- queries/products.graphql1.6 KiB
- queries/read-delivery-zones.graphql1.7 KiB
- queries/read-fulfillment-items.graphql743 B
- queries/read-refund-items.graphql1.4 KiB
- queries/read-variant-components.graphql751 B
- queries/url-redirects.graphql171 B
- scripts/shopify-discovery.jsScript32.5 KiB
- scripts/shopify-extract.jsScript13.1 KiB
- scripts/shopify-gift-card-remedy.jsScript10.4 KiB
- scripts/shopify-session-check.jsScript5.6 KiB
- source-schema.example.json5.7 KiB
lib/shopify-bulk.js
'use strict';
// The sequential Shopify bulk runner. One bulk operation runs at a time, for both
// callers: the discovery census and the extract. A caller supplies its own
// manifest, staging directory, ordered query list and handlers; this module owns every state, stop
// and recovery rule, so a caller adds no runner and no recovery path of its own.
//
// Query states, one atomic manifest write per change:
// pending -> starting -> running -> downloaded -> done
// \-> pending (one re-run, or an expired link)
// running | downloaded -> settling -> failed | denied (terminal; the handler runs in between)
// `settling` makes every terminal side effect recoverable: a query never becomes `failed` or
// `denied` directly. The runner writes `settling` with the outcome and the verbatim error, runs the
// outcome's handler, then writes the terminal state with the handler's result. On every start each
// `settling` entry's handler runs again before anything else, so every handler must be idempotent.
//
// run() resolves with { status: 'complete' | 'stopped' | 'awaiting_user_action', code?, message?,
// details?, manifest }. Anything else it throws is a crash: the manifest on disk is the recovery
// point, and a fresh runner over the same files resumes it.
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const http = require('node:http');
const https = require('node:https');
const path = require('node:path');
const readline = require('node:readline');
const { fork } = require('node:child_process');
const { finished } = require('node:stream/promises');
const { buildShopifyCoverageRows, upsertAdapterCoverageRows, writeJsonAtomic } = require('../../../lib/source-coverage.js');
const { createUserAction, readUserAction } = require('../../../lib/user-actions.js');
const { ShopifyCliError } = require('./shopify-cli.js');
const { nestedReadHook } = require('./shopify-nested-reads.js');
const { ShopifyExtractStop } = require('./import-findings.js');
const session = require('./shopify-session.js');
const QUERIES_DIR = path.join(__dirname, '..', 'queries');
const MANIFEST_SCHEMA_VERSION = 1;
const POLL_START_MS = 2000;
const POLL_CAP_MS = 30000;
const OVERDUE_MS = 4 * 60 * 60 * 1000;
const MAX_ATTEMPTS = 1;
const MAX_DOWNLOAD_TRIES = 3;
const MAX_LINK_RERUNS = 2;
const SNAPSHOT_PLACEHOLDER = '{{SNAPSHOT_TIME}}';
const OVERDUE_NAMESPACE = 'shopify-bulk-overdue';
const RAW_EXTRACT_DIR = 'data/source-extract';
const EXTRACT_MANIFEST_FILE = 'extract-manifest.json';
const SQLITE_WORKER = path.join(__dirname, 'shopify-bulk-sqlite-worker.js');
const STATUS_QUERY = 'query RpBulkStatus($id: ID!) { node(id: $id) { ... on BulkOperation { id status errorCode createdAt completedAt objectCount rootObjectCount fileSize url partialDataUrl } } }';
// ---------------------------------------------------------------------------------------------
// The query catalog: the queries, in this order, and how each one's child rows fold
// ---------------------------------------------------------------------------------------------
const MEDIA_TYPES = ['MediaImage', 'Video', 'ExternalVideo', 'Model3d', 'GenericFile'];
const DISCOUNT_APPLICATION_TYPES = ['DiscountCodeApplication', 'AutomaticDiscountApplication', 'ManualDiscountApplication', 'ScriptDiscountApplication'];
function typesTo(types, field, extra = {}) {
return Object.fromEntries(types.map((type) => [type, { field, ...extra }]));
}
// `children` maps a child row's `__typename` to the field it folds into on its parent (`into` names
// an inline object of the parent that holds the field). `rootFields` and `childFields` are the
// fields initialised to [] so an empty connection reads as empty, not absent. A bulk query cannot
// hold a connection inside a list field, so no catalog entry has such a child: those connections
// are read by lib/shopify-nested-reads.js before the dataset is done.
// Item scopes of a discount (the products, variants and collections a customerGets or customerBuys
// scope names) are connections under every discount type, and one bulk query allows five
// connections. Each `discounts-gets-*` / `discounts-buys-*` query therefore reads one role and one
// kind of item, and `joinDiscountFiles` folds its arrays into the discount row at `parent`.
const DISCOUNT_ITEM_CHILDREN = Object.freeze({
products: { type: 'Product', owner: 'DiscountProducts' },
productVariants: { type: 'ProductVariant', owner: 'DiscountProducts' },
collections: { type: 'Collection', owner: 'DiscountCollections' },
});
function discountItemQuery(name, side, fields) {
return {
name,
entity: 'discounts',
rootFields: fields,
children: Object.fromEntries(fields.map((field) => [DISCOUNT_ITEM_CHILDREN[field].type, { field }])),
supplement: { parent: ['discount', side, 'items'], fields },
};
}
const QUERY_CATALOG = Object.freeze([
{ name: 'products', entity: 'products', rootFields: ['variants', 'media', 'metafields'], children: { ProductVariant: { field: 'variants' }, ...typesTo(MEDIA_TYPES, 'media'), Metafield: { field: 'metafields' } } },
{ name: 'collections', entity: 'collections', rootFields: ['products', 'metafields'], children: { Product: { field: 'products' }, Metafield: { field: 'metafields' } } },
{ name: 'customers', entity: 'customers', rootFields: ['metafields'], children: { Metafield: { field: 'metafields' } } },
{ name: 'orders-core', entity: 'orders', rootFields: ['lineItems', 'shippingLines', 'discountApplications', 'metafields'], children: { LineItem: { field: 'lineItems' }, ShippingLine: { field: 'shippingLines' }, ...typesTo(DISCOUNT_APPLICATION_TYPES, 'discountApplications'), Metafield: { field: 'metafields' } } },
{ name: 'orders-refunds', entity: 'orders', children: {} },
{ name: 'orders-fulfillments', entity: 'orders', children: {} },
{ name: 'gift-cards', entity: 'gift-cards', children: {} },
{ name: 'discounts', entity: 'discounts', children: { DiscountRedeemCode: { field: 'codes', into: 'discount' } } },
discountItemQuery('discounts-gets-products', 'customerGets', ['products']),
discountItemQuery('discounts-gets-variants', 'customerGets', ['productVariants']),
discountItemQuery('discounts-gets-collections', 'customerGets', ['collections']),
discountItemQuery('discounts-buys-items', 'customerBuys', ['products', 'collections']),
discountItemQuery('discounts-buys-variants', 'customerBuys', ['productVariants']),
{ name: 'delivery-profiles', entity: 'delivery-profiles', children: {} },
{ name: 'inventory', entity: 'inventory-items', rootFields: ['inventoryLevels'], children: { InventoryLevel: { field: 'inventoryLevels' } } },
{ name: 'pages', entity: 'pages', children: {} },
{ name: 'blogs', entity: 'blogs', children: {} },
{ name: 'articles', entity: 'articles', children: {} },
{ name: 'menus', entity: 'menus', children: {} },
{ name: 'url-redirects', entity: 'url-redirects', children: {} },
{ name: 'locations', entity: 'locations', children: {} },
{ name: 'metaobjects', entity: 'metaobjects', rootFields: ['metaobjects'], children: { Metaobject: { field: 'metaobjects' } } },
{ name: 'files', entity: 'files', children: {} },
]);
const QUERY_NAMES = Object.freeze(QUERY_CATALOG.map((entry) => entry.name));
const CUSTOMER_QUERIES = Object.freeze(['customers']);
const ORDER_QUERIES = Object.freeze(['orders-core', 'orders-refunds', 'orders-fulfillments']);
const DENIAL_ATTRIBUTED_QUERIES = Object.freeze([...CUSTOMER_QUERIES, ...ORDER_QUERIES]);
function catalogEntry(name) {
const entry = QUERY_CATALOG.find((candidate) => candidate.name === name);
if (!entry) throw new Error(`unknown Shopify bulk query ${name}`);
return entry;
}
function queryFilePath(name, dir = QUERIES_DIR) {
return path.join(dir, `${name}.graphql`);
}
function readQueryFile(name, dir = QUERIES_DIR) {
return fs.readFileSync(queryFilePath(name, dir), 'utf8');
}
// The text sent for one query: the file with the snapshot time filled in, then the caller's
// projection (`projectQuery`), if it passes one, for the customer and order queries.
function generateQueryText(name, { snapshotTime, project = null, dir = QUERIES_DIR } = {}) {
let text = readQueryFile(name, dir);
if (text.includes(SNAPSHOT_PLACEHOLDER)) {
if (!snapshotTime) throw new Error(`query ${name} needs the snapshot time`);
text = text.split(SNAPSHOT_PLACEHOLDER).join(snapshotTime);
}
if (typeof project === 'function' && DENIAL_ATTRIBUTED_QUERIES.includes(name)) text = project(name, text);
return text;
}
// Static shape checks for the contract test: connections are the `edges { node }` blocks.
function stripForShape(text) {
const noComments = String(text).replace(/#[^\n]*/g, ' ');
let out = '';
let depth = 0;
let inString = false;
for (let i = 0; i < noComments.length; i += 1) {
const ch = noComments[i];
if (inString) {
if (ch === '\\') i += 1;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (ch === '(') { depth += 1; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); continue; }
if (depth === 0) out += ch;
}
return out;
}
function connectionShape(text) {
const tokens = stripForShape(text).match(/[{}]|[A-Za-z_][A-Za-z0-9_]*/g) || [];
const stack = [];
let connections = 0;
let maxDepth = 0;
let open = 0;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token === '{') {
const isEdges = tokens[i - 1] === 'edges';
stack.push(isEdges);
if (isEdges) { open += 1; connections += 1; maxDepth = Math.max(maxDepth, open); }
} else if (token === '}') {
if (stack.pop()) open -= 1;
}
}
return { connections, nestedLevels: Math.max(0, maxDepth - 1) };
}
// ---------------------------------------------------------------------------------------------
// Stops
// ---------------------------------------------------------------------------------------------
class BulkStop extends Error {
constructor(code, message, { status = 'stopped', details = {} } = {}) {
super(message);
this.name = 'BulkStop';
this.code = code;
this.status = status;
this.details = details;
}
}
const STOPPING_FAILED_CODE = 'shopify_bulk_failed';
function iso(ms) {
return new Date(ms).toISOString();
}
function entityOf(name) {
return catalogEntry(name).entity;
}
function isStopping(entry, caller) {
if (caller !== 'extract') return false;
if (entry.state === 'failed') return true;
return entry.state === 'denied' && entry.handlerOutcome && entry.handlerOutcome.attribution === 'unattributed';
}
// ---------------------------------------------------------------------------------------------
// Files: JSONL validation, folding, ndjson writing, the default file host download
// ---------------------------------------------------------------------------------------------
async function removeFile(file) {
await fsp.rm(file, { force: true });
}
async function writeTextAtomic(file, text) {
await fsp.mkdir(path.dirname(file), { recursive: true });
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
await fsp.writeFile(temp, text, 'utf8');
await fsp.rename(temp, file);
}
async function writeNdjsonAtomic(file, records) {
await fsp.mkdir(path.dirname(file), { recursive: true });
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
const out = fs.createWriteStream(temp, { encoding: 'utf8' });
let count = 0;
try {
for await (const record of records) {
if (!out.write(`${JSON.stringify(record)}\n`)) await new Promise((resolve) => out.once('drain', resolve));
count += 1;
}
out.end();
await finished(out);
await fsp.rename(temp, file);
return count;
} catch (error) {
out.destroy();
await removeFile(temp);
throw error;
}
}
async function* iterateNdjson(file) {
const input = fs.createReadStream(file, { encoding: 'utf8' });
const lines = readline.createInterface({ input, crlfDelay: Infinity });
for await (const line of lines) if (line.length) yield JSON.parse(line);
}
async function countLines(file) {
try {
await fsp.access(file);
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
let count = 0;
for await (const _record of iterateNdjson(file)) count += 1;
return count;
}
async function copyFileAtomic(source, destination) {
await fsp.mkdir(path.dirname(destination), { recursive: true });
const temp = `${destination}.${process.pid}.${Date.now()}.tmp`;
try {
await fsp.copyFile(source, temp);
await fsp.rename(temp, destination);
} catch (error) {
await removeFile(temp);
throw error;
}
}
// The download gate's file checks: empty or newline-terminated, and every line one
// JSON object. Streams the file; keeps no record in memory.
function validateJsonlFile(file) {
return new Promise((resolve) => {
const stream = fs.createReadStream(file, { encoding: 'utf8' });
let rest = '';
let lines = 0;
let last = '';
let failure = null;
const checkLine = (line) => {
if (failure) return;
let value;
try {
value = JSON.parse(line);
} catch {
failure = 'a line does not parse as JSON';
return;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) failure = 'a line is not a JSON object';
else lines += 1;
};
stream.on('data', (chunk) => {
if (failure) return;
last = chunk[chunk.length - 1];
rest += chunk;
let at = rest.indexOf('\n');
while (at !== -1 && !failure) {
checkLine(rest.slice(0, at));
rest = rest.slice(at + 1);
at = rest.indexOf('\n');
}
});
stream.on('error', (error) => resolve({ ok: false, reason: `the file could not be read: ${error.message}` }));
stream.on('end', () => {
if (!failure && rest.length) failure = 'the file does not end with a newline';
if (failure) resolve({ ok: false, reason: failure });
else resolve({ ok: true, lines, endsWithNewline: last === '\n' || last === '' });
});
});
}
// Node 22.12 ships node:sqlite behind --experimental-sqlite. Keep the documented adapter
// invocation flag-free: on that one supported boundary runtime, only the disk-backed transform
// runs in a child with the built-in module enabled. Node 22.13+ stays in-process. The worker gets
// only paths and the public query shape, never credentials or record values over the command line.
function loadDatabaseSync() {
try {
return require('node:sqlite').DatabaseSync;
} catch (error) {
if (error && error.code === 'ERR_UNKNOWN_BUILTIN_MODULE') return null;
throw error;
}
}
function sqliteWorkerError(payload) {
const error = new Error(payload && payload.message ? payload.message : 'the SQLite worker failed');
if (payload && payload.name) error.name = payload.name;
for (const field of ['code', 'errno', 'syscall', 'path']) {
if (payload && payload[field] !== undefined) error[field] = payload[field];
}
if (payload && payload.stack) error.stack = payload.stack;
return error;
}
function runSqliteWorker(operation, payload) {
return new Promise((resolve, reject) => {
// Preserve explicit memory ceilings used to prove the bounded-memory contract. Other Node
// entry-point/debug flags belong to the parent, not to this internal helper.
const memoryArgs = process.execArgv.filter((arg) => /^--(?:max-old-space-size|max-semi-space-size|stack-size)=/.test(arg));
const child = fork(SQLITE_WORKER, [], {
execArgv: [...memoryArgs, '--no-warnings', '--experimental-sqlite'],
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
});
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
reject(error);
};
child.once('error', fail);
child.once('exit', (code, signal) => {
if (!settled) fail(new Error(`the SQLite worker exited before replying (${signal || `code ${code}`})`));
});
child.once('message', (message) => {
if (settled) return;
settled = true;
if (child.connected) child.disconnect();
if (message && message.ok) resolve(message.result);
else reject(sqliteWorkerError(message && message.error));
});
child.send({ operation, payload }, (error) => {
if (error) fail(error);
});
});
}
// Shopify leaves `groupObjects` false, so children may precede their parents. Index child rows in
// a temporary SQLite database, then stream roots in source order and attach only that root's tree.
// Memory is therefore bounded by the largest assembled root, not by the full store export.
async function foldBulkJsonl(file, shape, { outputFile } = {}) {
if (!outputFile) throw new Error('foldBulkJsonl requires outputFile');
const DatabaseSync = loadDatabaseSync();
if (!DatabaseSync) return runSqliteWorker('foldBulkJsonl', { file, shape, outputFile });
await fsp.mkdir(path.dirname(outputFile), { recursive: true });
const work = `${outputFile}.${process.pid}.${Date.now()}.fold.tmp`;
const outputTemp = `${outputFile}.${process.pid}.${Date.now()}.tmp`;
const db = new DatabaseSync(work);
let output = null;
let lines = 0;
try {
db.exec(`
PRAGMA journal_mode = OFF;
PRAGMA synchronous = OFF;
PRAGMA temp_store = FILE;
CREATE TABLE roots (seq INTEGER PRIMARY KEY, json TEXT NOT NULL);
CREATE TABLE children (
seq INTEGER PRIMARY KEY,
parent_id TEXT NOT NULL,
child_id TEXT,
child_type TEXT,
json TEXT NOT NULL,
attached INTEGER NOT NULL DEFAULT 0,
orphan_reason TEXT
);
`);
const addRoot = db.prepare('INSERT INTO roots(seq, json) VALUES (?, ?)');
const addChild = db.prepare('INSERT INTO children(seq, parent_id, child_id, child_type, json) VALUES (?, ?, ?, ?, ?)');
db.exec('BEGIN');
let inTransaction = true;
try {
for await (const line of readline.createInterface({ input: fs.createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity })) {
if (!line.length) continue;
const row = JSON.parse(line);
lines += 1;
if (row.__parentId === undefined) addRoot.run(lines, line);
else addChild.run(lines, String(row.__parentId), typeof row.id === 'string' ? row.id : null, typeof row.__typename === 'string' ? row.__typename : null, line);
}
db.exec('COMMIT');
inTransaction = false;
db.exec('CREATE INDEX children_parent ON children(parent_id, seq)');
} catch (error) {
if (inTransaction) db.exec('ROLLBACK');
throw error;
}
const childrenFor = db.prepare('SELECT seq, json, child_type FROM children WHERE parent_id = ? AND attached = 0 ORDER BY seq');
const markAttached = db.prepare('UPDATE children SET attached = 1 WHERE seq = ?');
const markOrphan = db.prepare('UPDATE children SET orphan_reason = ? WHERE seq = ?');
// Node 22.12's flag-gated StatementSync has no iterate(). Walk the integer primary key with
// get() instead: one root is resident at a time and later runtimes follow the identical path.
const nextRoot = db.prepare('SELECT seq, json FROM roots WHERE seq > ? ORDER BY seq LIMIT 1');
const nestedParentTypes = new Set(Object.keys(shape.childFields || {}));
output = fs.createWriteStream(outputTemp, { encoding: 'utf8' });
let recordCount = 0;
let rootSeq = 0;
for (let stored = nextRoot.get(rootSeq); stored; stored = nextRoot.get(rootSeq)) {
rootSeq = Number(stored.seq);
const root = JSON.parse(stored.json);
const seen = new Set();
for (const field of shape.rootFields || []) if (!Array.isArray(root[field])) root[field] = [];
const attachChildren = (value, mayHaveChildren) => {
if (!value || typeof value !== 'object') return;
if (mayHaveChildren && typeof value.id === 'string' && !seen.has(value.id)) {
seen.add(value.id);
for (const storedChild of childrenFor.all(value.id)) {
const rule = shape.children[storedChild.child_type];
if (!rule) { markOrphan.run('unrecognised_child_type', storedChild.seq); continue; }
const holder = rule.into ? (value[rule.into] && typeof value[rule.into] === 'object' ? value[rule.into] : null) : value;
if (!holder) { markOrphan.run('parent_not_found', storedChild.seq); continue; }
const child = JSON.parse(storedChild.json);
delete child.__parentId;
if (!Array.isArray(holder[rule.field])) holder[rule.field] = [];
holder[rule.field].push(child);
markAttached.run(storedChild.seq);
attachChildren(child, nestedParentTypes.has(child.__typename));
}
}
};
attachChildren(root, true);
const initialiseTree = (value) => {
if (Array.isArray(value)) { for (const item of value) initialiseTree(item); return; }
if (!value || typeof value !== 'object') return;
const initialise = shape.childFields && shape.childFields[value.__typename];
if (initialise) for (const field of initialise) if (!Array.isArray(value[field])) value[field] = [];
for (const nested of Object.values(value)) initialiseTree(nested);
};
initialiseTree(root);
if (!output.write(`${JSON.stringify(root)}\n`)) await new Promise((resolve) => output.once('drain', resolve));
recordCount += 1;
}
output.end();
await finished(output);
output = null;
const orphanCount = Number(db.prepare('SELECT COUNT(*) AS count FROM children WHERE attached = 0').get().count);
if (orphanCount) {
const orphans = db.prepare('SELECT parent_id, child_id, child_type, orphan_reason FROM children WHERE attached = 0 ORDER BY seq LIMIT 20').all().map((row) => ({
reason: row.orphan_reason || 'parent_not_found',
parentId: row.parent_id,
id: row.child_id || null,
type: row.child_type || null,
}));
await removeFile(outputTemp);
return { orphans, orphanCount, lines };
}
await fsp.rename(outputTemp, outputFile);
return { outputFile, recordCount, lines };
} finally {
if (output) output.destroy();
db.close();
await removeFile(outputTemp);
await removeFile(work);
}
}
// Join the three order datasets without loading any of them as a whole. Supplemental rows live
// in a temporary SQLite table; core orders stream through one at a time in their original order.
async function joinOrderFiles({ coreFile, refundsFile, fulfillmentsFile, outputFile, workDir = path.dirname(outputFile) }) {
const DatabaseSync = loadDatabaseSync();
if (!DatabaseSync) return runSqliteWorker('joinOrderFiles', { coreFile, refundsFile, fulfillmentsFile, outputFile, workDir });
const work = path.join(workDir, `${path.basename(outputFile)}.${process.pid}.${Date.now()}.orders.tmp`);
const outputTemp = path.join(workDir, `${path.basename(outputFile)}.${process.pid}.${Date.now()}.tmp`);
const db = new DatabaseSync(work);
let output = null;
try {
db.exec(`
PRAGMA journal_mode = OFF;
PRAGMA synchronous = OFF;
PRAGMA temp_store = FILE;
CREATE TABLE supplements (
query_name TEXT NOT NULL,
order_id TEXT NOT NULL,
value TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(query_name, order_id)
);
CREATE INDEX supplements_order ON supplements(order_id);
`);
const add = db.prepare('INSERT OR REPLACE INTO supplements(query_name, order_id, value) VALUES (?, ?, ?)');
db.exec('BEGIN');
try {
for (const [query, field, file] of [['orders-refunds', 'refunds', refundsFile], ['orders-fulfillments', 'fulfillments', fulfillmentsFile]]) {
if (!file) continue; // this run does not carry that query
// A `done` supplement always has its staged file, empty when the dataset is. A missing one
// is lost input, never an empty dataset: fail loudly rather than write empty arrays.
try {
await fsp.access(file);
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`the staged ${query} file is missing: ${path.basename(file)}`);
throw error;
}
for await (const row of iterateNdjson(file)) add.run(query, String(row.id), JSON.stringify(Array.isArray(row[field]) ? row[field] : []));
}
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
const find = db.prepare('SELECT query_name, value FROM supplements WHERE order_id = ?');
const markUsed = db.prepare('UPDATE supplements SET used = 1 WHERE order_id = ?');
output = fs.createWriteStream(outputTemp, { encoding: 'utf8' });
for await (const order of iterateNdjson(coreFile)) {
const joined = { ...order, refunds: [], fulfillments: [] };
for (const row of find.all(String(order.id))) joined[row.query_name === 'orders-refunds' ? 'refunds' : 'fulfillments'] = JSON.parse(row.value);
markUsed.run(String(order.id));
if (!output.write(`${JSON.stringify(joined)}\n`)) await new Promise((resolve) => output.once('drain', resolve));
}
output.end();
await finished(output);
output = null;
await fsp.rename(outputTemp, outputFile);
const droppedByQuery = Object.fromEntries(['orders-refunds', 'orders-fulfillments'].map((query) => [query, Number(db.prepare('SELECT COUNT(*) AS count FROM supplements WHERE query_name = ? AND used = 0').get(query).count)]));
return { droppedOrphans: droppedByQuery['orders-refunds'] + droppedByQuery['orders-fulfillments'], droppedByQuery };
} finally {
if (output) output.destroy();
db.close();
await removeFile(outputTemp);
await removeFile(work);
}
}
// Fold the discount item-scope datasets into the discount rows. A discount's rows are few, so the
// supplements are read into memory as ids only; the core rows stream through in their original
// order. A field is set only when the core row's own scope kind owns it (products and variants
// under DiscountProducts, collections under DiscountCollections), and only when the supplement
// listed that discount at all: a discount the supplement never saw keeps the field absent, which
// reads as "not read", never as an empty scope.
async function joinDiscountFiles({ coreFile, supplements, outputFile, workDir = path.dirname(outputFile) }) {
const outputTemp = path.join(workDir, `${path.basename(outputFile)}.${process.pid}.${Date.now()}.tmp`);
const loaded = [];
for (const supplement of supplements) {
try {
await fsp.access(supplement.file);
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`the staged ${supplement.query} file is missing: ${path.basename(supplement.file)}`);
throw error;
}
const listed = new Map();
for await (const row of iterateNdjson(supplement.file)) {
const values = {};
for (const field of supplement.fields) if (Array.isArray(row[field]) && row[field].length) values[field] = row[field];
listed.set(String(row.id), values);
}
loaded.push({ ...supplement, listed });
}
const coreIds = new Set();
let output = null;
try {
output = fs.createWriteStream(outputTemp, { encoding: 'utf8' });
for await (const row of iterateNdjson(coreFile)) {
const id = String(row.id);
coreIds.add(id);
for (const supplement of loaded) {
if (!supplement.listed.has(id)) continue;
const values = supplement.listed.get(id);
let parent = row;
for (const part of supplement.parent) parent = parent && typeof parent === 'object' ? parent[part] : null;
if (!parent || typeof parent !== 'object') continue;
for (const field of supplement.fields) {
if (parent.__typename === DISCOUNT_ITEM_CHILDREN[field].owner) parent[field] = values[field] || [];
}
}
if (!output.write(`${JSON.stringify(row)}\n`)) await new Promise((resolve) => output.once('drain', resolve));
}
output.end();
await finished(output);
output = null;
await fsp.rename(outputTemp, outputFile);
const droppedByQuery = {};
for (const supplement of loaded) droppedByQuery[supplement.query] = [...supplement.listed.keys()].filter((id) => !coreIds.has(id)).length;
return { droppedOrphans: Object.values(droppedByQuery).reduce((sum, count) => sum + count, 0), droppedByQuery };
} finally {
if (output) output.destroy();
await removeFile(outputTemp);
}
}
// The default file host download: plain http(s), no content decoding (identity only), bytes
// counted as they arrive. It reports what happened; the gate decides what it means.
function defaultDownload(url, destination) {
return new Promise((resolve) => {
let client;
try {
client = String(url).startsWith('http:') ? http : https;
} catch {
client = https;
}
const request = client.get(url, { headers: { 'accept-encoding': 'identity' } }, (response) => {
if (response.statusCode !== 200) {
response.resume();
response.on('end', () => resolve({ status: response.statusCode, bytes: 0 }));
response.on('error', (error) => resolve({ status: response.statusCode, bytes: 0, error: error.message }));
return;
}
const out = fs.createWriteStream(destination);
let bytes = 0;
let failed = null;
response.on('data', (chunk) => { bytes += chunk.length; });
response.on('error', (error) => { failed = error.message; out.end(); });
response.on('aborted', () => { failed = failed || 'the connection was cut'; });
response.pipe(out);
out.on('finish', () => resolve({ status: 200, bytes, ...(failed || !response.complete ? { error: failed || 'the response did not complete' } : {}) }));
out.on('error', (error) => resolve({ status: 200, bytes, error: error.message }));
});
request.on('error', (error) => resolve({ status: null, bytes: 0, error: error.message }));
});
}
// ---------------------------------------------------------------------------------------------
// The runner
// ---------------------------------------------------------------------------------------------
function pendingEntry(query) {
return {
state: 'pending',
entity: query.entity || entityOf(query.name),
text: query.text,
attempts: 0,
reapproved: false,
downloadTries: 0,
linkReruns: 0,
};
}
function newManifest({ caller, store, snapshotTime, queries, previousSidecars, now }) {
const manifest = {
schemaVersion: MANIFEST_SCHEMA_VERSION,
caller,
store,
snapshotTime,
startedAt: iso(now()),
status: 'running',
order: queries.map((query) => query.name),
queries: Object.fromEntries(queries.map((query) => [query.name, pendingEntry(query)])),
};
if (previousSidecars.length) manifest.previousSidecars = [...new Set(previousSidecars)].sort();
return manifest;
}
const OPERATION_FIELDS = ['operationId', 'runningAt', 'deadlineAt', 'overdueRound', 'overdueActionId', 'url', 'fileSize', 'objectCount', 'rootObjectCount', 'createdAt', 'completedAt', 'startedAt'];
// The shopify extract adapters plug into the runner here and add no runner logic of their own: a denial
// handler, a `failed` handler per query (`failed.byQuery`), a reassembly hook per query, the
// `denied` reset, and `project` (the projection applied to the customer and order query text).
// Everything a handler registers must be idempotent. Options passed to createBulkRunner
// override what is registered.
const registered = { handlers: {}, deniedReset: null, project: null };
function registerHandlers({ denied, failed, reassemble, deniedReset, project } = {}) {
if (denied) registered.handlers.denied = denied;
if (failed) registered.handlers.failed = { ...(registered.handlers.failed || {}), ...failed, byQuery: { ...((registered.handlers.failed || {}).byQuery || {}), ...(failed.byQuery || {}) } };
if (reassemble) registered.handlers.reassemble = { ...(registered.handlers.reassemble || {}), ...reassemble };
if (deniedReset) registered.deniedReset = deniedReset;
if (project) registered.project = project;
}
function clearRegisteredHandlers() {
registered.handlers = {};
registered.deniedReset = null;
registered.project = null;
}
function mergeHandlers(extra = {}) {
const base = registered.handlers;
return {
denied: extra.denied || base.denied,
failed: { ...(base.failed || {}), ...(extra.failed || {}), byQuery: { ...((base.failed || {}).byQuery || {}), ...((extra.failed || {}).byQuery || {}) } },
reassemble: { ...(base.reassemble || {}), ...(extra.reassemble || {}) },
};
}
function createBulkRunner(options = {}) {
const {
projectDir,
store,
caller,
manifestPath,
stagingDir,
queries = null,
addQueries = null,
currentText = null,
snapshotTime = null,
execute,
handlers: handlerOptions = {},
deniedReset = registered.deniedReset,
retryFailed = false,
previousSidecars = [],
sessionCheck = null,
download = defaultDownload,
outputDir = null,
now = () => Date.now(),
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
progress = () => {},
onPersist = null,
overdueMs = OVERDUE_MS,
} = options;
if (caller !== 'extract' && caller !== 'census') throw new Error('caller must be extract or census');
if (!projectDir || !manifestPath || !stagingDir) throw new Error('projectDir, manifestPath and stagingDir are required');
if (typeof execute !== 'function' || typeof execute.startBulkOperation !== 'function') throw new Error('execute (a store executor with startBulkOperation) is required');
if (caller === 'extract' && !outputDir) throw new Error('an extract needs outputDir');
const stage = caller === 'extract' ? 'execution' : 'discovery';
const extract = caller === 'extract';
const normalizedStore = session.normalizeDomain(store);
const handlers = mergeHandlers(handlerOptions);
let manifest = null;
// -------------------------------------------------------------------------------------------
// Manifest
// -------------------------------------------------------------------------------------------
async function save(label, query = null) {
await writeJsonAtomic(manifestPath, manifest);
if (typeof onPersist === 'function') await onPersist({ label, query, state: query ? manifest.queries[query].state : null, manifest });
}
async function readManifest() {
try {
return JSON.parse(await fsp.readFile(manifestPath, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
const entry = (name) => manifest.queries[name];
async function setState(name, state, fields = {}, dropFields = []) {
const target = entry(name);
target.state = state;
Object.assign(target, fields);
for (const field of dropFields) delete target[field];
await save(`${name}:${state}`, name);
}
function dropOperation(name) {
const target = entry(name);
for (const field of OPERATION_FIELDS) delete target[field];
}
// -------------------------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------------------------
function coverageRowFor(name, { artifact, pointer, recoveryAction }) {
const entity = entityOf(name);
const [row] = buildShopifyCoverageRows({
captureFailures: [{ entity, artifact, reads: [name], errors: [{ code: 'shopify_bulk' }] }],
rawDir: RAW_EXTRACT_DIR,
});
return { ...row, evidence: { artifact, pointer }, recoveryAction };
}
function failureEvidence(name, target) {
const parts = [`queries.${name}`, `state ${target.outcome || target.state}`, `errorCode ${target.errorCode || 'unknown'}`];
if (target.error && target.error.message) parts.push(String(target.error.message).slice(0, 300));
return parts.join('; ');
}
// The default `failed` handler. Extract: the capture-failed row, upserted by coverageId.
// Census: the same row, carried in the entry's handlerOutcome for the discovery coverage step
// (the raw coverage file does not exist until discovery's last step, so nothing is upserted).
async function defaultFailedHandler({ name, entry: target }) {
const recoveryAction = extract
? `Fix the cause named in the stop message, then run shopify-extract.js --retry-failed for ${entityOf(name)}.`
: `Fix the cause, then run Shopify discovery again for ${entityOf(name)} (the extract's gated read also covers it).`;
const row = coverageRowFor(name, {
artifact: extract ? `${RAW_EXTRACT_DIR}/${EXTRACT_MANIFEST_FILE}` : path.relative(projectDir, manifestPath).split(path.sep).join('/'),
pointer: failureEvidence(name, target),
recoveryAction,
});
if (extract) await upsertAdapterCoverageRows(projectDir, { adapter: 'shopify', rows: [row] });
return { handlerOutcome: { coverageId: row.coverageId, ...(extract ? {} : { row }) } };
}
// The fail-closed denial fallback, used only when the caller registers no denial handler (the
// customer-and-order adapter registers its own): the denial is unattributed, so the dataset is treated like a failed one.
async function defaultDeniedHandler(context) {
const result = await defaultFailedHandler(context);
return { handlerOutcome: { attribution: 'unattributed', ...result.handlerOutcome }, stop: { code: STOPPING_FAILED_CODE, message: `Shopify denied the ${context.name} export and the denial could not be attributed to a withheld detail.` } };
}
function failedHandlerFor(name) {
const byQuery = (handlers.failed && handlers.failed.byQuery) || {};
return byQuery[name] || (handlers.failed && handlers.failed.default) || defaultFailedHandler;
}
function handlerContext(name) {
return { name, query: name, entity: entityOf(name), entry: entry(name), manifest, caller, stage, projectDir, store, execute, stagingDir, outputDir, manifestPath, now, sleep };
}
// -------------------------------------------------------------------------------------------
// Settling
// -------------------------------------------------------------------------------------------
async function beginSettling(name, outcome, { errorCode, error }) {
await setState(name, 'settling', { outcome, errorCode, error, settlingAt: iso(now()) });
}
// `later` is a start that finds an already-terminal stopping entry: it stops with the generic
// `shopify_bulk_failed`; the first settle stops with the entry's own code.
function stopFor(name, target, handlerStop, { later = false } = {}) {
const entity = entityOf(name);
if (handlerStop) {
return new BulkStop(handlerStop.code, handlerStop.message || `Shopify ${caller} stopped (${handlerStop.code}) at ${name}.`, { details: { query: name, entity, ...(handlerStop.details || {}) } });
}
const specific = target.errorCode && target.state === 'failed' ? target.errorCode : STOPPING_FAILED_CODE;
const code = later ? STOPPING_FAILED_CODE : specific;
const cause = target.error && target.error.message ? `: ${String(target.error.message).slice(0, 300)}` : '';
return new BulkStop(code, `Shopify dataset ${entity} could not be read (${specific})${cause}. Requires Import Operator: fix the cause named here, run \`shopify-extract.js --retry-failed\`, and when the extract completes record the coverage decision \`provided\` for \`shopify:${entity}\`; otherwise show the merchant the row at mapping.`, { details: { query: name, entity, errorCode: target.errorCode || null, coverageId: target.handlerOutcome ? target.handlerOutcome.coverageId : null } });
}
// Runs the outcome's handler for a `settling` entry, writes the terminal state, and returns the
// stop the entry causes, or null.
async function finishSettling(name) {
const target = entry(name);
const context = handlerContext(name);
let result;
if (target.outcome === 'failed') {
result = await failedHandlerFor(name)({ ...context, outcome: 'failed' });
} else {
result = await (handlers.denied || defaultDeniedHandler)({ ...context, outcome: 'denied' });
}
const terminal = { handlerOutcome: (result && result.handlerOutcome) || {} };
if (target.outcome === 'denied') terminal.deniedAt = iso(now());
await setState(name, target.outcome, terminal);
const handlerStop = result && result.stop ? result.stop : null;
if (handlerStop) return stopFor(name, entry(name), handlerStop);
if (isStopping(entry(name), caller)) return stopFor(name, entry(name), null);
return null;
}
async function settle(name, outcome, details) {
await beginSettling(name, outcome, details);
const stop = await finishSettling(name);
if (stop) throw stop;
}
async function settleAll() {
for (const name of manifest.order) {
if (entry(name).state === 'settling') {
const stop = await finishSettling(name);
if (stop) throw stop;
}
}
}
// -------------------------------------------------------------------------------------------
// Session expiry, denial
// -------------------------------------------------------------------------------------------
async function authExpiredStop(extraMessage = '') {
const action = await session.recordAuthExpired(projectDir, { stage, store });
return new BulkStop('source_auth_expired', `the Shopify connection for ${store} expired during the ${caller}${extraMessage}. Re-approval request ${action.requestId} is pending: run the Shopify session check (with --connect if asked), answer it, then run the same step again. Finished reads are not repeated.`, { details: { requestId: action.requestId } });
}
async function runSessionCheck() {
if (typeof sessionCheck !== 'function') return { ok: true };
return sessionCheck();
}
async function handleDenial(name, evidence) {
const target = entry(name);
const check = await runSessionCheck();
if (!check.ok && !target.reapproved) {
dropOperation(name);
await setState(name, 'pending', { reapproved: true });
throw await authExpiredStop(` (Shopify denied the ${name} export and the session check failed)`);
}
const verbatim = { message: 'Shopify refused the bulk export with ACCESS_DENIED', ...evidence };
if (DENIAL_ATTRIBUTED_QUERIES.includes(name)) {
await settle(name, 'denied', { errorCode: 'ACCESS_DENIED', error: verbatim });
} else {
await settle(name, 'failed', { errorCode: STOPPING_FAILED_CODE, error: { ...verbatim, message: 'Shopify refused the bulk export with ACCESS_DENIED' } });
}
}
// -------------------------------------------------------------------------------------------
// Per query
// -------------------------------------------------------------------------------------------
const hasCode = (errors, code) => (errors || []).some((error) => error && error.extensions && error.extensions.code === code);
async function startQuery(name) {
const target = entry(name);
await setState(name, 'starting', { startedAt: iso(now()) });
progress('Starting Shopify bulk export', { query: name });
const result = await execute.startBulkOperation(target.text);
if (result.errors && result.errors.length) {
if (hasCode(result.errors, 'ACCESS_DENIED')) { await handleDenial(name, { graphqlErrors: result.errors }); return; }
await settle(name, 'failed', { errorCode: STOPPING_FAILED_CODE, error: { message: 'Shopify answered the bulk start with GraphQL errors', graphqlErrors: result.errors } });
return;
}
const payload = result.data && result.data.bulkOperationRunQuery;
if (!payload) {
await settle(name, 'failed', { errorCode: STOPPING_FAILED_CODE, error: { message: 'Shopify answered the bulk start with no bulkOperationRunQuery result' } });
return;
}
if (Array.isArray(payload.userErrors) && payload.userErrors.length) {
await settle(name, 'failed', { errorCode: STOPPING_FAILED_CODE, error: { message: 'Shopify refused the bulk start', userErrors: payload.userErrors } });
return;
}
const operation = payload.bulkOperation;
if (!operation || typeof operation.id !== 'string') {
await settle(name, 'failed', { errorCode: STOPPING_FAILED_CODE, error: { message: 'Shopify answered the bulk start with no operation id' } });
return;
}
const runningAt = now();
await setState(name, 'running', { operationId: operation.id, runningAt: iso(runningAt), deadlineAt: iso(runningAt + overdueMs), overdueRound: 1 });
}
function overdueRequestId(name, round) {
return session.sequencedRequestId(OVERDUE_NAMESPACE, `${caller}\0${name}\0${entry(name).operationId}`, round);
}
async function readActionIfAny(requestId) {
try {
return await readUserAction(projectDir, requestId);
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
async function raiseOverdue(name) {
const target = entry(name);
const round = target.overdueRound || 1;
const requestId = overdueRequestId(name, round);
let action = await readActionIfAny(requestId);
if (!action) {
action = await createUserAction(projectDir, {
requestId,
kind: 'question',
stage,
title: `Shopify export overdue: ${name} (shopify_bulk_overdue)`,
prompt: `Shopify has been preparing the \`${name}\` export (operation ${target.operationId}) for over ${round * 4} hours. Wait another 4 hours, or abort this dataset?`,
response: { mode: 'choice' },
choices: [
{ id: 'wait', label: 'Wait another 4 hours' },
{ id: 'abort', label: 'Abort this dataset' },
],
});
}
await setState(name, 'running', { overdueActionId: action.requestId });
throw new BulkStop('shopify_bulk_overdue', `Shopify has been preparing the ${name} export for over ${round * 4} hours. Answer request ${action.requestId} (wait or abort), then run the same step again.`, { status: 'awaiting_user_action', details: { query: name, operationId: target.operationId, round, requestIds: [action.requestId] } });
}
// Applies an answered overdue action, or stops while it is unanswered. Returns 'waited' | 'aborted'.
async function applyOverdue(name) {
const target = entry(name);
const action = await readActionIfAny(target.overdueActionId);
if (!action || action.status !== 'answered') {
throw new BulkStop('shopify_bulk_overdue', `The ${name} export is overdue and request ${target.overdueActionId} is unanswered; nothing was polled or started.`, { status: 'awaiting_user_action', details: { query: name, operationId: target.operationId, round: target.overdueRound || 1, requestIds: [target.overdueActionId] } });
}
if (action.answer && action.answer.choice === 'abort') {
await settle(name, 'failed', { errorCode: 'shopify_bulk_overdue_aborted', error: { message: `The ${name} export was aborted after it ran over ${(target.overdueRound || 1) * 4} hours; Shopify's operation ${target.operationId} was not cancelled and its result was never downloaded`, operationId: target.operationId, requestId: action.requestId } });
return 'aborted';
}
const answeredAt = Date.parse(action.answer && action.answer.answeredAt);
const base = Number.isFinite(answeredAt) ? answeredAt : now();
await setState(name, 'running', { deadlineAt: iso(base + overdueMs), overdueRound: (target.overdueRound || 1) + 1 }, ['overdueActionId']);
return 'waited';
}
function classifyOperation(operation) {
if (!operation) return 'failed';
if (operation.partialDataUrl) return 'failed';
if (operation.status === 'COMPLETED') return 'completed';
if (operation.status === 'FAILED' && operation.errorCode === 'ACCESS_DENIED') return 'denied';
if (['FAILED', 'CANCELED', 'EXPIRED'].includes(operation.status)) return 'failed';
return 'running';
}
async function failedResult(name, { errorCode = STOPPING_FAILED_CODE, message, evidence = {} }) {
const target = entry(name);
if (target.attempts < MAX_ATTEMPTS) {
dropOperation(name);
await setState(name, 'pending', { attempts: target.attempts + 1, downloadTries: 0 });
return;
}
await settle(name, 'failed', { errorCode, error: { message, ...evidence } });
}
async function pollOperation(name) {
const result = await execute(STATUS_QUERY, { id: entry(name).operationId });
if (result.errors && result.errors.length) {
return { errors: result.errors };
}
return { operation: result.data && result.data.node ? result.data.node : null };
}
async function processRunning(name) {
if (entry(name).overdueActionId) {
const outcome = await applyOverdue(name);
if (outcome === 'aborted') return;
}
let wait = POLL_START_MS;
for (;;) {
const polled = await pollOperation(name);
if (polled.errors) {
if (hasCode(polled.errors, 'ACCESS_DENIED')) { await handleDenial(name, { graphqlErrors: polled.errors }); return; }
await settle(name, 'failed', { errorCode: STOPPING_FAILED_CODE, error: { message: 'Shopify answered the status poll with GraphQL errors', graphqlErrors: polled.errors } });
return;
}
const operation = polled.operation;
const kind = classifyOperation(operation);
if (kind === 'running') {
if (now() >= Date.parse(entry(name).deadlineAt)) await raiseOverdue(name);
await sleep(wait);
wait = Math.min(wait * 2, POLL_CAP_MS);
continue;
}
if (kind === 'denied') { await handleDenial(name, { operation: operationEvidence(operation) }); return; }
if (kind === 'failed') {
await failedResult(name, { message: operation ? `Shopify's bulk operation ended ${operation.status}${operation.errorCode ? ` (${operation.errorCode})` : ''}${operation.partialDataUrl ? ' with only partial data' : ''}` : 'Shopify no longer knows the bulk operation', evidence: { operation: operationEvidence(operation) } });
return;
}
await setState(name, 'running', {
url: operation.url || null,
fileSize: operation.fileSize === undefined ? null : operation.fileSize,
objectCount: operation.objectCount,
rootObjectCount: operation.rootObjectCount,
createdAt: operation.createdAt,
completedAt: operation.completedAt,
});
await downloadGate(name);
return;
}
}
function operationEvidence(operation) {
if (!operation) return null;
return { id: operation.id, status: operation.status, errorCode: operation.errorCode || null, partialDataUrl: operation.partialDataUrl || null, objectCount: operation.objectCount, fileSize: operation.fileSize === undefined ? null : operation.fileSize };
}
// Marks the entry `downloaded` only when the whole gate holds.
async function downloadGate(name) {
const jsonl = path.join(stagingDir, `${name}.jsonl`);
const partial = `${jsonl}.partial`;
await fsp.mkdir(stagingDir, { recursive: true });
for (;;) {
const target = entry(name);
if (!target.url) {
if (String(target.objectCount) === '0') {
await writeTextAtomic(jsonl, '');
await setState(name, 'downloaded', { bytes: 0, lines: 0 });
return;
}
await failedResult(name, { message: `Shopify completed the ${name} export with no file for ${target.objectCount} objects`, evidence: { operation: { objectCount: target.objectCount } } });
return;
}
progress('Downloading Shopify bulk file', { query: name });
await removeFile(partial);
const result = await download(target.url, partial);
const written = result && Number.isFinite(result.bytes) ? result.bytes : 0;
if (result && (result.status === 400 || result.status === 403)) {
await removeFile(partial);
if (target.linkReruns >= MAX_LINK_RERUNS) {
await settle(name, 'failed', { errorCode: 'shopify_bulk_download_failed', error: { message: `The ${name} export's download link expired ${target.linkReruns + 1} times (HTTP ${result.status})`, httpStatus: result.status } });
return;
}
dropOperation(name);
await setState(name, 'pending', { linkReruns: target.linkReruns + 1, downloadTries: 0 });
return;
}
let reason = null;
if (!result || result.status !== 200) reason = `the download answered ${result && result.status !== null && result.status !== undefined ? `HTTP ${result.status}` : 'no HTTP response'}${result && result.error ? ` (${result.error})` : ''}`;
else if (result.error) reason = `the download failed: ${result.error}`;
else if (target.fileSize === null || target.fileSize === undefined || String(written) !== String(target.fileSize)) reason = `the download was ${written} bytes, Shopify reported ${target.fileSize === null || target.fileSize === undefined ? 'no size' : target.fileSize}`;
let checked = null;
if (!reason) {
checked = await validateJsonlFile(partial);
if (!checked.ok) reason = checked.reason;
}
if (!reason) {
await fsp.rename(partial, jsonl);
await setState(name, 'downloaded', { bytes: written, lines: checked.lines });
return;
}
await removeFile(partial);
const tries = target.downloadTries + 1;
await setState(name, 'running', { downloadTries: tries });
if (tries < MAX_DOWNLOAD_TRIES) continue;
await failedResult(name, { errorCode: 'shopify_bulk_download_failed', message: `The ${name} export could not be downloaded whole after ${tries} tries: ${reason}`, evidence: { reason } });
return;
}
}
async function reassemble(name) {
const target = entry(name);
const catalog = catalogEntry(name);
const jsonl = path.join(stagingDir, `${name}.jsonl`);
if (!fs.existsSync(jsonl)) {
// `downloaded` promises a complete local file. If it is gone (staging cleared by hand), the
// dataset is simply read again: no attempt is spent, nothing is guessed.
dropOperation(name);
await setState(name, 'pending', { downloadTries: 0 }, ['bytes', 'lines']);
return;
}
const foldedFile = path.join(stagingDir, `${name}.folded.tmp`);
const folded = await foldBulkJsonl(jsonl, catalog, { outputFile: foldedFile });
if (folded.orphans) {
await settle(name, 'failed', { errorCode: 'shopify_bulk_orphan_child', error: { message: `${folded.orphanCount} child row(s) of ${name} match no parent row`, orphans: folded.orphans } });
return;
}
// A registered hook wins; otherwise the three datasets whose connections sit under a list field
// (a bulk query cannot hold those) get their nested ordinary reads.
const hook = (handlers.reassemble && handlers.reassemble[name]) || (extract ? nestedReadHook(name) : null);
let records = null;
let recordsFile = null;
let recordCount = folded.recordCount;
let outputs = {};
let sidecarFiles = {};
let result = {};
let outputFile = null;
let entryFields = {};
let hookStop = null;
if (hook) {
const hooked = await hook({ ...handlerContext(name), records: iterateNdjson(foldedFile), recordsFile: foldedFile, recordCount, lines: folded.lines });
if (hooked && hooked.ok === false) {
await removeFile(foldedFile);
await settle(name, 'failed', { errorCode: hooked.errorCode || STOPPING_FAILED_CODE, error: { message: hooked.message || `The ${name} recheck failed (${hooked.errorCode || STOPPING_FAILED_CODE})`, ...(hooked.evidence ? { evidence: hooked.evidence } : {}) } });
return;
}
if (hooked && hooked.records) records = hooked.records;
if (hooked && hooked.recordsFile) recordsFile = hooked.recordsFile;
if (hooked && Number.isInteger(hooked.recordCount)) recordCount = hooked.recordCount;
if (hooked && hooked.sidecars) outputs = hooked.sidecars;
if (hooked && hooked.sidecarFiles) sidecarFiles = hooked.sidecarFiles;
if (hooked && hooked.manifest) Object.assign(manifest, hooked.manifest);
if (hooked && hooked.result) result = hooked.result;
if (hooked && hooked.outputFile) outputFile = hooked.outputFile;
if (hooked && hooked.entry) entryFields = hooked.entry;
if (hooked && hooked.stop) hookStop = hooked.stop;
}
const readAt = { createdAt: target.createdAt || null, completedAt: target.completedAt || null };
const durationMs = Number.isFinite(Date.parse(target.completedAt)) && Number.isFinite(Date.parse(target.createdAt)) ? Date.parse(target.completedAt) - Date.parse(target.createdAt) : null;
if (extract) {
const stagedName = outputFile || `${name}.ndjson`;
const staged = path.join(stagingDir, stagedName);
if (recordsFile) {
await removeFile(staged);
await fsp.rename(recordsFile, staged);
await removeFile(foldedFile);
} else if (records) {
recordCount = await writeNdjsonAtomic(staged, records);
await removeFile(foldedFile);
} else {
await fsp.rename(foldedFile, staged);
}
const sidecarNames = [...new Set([...Object.keys(outputs), ...Object.keys(sidecarFiles)])].sort();
const oldSidecars = Array.isArray(target.sidecars) ? target.sidecars : [];
for (const sidecar of oldSidecars.filter((sidecar) => !sidecarNames.includes(sidecar))) {
await removeFile(path.join(stagingDir, `sidecar-${sidecar}.ndjson`));
manifest.obsoleteSidecars = [...new Set([...(manifest.obsoleteSidecars || []), sidecar])].sort();
}
for (const sidecar of sidecarNames) {
const destination = path.join(stagingDir, `sidecar-${sidecar}.ndjson`);
if (sidecarFiles[sidecar]) {
await removeFile(destination);
await fsp.rename(sidecarFiles[sidecar], destination);
} else {
await writeNdjsonAtomic(destination, outputs[sidecar]);
}
}
manifest.sidecars = [...new Set(manifest.order.flatMap((query) => query === name ? sidecarNames : (entry(query).sidecars || [])))].sort();
await setState(name, 'done', { readAt, durationMs, records: recordCount, sidecars: sidecarNames, ...(outputFile ? { outputFile } : {}), ...entryFields }, ['url']);
} else {
await removeFile(foldedFile);
await setState(name, 'done', { readAt, durationMs, result: { records: recordCount, ...result }, ...entryFields }, ['url']);
}
await removeFile(jsonl);
if (hookStop) throw new BulkStop(hookStop.code, hookStop.message || `Shopify extract stopped (${hookStop.code}) at ${name}.`, { details: { query: name, entity: entityOf(name), ...(hookStop.details || {}) } });
}
async function processQuery(name) {
for (;;) {
const target = entry(name);
switch (target.state) {
case 'done':
return;
case 'denied':
if (isStopping(target, caller)) throw stopFor(name, target, null, { later: true });
return;
case 'failed':
if (extract) throw stopFor(name, target, null, { later: true });
return;
case 'settling': {
const stop = await finishSettling(name);
if (stop) throw stop;
break;
}
case 'starting':
dropOperation(name);
await setState(name, 'pending');
break;
case 'pending':
await startQuery(name);
break;
case 'running':
await processRunning(name);
break;
case 'downloaded':
await reassemble(name);
break;
default:
throw new Error(`unknown query state ${target.state} for ${name}`);
}
}
}
// -------------------------------------------------------------------------------------------
// Resume resets
// -------------------------------------------------------------------------------------------
async function cleanLeftovers() {
let names = [];
try {
names = await fsp.readdir(stagingDir);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const leftover of names.filter((file) => file.endsWith('.partial') || file.endsWith('.tmp'))) await removeFile(path.join(stagingDir, leftover));
// A crash between the `done` write and the JSONL delete leaves the file behind.
for (const name of manifest.order) if (entry(name).state === 'done') await removeFile(path.join(stagingDir, `${name}.jsonl`));
}
function resetEntry(name, fields = {}) {
const target = entry(name);
dropOperation(name);
for (const field of ['outcome', 'errorCode', 'error', 'settlingAt', 'handlerOutcome', 'deniedAt', 'readAt', 'durationMs', 'records', 'result', 'bytes', 'lines']) delete target[field];
Object.assign(target, { state: 'pending', attempts: 0, downloadTries: 0, linkReruns: 0, reapproved: false }, fields);
}
async function applyResets() {
if (!extract) return;
let changed = false;
if (typeof deniedReset === 'function') {
for (const reset of (await deniedReset({ manifest, projectDir })) || []) {
resetEntry(reset.name, { text: reset.text });
changed = true;
}
}
if (retryFailed) {
for (const name of manifest.order) {
if (isStopping(entry(name), caller)) { resetEntry(name); changed = true; }
}
if (manifest.countFailure && manifest.countFailure.entities) {
for (const failedEntity of manifest.countFailure.entities) {
for (const name of manifest.order.filter((candidate) => entityOf(candidate) === failedEntity)) {
await removeFile(path.join(stagingDir, `${name}.ndjson`));
resetEntry(name);
}
}
delete manifest.countFailure;
changed = true;
}
}
// A manifest written before a catalog query existed lists neither it nor its rows. Finalizing it
// as is would report a complete extract with that dataset's data (a discount's product, variant and
// collection restrictions) absent, so each catalog query the manifest lacks is added as a pending
// dataset and joined at the end like any other. A finished (`complete`) manifest never reaches
// here: it needs an explicit catch-up.
if (Array.isArray(addQueries)) {
for (const query of addQueries) {
if (manifest.queries[query.name]) continue;
manifest.order.push(query.name);
manifest.queries[query.name] = pendingEntry(query);
changed = true;
}
}
// A dataset not yet started is sent the catalog's current text, so a fixed query reaches a resumed
// manifest. Started and finished datasets keep the text they were sent (the snapshot time is the
// manifest's, so the filter is unchanged).
if (typeof currentText === 'function') {
for (const name of manifest.order) {
const target = entry(name);
if (target.state !== 'pending' || target.operationId) continue;
const text = currentText(name);
if (typeof text === 'string' && text !== target.text) { target.text = text; changed = true; }
}
}
// A `done` extract entry promises its staged output, and a resume never reads a
// finished query again. When that output is gone before the final join, read the query again
// without spending an attempt instead of joining the hole as an empty dataset.
for (const name of manifest.order) {
const target = entry(name);
if (target.state !== 'done') continue;
const files = [target.outputFile || `${name}.ndjson`, ...(target.sidecars || []).map((sidecar) => `sidecar-${sidecar}.ndjson`)].map((file) => path.join(stagingDir, file));
const present = await Promise.all(files.map((file) => fsp.access(file).then(() => true, (error) => { if (error.code === 'ENOENT') return false; throw error; })));
if (present.every(Boolean)) continue;
for (const file of files) await removeFile(file);
resetEntry(name);
changed = true;
}
if (changed) await save('resets');
}
// -------------------------------------------------------------------------------------------
// Extract: the final join and the count check
// -------------------------------------------------------------------------------------------
function expectedFinalFiles() {
const entities = [...new Set(manifest.order.map(entityOf))];
return [
...entities.map((entity) => {
const first = manifest.order.find((name) => entityOf(name) === entity);
return (first && entry(first).outputFile) || `${entity}.ndjson`;
}),
...(manifest.sidecars || []).map((entity) => `${entity}.ndjson`),
];
}
async function joinFinalFiles() {
await fsp.mkdir(outputDir, { recursive: true });
const staged = (name) => path.join(stagingDir, `${name}.ndjson`);
let droppedOrphans = 0;
const droppedByQuery = {};
for (const entity of [...new Set(manifest.order.map(entityOf))]) {
const members = manifest.order.filter((name) => entityOf(name) === entity);
if (entity === 'discounts' && members.length > 1) {
const joined = await joinDiscountFiles({
coreFile: staged('discounts'),
supplements: members.filter((name) => catalogEntry(name).supplement).map((name) => ({ query: name, file: staged(name), ...catalogEntry(name).supplement })),
outputFile: path.join(outputDir, 'discounts.ndjson'),
workDir: stagingDir,
});
droppedOrphans += joined.droppedOrphans;
Object.assign(droppedByQuery, joined.droppedByQuery);
continue;
}
if (entity !== 'orders') {
const outputFile = entry(members[0]).outputFile || `${entity}.ndjson`;
const stagedFile = entry(members[0]).outputFile || `${members[0]}.ndjson`;
await copyFileAtomic(path.join(stagingDir, stagedFile), path.join(outputDir, outputFile));
continue;
}
const joined = await joinOrderFiles({
coreFile: staged('orders-core'),
refundsFile: members.includes('orders-refunds') ? staged('orders-refunds') : null,
fulfillmentsFile: members.includes('orders-fulfillments') ? staged('orders-fulfillments') : null,
outputFile: path.join(outputDir, 'orders.ndjson'),
workDir: stagingDir,
});
droppedOrphans += joined.droppedOrphans;
Object.assign(droppedByQuery, joined.droppedByQuery);
}
for (const sidecar of manifest.sidecars || []) await copyFileAtomic(path.join(stagingDir, `sidecar-${sidecar}.ndjson`), path.join(outputDir, `${sidecar}.ndjson`));
const currentSidecars = new Set(manifest.sidecars || []);
for (const sidecar of new Set([...(manifest.previousSidecars || []), ...(manifest.obsoleteSidecars || [])])) {
if (!currentSidecars.has(sidecar)) await removeFile(path.join(outputDir, `${sidecar}.ndjson`));
}
manifest.droppedOrphans = droppedOrphans;
manifest.droppedOrphansByQuery = droppedByQuery;
}
const COUNT_QUERIES = {
customers: (snapshot) => `query RpExtractCustomersCount { customersCount(query: "created_at:<='${snapshot}'", limit: null) { count precision } }`,
orders: (snapshot) => `query RpExtractOrdersCount { ordersCount(query: "created_at:<='${snapshot}'", limit: null) { count precision } }`,
};
async function readExactCount(entity) {
const root = entity === 'customers' ? 'customersCount' : 'ordersCount';
let result;
try {
result = await execute(COUNT_QUERIES[entity](manifest.snapshotTime));
} catch (error) {
if (error instanceof ShopifyCliError && error.code === 'shopify_throttled') return { error: { code: 'shopify_throttled', message: error.message } };
throw error;
}
if (result.errors && result.errors.length) return { error: { code: 'GRAPHQL_ERROR', message: result.errors.map((error) => error && error.message).filter(Boolean).join('; ').slice(0, 300) } };
const counted = result.data && result.data[root];
if (!counted || !Number.isInteger(counted.count)) return { error: { code: 'COUNT_MISSING', message: 'the count read returned no count' } };
return { count: counted.count, precision: counted.precision || null };
}
async function countCheck() {
const customersReceived = (await countLines(path.join(outputDir, 'customers.ndjson'))) || 0;
const withheld = (manifest.sidecars || []).includes('customers-withheld-identity') ? ((await countLines(path.join(outputDir, 'customers-withheld-identity.ndjson'))) || 0) : 0;
const received = { customers: customersReceived + withheld, orders: (await countLines(path.join(outputDir, 'orders.ndjson'))) || 0 };
manifest.counts = {};
const rows = [];
const stopCodes = [];
for (const entity of ['customers', 'orders']) {
const expected = await readExactCount(entity);
const record = { received: received[entity], ...(entity === 'customers' ? { withheldIdentity: withheld } : {}) };
let outcome;
if (expected.error) {
outcome = 'inexact';
Object.assign(record, { error: expected.error });
} else if (expected.precision !== 'EXACT') {
outcome = 'inexact';
Object.assign(record, { expected: expected.count, precision: expected.precision });
} else if (received[entity] < expected.count) {
outcome = 'short';
Object.assign(record, { expected: expected.count, precision: expected.precision });
} else {
outcome = 'pass';
Object.assign(record, { expected: expected.count, precision: expected.precision });
}
record.outcome = outcome;
manifest.counts[entity] = record;
if (outcome === 'pass') continue;
const code = outcome === 'short' ? 'shopify_extract_count_short' : 'shopify_extract_count_inexact';
const pointer = outcome === 'short'
? `counts.${entity}: received ${record.received} < Shopify's exact count ${record.expected} (precision ${record.precision})`
: `counts.${entity}: Shopify's count is not exact (${record.error ? `${record.error.code}: ${record.error.message}` : `precision ${record.precision}`}); received ${record.received}`;
rows.push(coverageRowFor(orderQueryOf(entity), { artifact: `${RAW_EXTRACT_DIR}/${EXTRACT_MANIFEST_FILE}`, pointer, recoveryAction: `Run shopify-extract.js --retry-failed to read ${entity} again, or decide the shortfall at mapping.` }));
stopCodes.push({ code, entity });
}
if (!stopCodes.length) { await save('counts'); return; }
manifest.countFailure = { entities: stopCodes.map((stop) => stop.entity), codes: stopCodes.map((stop) => stop.code) };
await save('counts');
const result = await upsertAdapterCoverageRows(projectDir, { adapter: 'shopify', rows });
throw new ShopifyExtractStop(stopCodes[0].code, `Shopify extract stopped (${stopCodes[0].code}): ${rows.map((row) => row.coverageId).join(', ')} recorded as source-coverage row(s) for the merchant's decision at mapping. Counts: ${JSON.stringify(manifest.counts)}. Requires Import Operator: after any cause is fixed, run \`shopify-extract.js --retry-failed\` to read the dataset again; when the extract completes, record the coverage decision \`provided\` for each row, otherwise show the merchant the row at mapping.`, { coverageIds: rows.map((row) => row.coverageId), ...result });
}
function orderQueryOf(entity) {
return entity === 'customers' ? 'customers' : 'orders-core';
}
async function finishExtract() {
if (!manifest.order.every((name) => entry(name).state === 'done')) {
const open = manifest.order.filter((name) => entry(name).state !== 'done');
throw new BulkStop(STOPPING_FAILED_CODE, `The extract cannot finish: ${open.join(', ')} ${open.length === 1 ? 'is' : 'are'} not read (${open.map((name) => `${name} ${entry(name).state}`).join(', ')}). Nothing was marked complete.`, { details: { open } });
}
await joinFinalFiles();
await save('joined');
await countCheck();
manifest.status = 'complete';
manifest.completedAt = iso(now());
delete manifest.countFailure;
await save('complete');
await fsp.rm(stagingDir, { recursive: true, force: true });
}
// -------------------------------------------------------------------------------------------
// run()
// -------------------------------------------------------------------------------------------
async function filesPresent() {
for (const file of expectedFinalFiles()) {
try {
await fsp.access(path.join(outputDir, file));
} catch {
return false;
}
}
return true;
}
async function runInner() {
manifest = await readManifest();
let storeMatches = !manifest;
if (manifest && manifest.store) {
try {
storeMatches = session.normalizeDomain(manifest.store) === normalizedStore;
} catch {
storeMatches = false;
}
}
if (manifest && !storeMatches) {
const recorded = manifest.store || null;
manifest = null;
throw new BulkStop('shopify_extract_store_mismatch', `${path.basename(manifestPath)} was read from ${recorded || 'an unrecorded store'}, not ${normalizedStore}. Nothing was read or changed.`, { details: { recorded, store: normalizedStore } });
}
if (!manifest) {
if (!Array.isArray(queries) || !queries.length) throw new Error('queries are required to start a new manifest');
manifest = newManifest({ caller, store: normalizedStore, snapshotTime: extract ? snapshotTime : null, queries, previousSidecars, now });
await save('created');
}
if (extract && manifest.status === 'complete') {
if (!(await filesPresent())) {
throw new BulkStop('shopify_extract_files_missing', `The extract manifest says complete but its final files are missing from ${path.relative(projectDir, outputDir)}. Run shopify-extract.js --catch-up to read the store again.`);
}
await fsp.rm(stagingDir, { recursive: true, force: true });
return { status: 'complete', skipped: true };
}
if (manifest.lastStop) { delete manifest.lastStop; await save('cleared-stop'); }
await settleAll();
await cleanLeftovers();
await applyResets();
for (const name of manifest.order) {
if (isStopping(entry(name), caller)) throw stopFor(name, entry(name), null, { later: true });
}
for (const name of manifest.order) await processQuery(name);
if (extract) await finishExtract();
else { manifest.status = 'complete'; manifest.completedAt = iso(now()); await save('complete'); }
return { status: 'complete' };
}
async function run() {
let stop = null;
try {
const outcome = await runInner();
return { ...outcome, manifest };
} catch (error) {
if (error instanceof BulkStop) stop = error;
else if (error instanceof ShopifyExtractStop) stop = new BulkStop(error.code, error.message, { details: error.details });
else if (error instanceof ShopifyCliError && error.code === 'source_auth_expired') stop = await authExpiredStop();
else if (error instanceof ShopifyCliError) stop = new BulkStop(error.code, error.message, { details: { cli: true } });
else throw error;
}
if (manifest) {
manifest.lastStop = { code: stop.code, message: stop.message, status: stop.status, at: iso(now()) };
await save('stopped');
}
return { status: stop.status, code: stop.code, message: stop.message, details: stop.details, manifest };
}
return { run, get manifest() { return manifest; } };
}
module.exports = {
QUERIES_DIR,
QUERY_CATALOG,
QUERY_NAMES,
CUSTOMER_QUERIES,
ORDER_QUERIES,
DENIAL_ATTRIBUTED_QUERIES,
MANIFEST_SCHEMA_VERSION,
EXTRACT_MANIFEST_FILE,
RAW_EXTRACT_DIR,
SNAPSHOT_PLACEHOLDER,
STATUS_QUERY,
POLL_START_MS,
POLL_CAP_MS,
OVERDUE_MS,
MAX_DOWNLOAD_TRIES,
MAX_LINK_RERUNS,
BulkStop,
catalogEntry,
entityOf,
readQueryFile,
generateQueryText,
connectionShape,
joinDiscountFiles,
validateJsonlFile,
foldBulkJsonl,
joinOrderFiles,
defaultDownload,
registerHandlers,
clearRegisteredHandlers,
registeredProjection: () => registered.project,
createBulkRunner,
};
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Wix
- Publisher
- Wix (Official)
- Source
- wix/skills · skills/wix-replatform/resources/rp-source-shopify · commit d168bc2
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Wix.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download rp-source-shopify.zip