Skip to content

feat(registry): typed entity history API (registry.history) #109

Description

@alejandroGM0

Summary

Add a typed entity history API on Registry that wraps ChaincodeStub.getHistoryForKey with entity deserialization, primary-key resolution, and a small immutable record. Purely additive on top of main: no changes to HypernateContext, middlewares, annotations, or StubMiddleware API.

Motivation

ChaincodeStub.getHistoryForKey returns QueryResultsIterator<KeyModification> keyed by raw composite key strings, and each KeyModification carries raw byte[]. Today every contract that needs audit/history has to:

  1. Rebuild the composite key from entity metadata by hand.
  2. Iterate QueryResultsIterator<KeyModification>.
  3. UTF-8 + JSON-deserialize each non-deleted payload into a DTO.
  4. Track isDeleted() and unwrap Instant / txId manually.

This is the same shape as mustRead/tryRead/readAll — but history is the one CRUD-adjacent operation that has no entity-aware wrapper. Logging (#34/#105) already covers observability of getHistoryForKey; what is missing is a developer-facing API.

Proposal

New public method on Registry:

public <T> List<EntityHistory<T>> history(Class<T> clazz, Object... keyParts);

New record in hu.bme.mit.ftsrg.hypernate.registry:

public record EntityHistory<T>(
    String txId,
    Instant timestamp,
    boolean deleted,
    T value
) {}

Developer experience:

List<EntityHistory<Asset>> history = registry.history(Asset.class, assetId);

for (EntityHistory<Asset> entry : history) {
    if (entry.deleted()) {
        log.info("{} deleted at {} by tx {}", assetId, entry.timestamp(), entry.txId());
    } else {
        log.info("{} modified at {}: {}", assetId, entry.timestamp(), entry.value());
    }
}

Behavior

Situation Result
clazz not annotated with @PrimaryKey MissingPrimaryKeysException
keyParts.length != primaryKeyCount IllegalArgumentException
Key has never been written Empty List (not an error)
Write modification EntityHistory(txId, ts, false, deserialized)
Delete modification EntityHistory(txId, ts, true, null)
Malformed JSON on a historical payload SerializationException propagates

A single flat method is proposed instead of mustHistory / tryHistory because, unlike read, an empty history is a valid, non-exceptional state — a fresh key, or a key that never existed, returns an empty iterator from Fabric. This matches the shape of readAll.

Design

Package layout

lib/src/main/java/hu/bme/mit/ftsrg/hypernate/registry/
├── EntityHistory.java   # NEW
└── Registry.java        # +1 public method, +1 private helper in EntityUtil
lib/src/test/java/hu/bme/mit/ftsrg/hypernate/
└── RegistryTest.java    # +1 assertion in the existing "do anything" test, +1 @Nested class

Everything stays under registry/, consistent with the existing exception siblings (EntityNotFoundException, EntityExistsException, SerializationException).

Reuse

Key generation, type resolution, and JSON deserialization reuse the existing private EntityUtil:

  • EntityUtil.getType(clazz) — same as mustRead.
  • EntityUtil.mapKeyPartsToString(clazz, keyParts) — same as mustRead.
  • EntityUtil.fromBuffer(buffer, clazz) — same as mustRead and readAll.

A small new helper maps KeyModification to EntityHistory<T>:

<T> EntityHistory<T> toEntityHistory(final KeyModification mod, final Class<T> clazz) {
  final boolean deleted = mod.isDeleted();
  final T value = deleted ? null : fromBuffer(mod.getValue(), clazz);
  return new EntityHistory<>(mod.getTxId(), mod.getTimestamp(), deleted, value);
}

The iteration follows the same StreamSupport + Collectors.toList() shape as readAll.

Errors

Errors reuse the existing hierarchy rooted at DataAccessException / HypernateException. No new exception types are introduced — this feature does not add a genuinely new failure mode beyond what mustRead already distinguishes.

Logging

No new logging plumbing required.

Middleware interaction

None required:

  • CachingStubMiddleware (Generalize WriteBackCachedChaincodeStubMiddleware #35 redesign) already treats getHistoryForKey as pass-through — history reflects committed block history, not in-flight cache state.
  • No endorsement / private-data / transient-data code paths are touched.

Compatibility

  • Fully additive. No existing public signature changes.
  • No new dependencies in build.gradle.
  • No modification to HypernateContext, HypernateContract, StubMiddleware, util.JSON, or any existing exception.
  • One new public record (EntityHistory) + one new public method on Registry.

Tests

Follows the existing RegistryTest conventions (@DisplayNameGeneration(ReplaceUnderscores.class), nested when_<method> classes, BDDMockito given/then):

  • Extend given_entity_without_primary_keys_when_doing_anything_then_throws_exception with a history assertion.
  • New @Nested class when_history:
    • given_insufficient_key_parts_then_throw_illegal_argument
    • given_empty_history_then_return_empty_list
    • given_write_modification_then_return_populated_entry
    • given_delete_modification_then_value_is_null_and_deleted_true
    • given_malformed_payload_then_throw_serialization

Summary of changes

Action Target
New registry/EntityHistory.java (record: txId, timestamp, deleted, value)
Update registry/Registry.java — add history(Class, Object...) and EntityUtil#toEntityHistory
Update test/.../RegistryTest.java — extend the "do anything" test and add when_history nested class

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageThe topic needs further inspection/discussion by maintainers

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions