element.version === currentVersion)) { versionNodes.unshift({ version: currentVersion, url: "#" }); } document.querySelector("#project-version").innerHTML = versionNodes.reduce( (acc, element) => { const status = currentVersion === element.version ? "selected disabled" : ""; return ` ${acc} `; }, `
shopify_draft_proxy · v0.1.0

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

  • Request(method, path, headers, body) — HTTP-shaped input. headers is a Dict(String, String); body is the raw request body string (typically a JSON-encoded {"query": "...", "variables": {...}} for GraphQL).
  • Response(status, body, headers) — HTTP-shaped output. body is a gleam/json tree; encode it with json.to_string before writing to the wire.
  • Config(read_mode, port, shopify_admin_origin, snapshot_path) — sanitised runtime config. Mirrors what the legacy TS proxy exposes via GET /__meta/config.
  • ReadMode — one of Snapshot, LiveHybrid, Live; the JS-facing shim exposes Live as the legacy public string value passthrough.
  • DraftProxy — opaque-ish state record. Threaded through every request call so callers can advance the staged-mutation log.

Functions

  • new() -> DraftProxy — fresh proxy with default_config().
  • default_config() -> Config — defaults matching the TS test suite (Snapshot read mode, port 4000, https://shopify.com admin origin).
  • with_config(Config) -> DraftProxy — fresh proxy with a custom config.
  • with_registry(DraftProxy, List(RegistryEntry)) -> DraftProxy — attach a parsed operation registry so dispatch routes by capability instead of the hardcoded predicates. Optional; without a registry the proxy falls back to the legacy domain predicates.
  • registry_entry_has_local_dispatch(RegistryEntry) -> Bool — report whether a TS registry entry is both marked implemented and accepted by a currently ported Gleam root predicate. This is intentionally narrower than capability classification so unported TS roots are not advertised as local support.
  • process_request(DraftProxy, Request) -> #(Response, DraftProxy) — handle one request and return the response paired with the next proxy state. The TS class mutates itself in place; the Gleam port returns both halves so callers can thread state forward explicitly.
  • config_summary(Config) -> String — small read_mode@port debug string.

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

  • ShopifyDraftProxy.new/0 returns an opaque %ShopifyDraftProxy{} value.
  • ShopifyDraftProxy.graphql/3 and meta helpers return %ShopifyDraftProxy.Response{status:, body:, headers:, proxy:}; thread the returned proxy into the next call to preserve isolated staged state.
  • body is a JSON string converted from the Gleam JSON tree.
  • ShopifyDraftProxy.dump_state/2 returns the state-dump JSON string; ShopifyDraftProxy.restore_state/2 returns {:ok, proxy} or {:error, reason}.
  • ShopifyDraftProxy.commit_with/4 is the BEAM embedder seam for tests that need deterministic commit reports without real Shopify HTTP.

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:

  • createDraftProxy(config?: AppConfig): DraftProxy
  • DraftProxy#processRequest(request: DraftProxyRequest): DraftProxyHttpResponse
  • DraftProxy#dumpState(): DraftProxyStateDump / restoreState(dump)
  • DraftProxyCommitError, DRAFT_PROXY_STATE_DUMP_SCHEMA
  • Types: AppConfig, ReadMode, DraftProxyRequest, DraftProxyHttpResponse, DraftProxyStateDump, etc.
  • createApp(config, proxy?) constructs the JavaScript-target Node http adapter over the Gleam-backed DraftProxy shim. The adapter exposes callback() and listen(...); listen(...) returns the underlying Node Server.
  • loadConfig(env?) mirrors the legacy package environment parser: SHOPIFY_ADMIN_ORIGIN is required, PORT defaults to 3000, SHOPIFY_DRAFT_PROXY_READ_MODE defaults to live-hybrid, and SHOPIFY_DRAFT_PROXY_SNAPSHOT_PATH enables snapshot loading.

Calling conventions (Gleam → JS)

  • Gleam records become plain JS objects with the field names you wrote in the Gleam source: Request(method:, path:, headers:, body:){ method, path, headers, body }. Gleam’s compiler emits TypeScript declarations alongside the ESM (typescript_declarations = true in gleam.toml), so editor IntelliSense works without a hand-written .d.ts.
  • Gleam tuples (#(a, b)) become JS arrays — process_request returns [response, nextProxy].
  • Option(a) is a tagged class instance; the shim collapses it to T | null for the JS-facing API.
  • Dict is a JS Map; the shim accepts and returns plain objects.
  • Result(a, b) is preserved; the shim throws on Error(_) for the imperative TS callers and exposes a safe-prefixed variant for callers that want to handle the error tuple directly.

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

  • src/ — Gleam source.
    • shopify_draft_proxy.gleam — root module (currently a phase-0 marker).
    • shopify_draft_proxy/proxy/draft_proxy.gleam — public entry point.
    • shopify_draft_proxy/proxy/*.gleam — per-domain dispatchers.
    • shopify_draft_proxy/graphql/*.gleam — lexer, parser, root-field walk.
    • shopify_draft_proxy/state/*.gleam — store, synthetic identity, types.
  • test/ — gleeunit tests, mirroring src/.
  • elixir_smoke/ — mix project that loads the Erlang shipment and asserts the package is callable from Elixir.
  • gleam.toml — package manifest. Default target is JavaScript so gleam test exercises the runtime Node consumers will use; the Erlang target is run alongside in CI and via gleam test --target erlang.

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