# shopify_draft_proxy
A Shopify Admin GraphQL digital twin / draft proxy. Implemented in [Gleam](https://gleam.run)
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`.
```gleam
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:
```gleam
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:
>
> ```elixir
> # 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](#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
```elixir
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:
```elixir
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)
```ts
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:
```sh
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
```sh
# 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.
```sh
# 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.