keiki-codec-json
Optional JSON codec support for
keiki's type-level
register file, RegFile rs.
This package is separate by design: the keiki core remains
aeson-free, while applications that persist snapshots as JSON can opt
in here. The structural shape hash
(Keiki.Shape.regFileShapeHash) stays in keiki so consumers can
discriminate snapshot shapes without pulling in a JSON dependency.
This package ships:
Keiki.Codec.JSON.RegFileToJSON — three-method class providing
regFileToJSON :: RegFile rs -> Aeson.Value (strict object encoder)
regFileFromJSON :: Aeson.Value -> Either String (RegFile rs)
(strict decoder; missing / extra / type-mismatched fields are
rejected with a per-slot error message)
regFileToEncoding :: RegFile rs -> Aeson.Encoding — streaming
encoder over Aeson.Series, avoiding the O(output-size)
intermediate Aeson.Value allocation for users with multi-MB slot
values
Keiki.Codec.JSON.TH — Template Haskell helpers for deriving record
codecs through the same RegFileToJSON path
Keiki.Codec.JSON.Event — Template Haskell helpers for generating a
kind-discriminated event codec skeleton from event sum types
Using
import Data.Proxy (Proxy (..))
import Keiki.Codec.JSON (regFileFromJSON, regFileToEncoding, regFileToJSON)
import Keiki.Shape (regFileShapeHash)
type Snapshot = '[ '("retryCount", Int), '("note", Text) ]
-- Snapshot persister:
let bytes = encodingToLazyByteString (regFileToEncoding rf)
hash = regFileShapeHash (Proxy @Snapshot)
writeRow (snapshotTable hash bytes)
-- Hydration:
case Aeson.decode bytes of
Nothing -> Left "snapshot bytes not JSON"
Just v -> regFileFromJSON @Snapshot v
Deriving the codec for a record type
If you have a plain Haskell record and want the three codec functions
without writing them by hand, use the TH splice from
Keiki.Codec.JSON.TH:
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE TemplateHaskell #-}
import qualified Data.Aeson as Aeson
import Data.Text (Text)
import GHC.Generics (Generic)
import Keiki.Codec.JSON.TH (deriveRegFileCodec)
data Snapshot = Snapshot
{ retryCount :: Int
, note :: Text
}
deriving stock (Eq, Show, Generic)
$(deriveRegFileCodec ''Snapshot)
-- emits:
-- snapshotToJSON :: Snapshot -> Aeson.Value
-- snapshotToEncoding :: Snapshot -> Aeson.Encoding
-- snapshotFromJSON :: Aeson.Value -> Either String Snapshot
The emitted functions route through the same RegFileToJSON class as
the hand-written path: the record's field names become the JSON object's
keys, missing/extra/type-mismatched fields are rejected with the same
per-slot error messages, and the encoding path streams without
allocating an intermediate Aeson.Value.
Every field type must carry Aeson.ToJSON + Aeson.FromJSON. If a
field type lacks either instance, compilation fails at the use site of
the emitted function with a precise per-field error pointing at the
missing instance.
The record must have deriving (Generic) — the splice does not emit
a Generic instance for you. Multi-constructor sum types, positional
(non-record-syntax) constructors, and type synonyms are rejected at
splice time with a precise error message.
Deriving an event codec skeleton
A service that stores its events as JSON usually hand-writes a
kind-discriminated encoder/decoder per event sum type — a large
case with one branch per constructor and one .= per payload field,
plus a matching parser. deriveEventCodecSkeleton (from
Keiki.Codec.JSON.Event) removes that boilerplate. Given a sum type whose
constructors each wrap a single record payload, or are no-argument
singletons:
{-# LANGUAGE TemplateHaskell #-}
import qualified Data.Aeson as Aeson
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Text (Text)
import Keiki.Codec.JSON.Event
( EventCodecOptions (..)
, FieldCodec (..)
, defaultEventCodecOptions
, deriveEventCodecSkeleton
)
newtype OrderId = OrderId Int deriving stock (Eq, Show)
orderIdToJSON :: OrderId -> Aeson.Value
orderIdFromJSON :: Aeson.Value -> Either String OrderId
data PlacedData = PlacedData
{ orderId :: OrderId
, qty :: Int
}
deriving stock (Eq, Show)
data ShippedData = ShippedData
{ trackingNo :: Text
}
deriving stock (Eq, Show)
data OrderEvent
= Placed PlacedData
| Shipped ShippedData
| Cancelled
deriving stock (Eq, Show)
$(deriveEventCodecSkeleton
defaultEventCodecOptions
{ fieldCodecOverrides =
Map.fromList [("orderId", FieldCodec 'orderIdToJSON 'orderIdFromJSON)]
, passthroughFields = Set.fromList ["qty", "trackingNo"]
}
''OrderEvent)
-- emits, using the lower-cased type name as prefix:
-- orderEventToJSON :: OrderEvent -> Aeson.Value
-- orderEventFromJSON :: Aeson.Value -> Either String OrderEvent
-- orderEventEventTypes :: [Text]
-- orderEventKindMap :: [(Text, Text)]
Each constructor encodes to an object carrying a "kind" discriminator
(its constructor name) plus one entry per payload field, so
orderEventToJSON (Placed (PlacedData (OrderId 7) 3)) is
{"kind":"Placed","orderId":"ord-7","qty":3} — note orderId is the
override's output, not a generic Int. The orderEventEventTypes /
orderEventKindMap bindings are plain Text (no Keiro dependency) so a
downstream can feed them to Keiro's Codec.eventTypes.
No silent generic fallback. Each payload field is encoded by name:
an override (fieldCodecOverrides), a passthrough using the field's own
aeson instances (passthroughFields), or — for a field in neither —
whatever onMissingCodec says. The default FailAtCompileTime aborts the
splice listing every unhandled <Event>.<field> :: <Type>; the
alternative EmitTodoBindings emits a _todo_<Event>_<field> placeholder
that compiles but fails when evaluated. Adding a field to a payload
record therefore forces a compile-time decision instead of silently
changing, or dropping, the stored JSON.
Constructors that are multi-argument, use record syntax directly, or are
GADT/infix are rejected at splice time with a precise message; wrap a
single record payload type instead (Placed PlacedData).
When to use the streaming encoder
regFileToJSON builds an Aeson.Value whose Object is an
Aeson.KeyMap — internally a Map Key Value in aeson 2.2, so its
serialised form orders keys alphabetically. regFileToEncoding walks
the slot list directly into Aeson.Series (slot-list order) without
materialising the intermediate Aeson.Value. Both paths round-trip
through regFileFromJSON to the same RegFile, but for multi-MB
RegFiles the Encoding path saves a substantial allocation (see
bench/baseline.csv — for the 5000-item batch reconciliation fixture
the Encoding path is ~1.5× faster and allocates roughly two-thirds the
bytes).
Benchmarks
cabal bench keiki-codec-json:keiki-codec-json-bench
Four fixtures cover representative snapshot sizes:
| Fixture |
Scenario |
Condensed size |
BenchA_ContractSign |
Contract signing |
5 parties, 50 audit rows |
BenchB_BatchRecon |
Batch reconciliation |
5,000 processedItems |
BenchC_TicketAgg |
Ticket aggregate |
100 comments |
BenchD_Auction |
Auction |
1,000 bids |
Per fixture: encode-via-Value, encode-via-Encoding, decode, hash.
bench/baseline.csv carries reference numbers from a GHC 9.12.2 run on
macOS aarch64. The benchmark is a tracked metric, not a correctness
gate; the golden shape-hash tests are the release-blocking checks.
If you persist RegFile rs to JSON and want to guard against the
schema-evolution case the shape hash cannot catch by design — a
silent change to a slot type's Aeson.ToJSON instance — see the
sibling package
keiki-codec-json-test. It
ships a per-slot-type golden-byte detector (slotGoldenSpec) plus
library versions of the round-trip and sensitivity disciplines,
parameterised over your own slot list. Production consumers of
keiki-codec-json do not need to depend on it.
Test suite
cabal test keiki-codec-json:keiki-codec-json-test
Covers unit tests, four QuickCheck properties, schema-evolution
sensitivity assertions, and the golden hash fixture pinned for
GHC 9.12.*.