README.md

# 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.