morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
payload_schema.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3/// @file
4/// @brief Structural fingerprint of an action payload's JSON shape.
5///
6/// The journal records an action's request as JSON and replays it, later,
7/// through `ActionTraits<A>::fromJson` — a *lenient* decode that ignores
8/// unknown keys and default-constructs absent ones. That leniency is deliberate
9/// (it is what makes an additive field a non-event for every already-deployed
10/// reader), but on the replay path it also means a **renamed** field silently
11/// becomes "key absent + unknown key ignored": the entry decodes, the model
12/// reconstructs, and the value that was actually recorded is gone. Nothing in
13/// the entry said which shape wrote it, so nothing could notice.
14///
15/// This header supplies the missing discriminator: a short, stable string
16/// derived from the payload struct's reflected shape, cheap enough to stamp on
17/// every recorded entry and compare on every replayed one. See
18/// `docs/spec/journal/journal.md`, "Payload schema fingerprint".
19
20#pragma once
21#include <algorithm>
22#include <cstddef>
23#include <cstdint>
24#include <glaze/glaze.hpp>
26#include <optional>
27#include <string>
28#include <string_view>
29#include <type_traits>
30#include <utility>
31#include <vector>
32
33namespace morph::model {
34
35/// @brief Version of the fingerprint *algorithm*, not of any one payload.
36///
37/// Stamped as the prefix of every fingerprint (`"2:…"`) so a future build that
38/// computes fingerprints differently can still recognise — and reject, or
39/// re-derive — a fingerprint written under an earlier scheme, instead of
40/// mistaking a scheme change for a payload change. Bumped only when
41/// `detail::payloadShape` changes what it emits for an unchanged struct.
42///
43/// Scheme 2 renders a custom-codec type that declares a
44/// `morph::model::PayloadShapeTag` as that declared name rather than as the
45/// bare opaque tag, so that a retype between two of them is visible. Every
46/// payload with such a member therefore fingerprints differently under 2 than
47/// it did under 1, which is exactly what the prefix exists to make legible: an
48/// entry stamped `1:…` read by this build reports a mismatch that names an
49/// algorithm change, not a payload change.
50inline constexpr std::uint32_t kPayloadFingerprintScheme = 2;
51
52namespace detail {
53
54/// @brief Recursion limit for `payloadShape`.
55///
56/// A payload nested deeper than this contributes the opaque tag rather than its
57/// structure — the fingerprint stays finite for a self-referential type (a
58/// `struct Node { std::vector<Node> kids; }` is a legal Glaze payload) without
59/// needing cycle detection. Eight levels is far past any action struct in this
60/// repository or its examples.
61inline constexpr int kPayloadShapeMaxDepth = 8;
62
63/// @brief Tag emitted for a type whose JSON shape this header does not
64/// decompose — a type with a custom Glaze codec (`glz::meta` naming a
65/// value rather than an object), a function pointer, a variant.
66///
67/// Deliberately carries no *compiler-derived* type name: `glz::name_v` falls
68/// back to a `__PRETTY_FUNCTION__`/`__FUNCSIG__`-derived spelling that differs
69/// between compilers, and a fingerprint that changes when the *compiler*
70/// changes would make every journal unreadable by a peer build.
71///
72/// A custom-codec type can still name *itself*, by specialising
73/// `morph::model::PayloadShapeTag` with a name spelled in these sources
74/// (`core/payload_shape_tag.hpp`); `payloadShape` then emits
75/// `x{<name>}` in place of the bare tag. This tag is what remains for a type
76/// that has declared nothing. See the spec's "What the fingerprint does not
77/// catch".
78inline constexpr std::string_view kOpaqueShapeTag = "x";
79
80template <typename T>
81[[nodiscard]] std::string payloadShape(int depth);
82
83/// @brief Trait detecting `std::optional<T>` exactly (not "nullable in general").
84/// @tparam T Type under test.
85template <typename T>
86struct IsStdOptional : std::false_type {};
87
88/// @brief Specialisation matching `std::optional<T>`.
89/// @tparam T Contained type.
90template <typename T>
91struct IsStdOptional<std::optional<T>> : std::true_type {};
92
93/// @brief Renders a reflected aggregate as `(key:shape,key:shape,…)`, with the
94/// members sorted by key.
95///
96/// **Sorted, so member reordering is not a schema change.** JSON objects are
97/// unordered and the decode matches by name, so moving a field within the
98/// struct changes nothing about which bytes decode to which member; making the
99/// fingerprint order-sensitive would turn a purely cosmetic edit into a replay
100/// break for every retained journal. Renaming, adding, or removing a member
101/// does change the rendering, which is the whole point.
102///
103/// @tparam T Reflected aggregate (or `glz::meta`-declared object) type.
104/// @param depth Current recursion depth; members are rendered at `depth + 1`.
105/// @return The parenthesised, comma-joined, key-sorted member rendering.
106template <typename T>
107[[nodiscard]] std::string payloadShapeObject(int depth) {
108 using V = std::remove_cvref_t<T>;
109 constexpr std::size_t kMemberCount = glz::reflect<V>::size;
110 std::vector<std::string> members;
111 members.reserve(kMemberCount);
112 [&]<std::size_t... I>(std::index_sequence<I...>) {
113 (members.emplace_back(std::string{glz::reflect<V>::keys[I]} + ':' +
114 payloadShape<glz::refl_t<V, I>>(depth + 1)),
115 ...);
116 }(std::make_index_sequence<kMemberCount>{});
117 std::ranges::sort(members);
118 std::string out{"("};
119 bool first = true;
120 for (const auto& member : members) {
121 if (!first) {
122 out += ',';
123 }
124 first = false;
125 out += member;
126 }
127 out += ')';
128 return out;
129}
130
131/// @brief Renders @p T's JSON shape as a compact, compiler-independent string.
132///
133/// The grammar, in full:
134///
135/// | C++ type | Rendering |
136/// |---|---|
137/// | `bool` | `b` |
138/// | `char` | `c` (its signedness is platform-dependent; the tag is not) |
139/// | signed integral | `i` + `sizeof` (e.g. `i4`) |
140/// | unsigned integral | `u` + `sizeof` |
141/// | enumeration | `e` + `sizeof` of the underlying type |
142/// | floating point | `f` + `sizeof` |
143/// | string-like | `s` |
144/// | `std::optional<T>` | `?` + T's rendering |
145/// | map | `{` key `>` mapped `}` |
146/// | other range | `[` element `]` |
147/// | reflected object | `(` sorted `key:shape` list `)` |
148/// | declares a `PayloadShapeTag` | `x{` name `}`, or `x{` name `:` inner shape `}` |
149/// | anything else | `x` (see `kOpaqueShapeTag`) |
150///
151/// Every tag is derived from `std::` type traits, from Glaze's *reflected key
152/// strings*, or from a name spelled in this repository's own sources, never
153/// from a compiler-spelled type name, so two builds of the same sources on
154/// different compilers, standard libraries, or platforms render an unchanged
155/// struct identically.
156///
157/// A declared `PayloadShapeTag` is consulted *first*, before any structural
158/// case. A custom-codec type that Glaze can also reflect over would otherwise
159/// be decomposed into members its codec never reads or writes, describing a
160/// shape that never reaches the journal.
161///
162/// @tparam T Type to render.
163/// @param depth Current recursion depth; once it is *past*
164/// `kPayloadShapeMaxDepth` the opaque tag is emitted instead of
165/// recursing further, so `kPayloadShapeMaxDepth` is the deepest
166/// level that still renders its own structure.
167/// @return The rendering described above.
168template <typename T>
169[[nodiscard]] std::string payloadShape(int depth) {
170 using U = std::remove_cvref_t<T>;
171 if (depth > kPayloadShapeMaxDepth) {
172 return std::string{kOpaqueShapeTag};
173 }
174 if constexpr (HasPayloadShapeInner<U>) {
175 return std::string{kOpaqueShapeTag} + '{' + std::string{PayloadShapeTag<U>::name()} + ':' +
176 payloadShape<typename PayloadShapeTag<U>::Inner>(depth + 1) + '}';
177 } else if constexpr (HasPayloadShapeTag<U>) {
178 return std::string{kOpaqueShapeTag} + '{' + std::string{PayloadShapeTag<U>::name()} + '}';
179 } else if constexpr (std::is_same_v<U, bool>) {
180 return "b";
181 } else if constexpr (std::is_same_v<U, char>) {
182 return "c";
183 } else if constexpr (std::is_enum_v<U>) {
184 return "e" + std::to_string(sizeof(std::underlying_type_t<U>));
185 } else if constexpr (std::is_integral_v<U>) {
186 return (std::is_signed_v<U> ? std::string{"i"} : std::string{"u"}) + std::to_string(sizeof(U));
187 } else if constexpr (std::is_floating_point_v<U>) {
188 return "f" + std::to_string(sizeof(U));
189 } else if constexpr (glz::str_t<U> || glz::string_view_t<U>) {
190 return "s";
191 } else if constexpr (IsStdOptional<U>::value) {
192 return "?" + payloadShape<typename U::value_type>(depth + 1);
193 } else if constexpr (glz::readable_map_t<U>) {
194 return "{" + payloadShape<typename U::key_type>(depth + 1) + ">" +
195 payloadShape<typename U::mapped_type>(depth + 1) + "}";
196 } else if constexpr (glz::range<U>) {
197 return "[" + payloadShape<glz::range_value_t<U>>(depth + 1) + "]";
198 } else if constexpr (glz::reflectable<U> || glz::glaze_object_t<U>) {
199 return payloadShapeObject<U>(depth);
200 } else {
201 return std::string{kOpaqueShapeTag};
202 }
203}
204
205/// @brief FNV-1a over @p bytes, 64-bit.
206///
207/// Chosen for being three lines of dependency-free, byte-exact-on-every-platform
208/// arithmetic — this hash is a *discriminator*, not a security primitive, and
209/// nothing downstream must be able to trust it against an adversary who can
210/// also rewrite the journal line it is stamped on.
211/// @param bytes Input to hash.
212/// @return The 64-bit digest.
213[[nodiscard]] inline std::uint64_t fnv1a64(std::string_view bytes) noexcept {
214 constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
215 constexpr std::uint64_t kPrime = 1099511628211ULL;
216 std::uint64_t hash = kOffsetBasis;
217 for (const char byte : bytes) {
218 hash ^= static_cast<std::uint64_t>(static_cast<unsigned char>(byte));
219 hash *= kPrime;
220 }
221 return hash;
222}
223
224/// @brief Renders @p value as exactly 16 lowercase hex digits.
225/// @param value Digest to render.
226/// @return The zero-padded hex string.
227[[nodiscard]] inline std::string hex64(std::uint64_t value) {
228 constexpr std::string_view kDigits = "0123456789abcdef";
229 std::string out(16, '0');
230 for (std::size_t i = 0; i < 16; ++i) {
231 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) — fixed 16-char buffer
232 out[15 - i] = kDigits[(value >> (4 * i)) & 0xFULL];
233 }
234 return out;
235}
236
237} // namespace detail
238
239/// @brief Returns the human-readable shape rendering behind
240/// `payloadFingerprint<A>()`.
241///
242/// Exposed because a fingerprint mismatch is otherwise two opaque hex strings:
243/// with this, a diagnostic (or a developer at a prompt) can print both shapes
244/// and see *which* member moved. Not stamped on entries — it is unbounded in
245/// length, whereas the fingerprint is 18 bytes.
246/// @tparam A Action payload type.
247/// @return The shape rendering; see `detail::payloadShape` for the grammar.
248template <typename A>
249[[nodiscard]] inline const std::string& payloadShapeString() {
250 static const std::string kShape = detail::payloadShape<A>(0);
251 return kShape;
252}
253
254/// @brief Returns `A`'s payload fingerprint: `"<scheme>:<16 hex digits>"`.
255///
256/// Two payload types fingerprint identically exactly when
257/// `payloadShapeString()` renders them identically — i.e. when they have the
258/// same member names, at the same nesting positions, with the same JSON-shape
259/// tags. Computed once per type per process (a function-local static) and
260/// returned by reference; stamping it on a journal entry is a string copy of
261/// 18 bytes.
262///
263/// @tparam A Action payload type.
264/// @return Reference to the process-lifetime fingerprint string for `A`.
265template <typename A>
266[[nodiscard]] inline const std::string& payloadFingerprint() {
267 static const std::string kFingerprint = [] {
268 return std::to_string(kPayloadFingerprintScheme) + ':' +
269 detail::hex64(detail::fnv1a64(payloadShapeString<A>()));
270 }();
271 return kFingerprint;
272}
273
274} // namespace morph::model
Satisfied when T's tag also names an inner type to render inside it (a transparent wrapper).
Satisfied when T declares a stable shape tag.
const std::string & payloadShapeString()
Returns the human-readable shape rendering behind payloadFingerprint<A>().
constexpr std::uint32_t kPayloadFingerprintScheme
Version of the fingerprint algorithm, not of any one payload.
const std::string & payloadFingerprint()
Returns A's payload fingerprint: "<scheme>:<16 hex digits>".
Opt-in, compiler-independent shape tag for a type with a custom Glaze codec.
Declares a stable shape tag for a type T whose JSON shape payloadShape cannot decompose.