The wal2json decoder — JSON format version 2 (ADR-0009 §4).
One JSON document per action (B, C, I, U, D, T, M; anchors in
.kimosabe/intents/pglogical-wal2json-decoders.md). The decoder requests
include-transaction, include-lsn, include-xids, include-timestamp,
include-types, include-type-oids, include-pk and
numeric-data-types-as-string (wal2json ≥ 2.6): numerics arrive as JSON strings so
no trailing digit is lost, and type names come from the type OIDs through the same
OidDatabase call pgoutput's decoder uses. add-tables carries the configured
tables.
wal2json sends no relation message, so the decoder synthesizes a
Replicant.Decoder.Messages.Relation from the first change it sees for a table and
re-emits it ahead of any change whose column list differs (ADR-0009 §5) — the
existing schema-change classification then runs unchanged. A column ABSENT from a
change's columns list is wal2json's unchanged-TOAST sentinel (the plugin simply
skips it), which this decoder surfaces as the first-class :unchanged_toast marker.
The relation's replica_identity and stable id come from the connect-time catalog
read in the cache.
Documented divergences from the pgoutput contract
wal2json's format 2 has no relation message, so a dropped column is detected from
the change stream by two wire rules (dropped_columns/3): a cached column ABSENT
from an INSERT is dropped (inserts carry every live column — NULLs included, as
in-array null values), and a cached FIXED-WIDTH column ABSENT from an UPDATE is
dropped (no column STORAGE setting can store a fixed-width type out-of-line). Both
re-emit the subset relation ahead of the change and the assembler's destructive
classification (Critical Rule 4) halts fail-closed, exactly like a pgoutput
Relation re-emit after the same DDL. The residual: a dropped TOASTable column on
an update-only table is wire-identical to the unchanged-TOAST sentinel and stays
unchanged: until the periodic catalog guard (schema_check_interval, default
30s) or the next reconnect's catalog read re-emits the shrunken relation. A DROP
and an ADD committed in one DDL batch take the append-only drift branch first
(the ADD is visible in the change, the DROP is not), so the destructive halt for
that DROP is deferred to the guard or reconnect. A column ADD alone is detected
and classified as usual. A replica-identity change is still only visible at the
next reconnect's catalog read.
Value text: byte-identical to pgoutput, with one server-vintage boundary
With numeric-data-types-as-string (pre-flighted at connect — a build that
rejects it halts :decoder_option_unsupported before streaming), wal2json emits
every column value as the SERVER'S OWN output-function text: int2/4/8, oid,
float4/8 and numeric as JSON strings, bool as a JSON boolean, bytea as bare hex,
every other type as an escaped JSON string, NULL as an in-array null. A JSON
number in a column value is therefore structurally impossible on the wire, and
normalize_value/2 fails closed (:decode_failure) on one rather than
re-rendering it — an Elixir re-render (for example Float.to_string/1's
"1.0e20") is NOT the server's float8out text ("1e+20") and would silently
diverge from what pgoutput delivers. The one boundary is a SERVER fact, not a
decoder one: a float whose shortest round-trip needs more than 15 significant
digits prints differently on pre-PG12 servers (%.{DBL_DIG}g) than on PG12+
(shortest round-trip), so cross-vintage comparisons of that magnitude class
compare different server text; within one server both decoders deliver identical
bytes.