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.headersis aDict(String, String);bodyis the raw request body string (typically a JSON-encoded{"query": "...", "variables": {...}}for GraphQL).Response(status, body, headers)— HTTP-shaped output.bodyis agleam/jsontree; encode it withjson.to_stringbefore writing to the wire.Config(read_mode, port, shopify_admin_origin, snapshot_path)— sanitised runtime config. Mirrors what the legacy TS proxy exposes viaGET /__meta/config.ReadMode— one ofSnapshot,LiveHybrid,Live; the JS-facing shim exposesLiveas the legacy public string valuepassthrough.DraftProxy— opaque-ish state record. Threaded through every request call so callers can advance the staged-mutation log.
Functions
new() -> DraftProxy— fresh proxy withdefault_config().default_config() -> Config— defaults matching the TS test suite (Snapshotread mode, port 4000,https://shopify.comadmin 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— smallread_mode@portdebug 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.jsonland 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 yourgleam.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"} ] endUntil then, the canonical way to consume the package locally is the
gleam export erlang-shipmentartefact loaded by the smoke project in./elixir_smoke/— see Building a release artefact.
Calling conventions
ShopifyDraftProxy.new/0returns an opaque%ShopifyDraftProxy{}value.ShopifyDraftProxy.graphql/3and meta helpers return%ShopifyDraftProxy.Response{status:, body:, headers:, proxy:}; thread the returnedproxyinto the next call to preserve isolated staged state.bodyis a JSON string converted from the Gleam JSON tree.ShopifyDraftProxy.dump_state/2returns the state-dump JSON string;ShopifyDraftProxy.restore_state/2returns{:ok, proxy}or{:error, reason}.ShopifyDraftProxy.commit_with/4is 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-proxywill continue to be the npm package name; the published tarball will bundle the Gleam-emitted ESM plus adist/index.{js,d.ts}shim. Until the cutover,../srcships 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): DraftProxyDraftProxy#processRequest(request: DraftProxyRequest): DraftProxyHttpResponseDraftProxy#dumpState(): DraftProxyStateDump/restoreState(dump)DraftProxyCommitError,DRAFT_PROXY_STATE_DUMP_SCHEMA- Types:
AppConfig,ReadMode,DraftProxyRequest,DraftProxyHttpResponse,DraftProxyStateDump, etc. createApp(config, proxy?)constructs the JavaScript-target Nodehttpadapter over the Gleam-backedDraftProxyshim. The adapter exposescallback()andlisten(...);listen(...)returns the underlying NodeServer.loadConfig(env?)mirrors the legacy package environment parser:SHOPIFY_ADMIN_ORIGINis required,PORTdefaults to3000,SHOPIFY_DRAFT_PROXY_READ_MODEdefaults tolive-hybrid, andSHOPIFY_DRAFT_PROXY_SNAPSHOT_PATHenables 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 = trueingleam.toml), so editor IntelliSense works without a hand-written.d.ts. - Gleam tuples (
#(a, b)) become JS arrays —process_requestreturns[response, nextProxy]. Option(a)is a tagged class instance; the shim collapses it toT | nullfor the JS-facing API.Dictis a JSMap; the shim accepts and returns plain objects.Result(a, b)is preserved; the shim throws onError(_)for the imperative TS callers and exposes asafe-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, mirroringsrc/.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 sogleam testexercises the runtime Node consumers will use; the Erlang target is run alongside in CI and viagleam 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.