keiki-codec-json: Optional JSON codec for keiki's RegFile.

[ bsd3, codec, library ] [ Propose Tags ] [ Report a vulnerability ]

Sibling package to keiki providing a JSON encoder, decoder, and streaming encoder for RegFile rs. The keiki core remains aeson-free; this package opts in. See keiki's Keiki.Shape module for the GHC-upgrade-safe shape hash used to discriminate snapshots — the two halves of the snapshot persistence story. . Three methods on class RegFileToJSON rs: . * regFileToJSON :: RegFile rs -> Aeson.Value — strict Value-path encoder. . * regFileFromJSON :: Aeson.Value -> Either String (RegFile rs) — strict decoder with per-slot error messages on missing / extra / type-mismatched fields. . * regFileToEncoding :: RegFile rs -> Aeson.Encoding — streaming encoder over Aeson.Series, avoiding the O(output-size) intermediate Aeson.Value allocation. Recommended for RegFiles with multi-MB slot values. . Also ships a Template Haskell helper module Keiki.Codec.JSON.TH with deriveRegFileCodec that emits the three codec functions for a record type with deriving Generic. . The companion package keiki-codec-json-test ships a property- test toolkit for downstream consumers (case-#10 ToJSON-change detector plus library-ised round-trip / sensitivity helpers).


[Skip to Readme]

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.1.0.0
Change log CHANGELOG.md
Dependencies aeson (>=2.2 && <2.3), base (>=4.21 && <4.22), containers (>=0.7 && <0.8), keiki (>=0.1 && <0.2), template-haskell (>=2.23 && <2.24), text (>=2.1 && <2.2) [details]
Tested with ghc >=9.12 && <9.13
License BSD-3-Clause
Copyright 2026 Nadeem Bitar
Author Nadeem Bitar
Maintainer nadeem@gmail.com
Uploaded by shinzui at 2026-06-07T16:37:25Z
Category Codec
Distributions
Reverse Dependencies 1 direct, 0 indirect [details]
Downloads 1 total (1 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs uploaded by user
Build status unknown [no reports yet]

Readme for keiki-codec-json-0.1.0.0

[back to package description]

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.

Test toolkit for downstream consumers

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