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 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 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.
The real extension point
Code
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
Code
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, 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 for POST /auth/orgs/POST /auth/login),
then:
Code
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):
Code
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:
Code
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 fromnull— has no equivalent in this API's plain JSON value union (number/string/boolean/null). Collapsed tonullon write, mapped back toNAon read. Round-trips cleanly for values this extension itself wrote; a value that was genuinelynullfrom some other source would come back asNAinstead.- 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/ingestarray support is for exactly that shape. - Verified on plain, open-source Haxall. The
hx::IHisExtmixin itself is shared, open infrastructure, but compatibility with other Haystack servers implementing it has not been verified — don't assume it without testing.

