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, 0.2.0.0
Change log CHANGELOG.md
Dependencies aeson (>=2.2 && <2.3), base (>=4.21 && <4.22), containers (>=0.7 && <0.8), keiki (>=0.2 && <0.3), scientific (>=0.3 && <0.4), 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-07-13T14:54:37Z
Category Codec
Source repo head: git clone https://github.com/shinzui/keiki.git(keiki-codec-json)
Distributions
Reverse Dependencies 2 direct, 2 indirect [details]
Downloads 14 total (10 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.2.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.

Register-file wire rules

Every register slot must be written before encoding. emptyRegFile is an initialization scaffold whose unwritten slots throw an ErrorCall beginning with uninit: when read or encoded; it is not an encodable snapshot. On the streaming path that exception can surface after earlier bytes have already been emitted, so snapshot only fully hydrated aggregates.

Every register slot is present in the JSON object. A Nothing slot encodes as explicit JSON null; omitting its key is an error, not another spelling of Nothing. A nested optional value is lossy under aeson's standard instances: Just Nothing :: Maybe (Maybe a) and outer Nothing both encode as null and decode as outer Nothing. Avoid nested-Maybe slots when that distinction matters, or wrap the inner optional value in a newtype with explicit JSON instances.

The Value encoder emits aeson's object-key order, while the streaming Encoding path emits slot-list order. Their bytes may differ, but both decode to the same register file.

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 (fcOnMissing)
  , defaultEventCodecOptions
  , deriveEventCodecSkeleton
  , fieldCodec
  )

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)]
--   orderEventSchemaVersion :: Int

Each constructor encodes to an object carrying a "kind" discriminator (its constructor name unless pinned) and a "v" schema version, plus one entry per payload field, so orderEventToJSON (Placed (PlacedData (OrderId 7) 3)) is {"kind":"Placed","v":1,"orderId":"ord-7","qty":3} — note orderId is the override's output, not a generic Int. The orderEventEventTypes / orderEventKindMap bindings contain the resolved wire kinds as plain Text (no Keiro dependency), and orderEventSchemaVersion contains the configured current version.

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

Evolving an event schema

There are three common changes, each with a distinct codec move.

  1. Add a payload field without bumping the version. For an optional field such as note :: Maybe Text, add "note" to passthroughFields; a missing key decodes as Nothing, while an explicit JSON null also follows aeson's normal Maybe decoder. For a non-Maybe field, provide a named default constant through the override:

    defaultPriority :: Priority
    defaultPriority = NormalPriority
    
    priorityCodec :: FieldCodec
    priorityCodec =
      (fieldCodec 'priorityToJSON 'priorityFromJSON)
        { fcOnMissing = Just 'defaultPriority }
    

    Put priorityCodec in fieldCodecOverrides. Keep currentVersion unchanged: this is an additive compatibility rule, not a structural migration. Required fields without either form of default still fail with missing field: <name>.

  2. Rename a Haskell constructor without changing stored bytes. Pin the renamed constructor to its historical discriminator:

    kindOverrides = Map.fromList [("OrderPlaced", "Placed")]
    

    Override keys are current constructor names. The splice rejects unknown keys and duplicate resolved wire kinds, while encoding, decoding, EventTypes, and KindMap all use the pinned wire value.

  3. Restructure a payload. Increment currentVersion and register one whole-envelope upcaster for every historical step:

    upcastOrderV1 :: Aeson.Value -> Either String Aeson.Value
    upcastOrderV1 = ... -- rewrite a version-1 object into version-2 shape
    
    currentVersion = 2
    upcasters = [(1, 'upcastOrderV1)]
    

    An absent "v" is version 1. Before constructor dispatch, the decoder runs every rung from the stored version to the current version. For currentVersion = n, the splice requires exactly the source versions [1 .. n - 1]; gaps, duplicates, and out-of-range entries fail at compile time. A rung is one-envelope-to-one-envelope. If one historical event must split into several current events, do that in the application's event-store adapter as described in docs/research/schema-evolution.md.

Unknown object keys are intentionally ignored by the event decoder so additive deployments can overlap. This differs from the RegFile snapshot decoder, which rejects extra keys because a snapshot must match one exact register shape.

The in-band "v" is opt-in version ownership for applications that have no outer event envelope. If an application already owns out-of-band metadata — for example, a keiro-style schemaVersion beside the payload — keep this codec's currentVersion = 1 and evolve at that outer layer. Running both schemes with different version numbers is a configuration error; neither layer detects the disagreement for the other.

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