# Haxall Drop-In

Baler can be the actual point-history backend behind a real Haxall
project — not a mirror, not a comparison, the real thing. Haxall's UI,
Axon, and rules keep working completely unchanged; `hisWrite`/`hisRead`
just land in Baler's Postgres/Timescale store instead of Folio.

This works because Haxall exposes a real, intentional extension point for
exactly this: `hx::IHisExt`. A small Fantom library
(`~/baler/fantom-his-ext/`) implements it and forwards reads/writes
to this API's [`/history` endpoints](/api/ingest) over plain HTTP.

Live-verified against a real Haxall 4.0.6 instance, through the current
gateway/Bearer-token config (not just against source, and not just the
old hardcoded-URL version): `hisWrite` called through Axon lands in
Baler's Postgres, checked independently via this API's own `GET
/history`; then Haxall's own `hisRead` op reads the same value back at
the same timestamp. See [Re-verified live](#configuring-it) below for
the exact steps that were run.

This is push-mode: something is installed into Haxall so it calls out to
Baler. For the opposite direction — Baler pulling from a
remote Haystack/Haxall/SkySpark server with nothing installed on the
other side — see [Haystack Puller](/haystack-puller).

## The real extension point

```fantom
// hx.pod, src/core/hx/fan/IExts.fan
const mixin IHisExt : Ext {
  abstract Void read(Dict pt, Span? span, Dict? opts, |HisItem| f)
  abstract Future write(Dict pt, HisItem[] items, Dict? opts := null)
}
```

`BalerHisExt` implements this exactly like Haxall's own default
(`hxd::HxdHisExt`, which just wraps `rt.db.his`) — same two methods, HTTP
calls to `/ingest` and `/history` instead.

## A real, load-bearing naming trick

Haxall's built-in historian (`hx.hxd.his`) is a **boot lib** — it cannot
be removed via `libRemove` (`hx::CannotRemoveBootLibErr`, confirmed live).
With two libraries both implementing `IHisExt`, Haxall's lookup
(`RuntimeExts.getByType`) picks whichever one sorts **first
alphabetically by name**. That's why this library is registered as `his`,
not a more properly-namespaced `td.his` — `"his" < "hx.hxd.his"` wins the
tie-break. This is a real workaround for a real gap (Haxall has no
supported way to explicitly select which `IHisExt` should be active when
more than one is enabled), not a stylistic choice. If Haxall ever adds an
explicit override mechanism, prefer that instead.

## Enabling it

```axon
libAdd(["his"])
```

That's it — no `libRemove` needed or possible for the boot lib; adding
`his` is enough for it to win going forward.

## Configuring it

The extension needs a Baler base URL and, since every route now
goes through the JWT-enforced [gateway](/architecture#the-gateway), a
Bearer token. Both are real settings (`BalerHisSettings.fan`, the
same `@Setting`/`Ext.settings()` mechanism Haxall's own `hxHttp` lib
uses), not hardcoded — get an operator-role token first (see
[Gateway Auth](/gateway-auth) for `POST /auth/orgs`/`POST /auth/login`),
then:

```axon
extSettingsUpdate("his", {authToken: "eyJhbGci..."})
```

`baseUri` already defaults to the gateway
(`http://host.docker.internal:8080/ingest/`); only override it if you're
pointing this at a non-default host/port, or — for a Haxall integration
you consider fully trusted — at a host port mapped directly onto
`ingest_api` in `docker-compose.yml`, bypassing the gateway (and then
`authToken` isn't needed):

```axon
extSettingsUpdate("his", {baseUri: `http://host.docker.internal:8000/`})
```

Leaving `authToken` unset while `baseUri` still points at the gateway
fails loudly — a `401` on the first `hisRead`/`hisWrite` — rather than
silently talking to a dead port the way the previous hardcoded URL did.

**Re-verified live (2026-08-27).** Compiled `tdHis`/`his.xetolib` against
a real Haxall 4.0.6 install, `libAdd(["his"])`'d it, ran
`extSettingsUpdate("his", {authToken, baseUri})` for real, wrote a value
through a live curVal→`hisCollectCov` transition (`hisStatus: "ok"`), and
confirmed it independently via this API's own `GET /history` — then read
it back through Haxall's own `hisRead` op (which calls
`BalerHisExt.read()` under the hood) and got the same value at the
same timestamp. Both directions of the settings-based config work
end-to-end through the gateway with a real Bearer token, not just
cross-checked against source.

## Automated smoke test

`tests/test_haxall_drop_in_smoke.py` runs the same check above against a
real Haxall instance — enables `his`/`hx.point`, sets `authToken`/`baseUri`,
writes a value through a live curVal/`hisCollectCov` transition, and
asserts it landed via this API's own `GET /ingest/history` and reads back
correctly via Haxall's `hisRead`. Opt-in, same convention as
`test_haystack_puller.py`'s real-Haxall test — skipped unless
`HAXALL_SU_USERNAME` is set:

```bash
HAXALL_SU_USERNAME=su HAXALL_SU_PASSWORD=<pass> HAXALL_OSS_URL=http://localhost:8280 \
  python -m pytest tests/test_haxall_drop_in_smoke.py -v
```

Needs a running Haxall instance with the `his` lib built (see "Enabling
it" above) and the gateway up (`docker compose up -d`) — it creates its
own throwaway org/admin account per run for the operator token.

## Known limitations

- **`NA` (Not Available)** — a real, storable Haystack value distinct
  from `null` — has no equivalent in this API's plain JSON value union
  (number/string/boolean/null). Collapsed to `null` on write, mapped back
  to `NA` on read. Round-trips cleanly for values this extension itself
  wrote; a value that was genuinely `null` from some other source would
  come back as `NA` instead.
- **Batching is per-point, not cross-point** — Haxall calls `write()`
  once per point per collection cycle with a batch of items for *that*
  point, never a multi-point batch in one call. This API's `/ingest`
  array support is for exactly that shape.
- **Verified on plain, open-source Haxall.** The `hx::IHisExt` mixin itself
  is shared, open infrastructure, but compatibility with other Haystack
  servers implementing it has not been verified — don't assume it without
  testing.
