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.