shopify_draft_proxy

A Shopify Admin GraphQL digital twin / draft proxy. Implemented in Gleam and compiled to both Erlang (BEAM) and JavaScript so it can be embedded from Elixir/Erlang services and from Node/TypeScript without duplicating domain logic.

This directory holds the in-progress port from the legacy TypeScript implementation in ../src. It shares parity specs (../config/parity-specs) and recorded Shopify fixtures (../fixtures/conformance) with the legacy implementation. See ../GLEAM_PORT_INTENT.md for the why and the non-goals, and ../docs/architecture.md for the runtime design.

Status: Port in progress. The substrate (request routing, mutation log, snapshot/restore) is wired end-to-end; per-domain coverage is partial. The public API documented below is what the Gleam package will ship — gaps are called out inline as TODO.

Public API

The package’s entry point is shopify_draft_proxy/proxy/draft_proxy. Every target consumes the same surface; only the syntax for calling into it differs.

Types

Functions

Routes handled today: GET /__meta/health, GET /__meta/config, GET /__meta/log, GET /__meta/state, POST /__meta/reset, POST /__meta/commit, and POST /admin/api/:version/graphql.json for the currently ported Admin API domains. Anything else returns the same JSON error envelopes as the legacy webservice for the supported route surface.

TODO: GET /__bulk_operations/:id/result.jsonl and the staged-uploads routes are still required to fully replace every TS proxy HTTP endpoint. See ../GLEAM_PORT_INTENT.md “Substrate acceptance criteria”.

Using from Gleam

TODO: installation. The package is not yet on Hex; once it is, depend on shopify_draft_proxy = ">= 0.1 and < 1.0" in your gleam.toml.

import gleam/dict
import gleam/io
import gleam/json
import shopify_draft_proxy/proxy/draft_proxy

pub fn main() {
  let proxy = draft_proxy.new()

  let request =
    draft_proxy.Request(
      method: "GET",
      path: "/__meta/health",
      headers: dict.new(),
      body: "",
    )

  let #(response, _next_proxy) = draft_proxy.process_request(proxy, request)
  io.println(json.to_string(response.body))
  // => {"ok":true,"message":"shopify-draft-proxy is running"}
}

To handle a GraphQL request, point path at /admin/api/:version/graphql.json and put the JSON body (with query and optional variables) in body. The returned Response is the GraphQL envelope {"data": ...} — encode it with json.to_string before sending it on the wire. Thread the second element of the tuple back into the next process_request call to keep the staged mutation log advancing.

To use a non-default config:

let proxy =
  draft_proxy.with_config(draft_proxy.Config(
    read_mode: draft_proxy.LiveHybrid,
    port: 4000,
    shopify_admin_origin: "https://my-shop.myshopify.com",
    snapshot_path: option.None,
  ))

Using from Elixir

The Gleam package compiles to BEAM and is publishable to Hex, so Elixir consumes it as an ordinary mix dependency. The low-level Gleam modules remain available as Erlang modules with @-separated path segments, but Elixir application code should prefer the thin ShopifyDraftProxy wrapper exercised by elixir_smoke/. The wrapper keeps the Gleam proxy value opaque, returns the next proxy state explicitly, and exposes response bodies as JSON strings that can be decoded with Jason.decode/1 or another Elixir JSON library.

TODO: installation. Once published:

# mix.exs
defp deps do
  [
    {:shopify_draft_proxy, "~> 0.1"}
  ]
end

Until then, the canonical way to consume the package locally is the gleam export erlang-shipment artefact loaded by the smoke project in ./elixir_smoke/ — see Building a release artefact.

Calling conventions

Example

defmodule MyApp.DraftProxyDemo do
  @moduledoc """
  Minimal end-to-end use of the Gleam-compiled draft proxy from Elixir.
  """

  def product_lifecycle do
    create =
      ShopifyDraftProxy.graphql(ShopifyDraftProxy.new(), ~s|
        mutation {
          productCreate(product: { title: "Wrapper Hat" }) {
            product { id title handle status }
            userErrors { field message }
          }
        }
      |)

    %ShopifyDraftProxy.Response{status: 200, body: body, proxy: proxy} = create
    {:ok, %{"data" => %{"productCreate" => %{"product" => product}}}} =
      Jason.decode(body)

    read =
      ShopifyDraftProxy.graphql(
        proxy,
        ~s|query { product(id: "#{product["id"]}") { id title handle status } }|
      )

    {product, read}
  end
end

A custom config:

proxy =
  ShopifyDraftProxy.with_config(
    read_mode: :live_hybrid,
    port: 4000,
    shopify_admin_origin: "https://my-shop.myshopify.com"
  )

The raw Gleam module remains callable as :shopify_draft_proxy@proxy@draft_proxy for adapter-level code that really needs the compiled tuple ABI. Application tests should use the wrapper instead.

Using from TypeScript / JavaScript

The Gleam package emits ESM as a build target, and that emitted ESM will become the only TypeScript implementation once the port lands. The legacy ../src will be deleted; consumers will continue to import the same createDraftProxy(config) / processRequest(...) names from a thin TS shim that re-exports the Gleam-emitted modules with stable types.

TODO: delete ../src/** once Gleam domain coverage matches the legacy proxy. See ../GLEAM_PORT_INTENT.md “Domain coverage acceptance criteria”.

TODO: installation. shopify-draft-proxy will continue to be the npm package name; the published tarball will bundle the Gleam-emitted ESM plus a dist/index.{js,d.ts} shim. Until the cutover, ../src ships the legacy implementation under that name.

Public surface

The TS shim re-exports the same names the legacy ../src/index.ts exports today. Notable items:

Calling conventions (Gleam → JS)

Example (planned shim shape)

import { createDraftProxy } from 'shopify-draft-proxy';

const proxy = createDraftProxy();

const response = proxy.processRequest({
  method: 'POST',
  path: '/admin/api/2025-01/graphql.json',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ query: '{ events(first: 1) { nodes { id } } }' }),
});

console.log(response.status, response.body);

To launch the JavaScript-target HTTP adapter from gleam/js during port work:

SHOPIFY_ADMIN_ORIGIN=https://your-store.myshopify.com corepack pnpm dev

corepack pnpm build
SHOPIFY_ADMIN_ORIGIN=https://your-store.myshopify.com corepack pnpm start

The repository root still ships the legacy TypeScript/Koa runtime until the whole-port cutover. The gleam/js package is the JS-target adapter under test for the port and is not yet the root package export.

Development

# Install dependencies
gleam deps download

# Run tests on both targets
gleam test --target erlang
gleam test --target javascript

Building a release artefact

For Elixir/Erlang consumers, gleam export erlang-shipment produces a self-contained directory tree of .beam files that can be loaded into any mix/rebar3 project without an Elixir-side compile step. The elixir_smoke/ project consumes that shipment to assert the package is loadable and callable from Elixir.

# from gleam/
gleam export erlang-shipment

# then, from gleam/elixir_smoke/
mix test

From the repository root, corepack pnpm elixir:smoke runs the same flow. On hosts without native escript/mix, the script falls back to the ghcr.io/gleam-lang/gleam:v1.16.0-erlang-alpine container and installs Elixir inside the disposable container before running the smoke project.

This is the local equivalent of mix deps.get && mix compile against a published Hex release; running it before gleam publish catches BEAM-side regressions that the JavaScript test target would miss.

Layout

The package will be promoted to the repository root and the legacy TypeScript in ../src will be deleted once domain coverage reaches parity.

Search Document