Repository: serverpod/relic
Pull request #42: fix: Ensure cache is updated immediately
Author: login: nielsenko
Created: 2025-04-03T17:14:40Z | Merged: 2025-04-07T06:32:00Z

## Description

Previously reading a value through a header accessor inside an update callback would in some cases be stale. 
Example:
```dart
    final headers = Headers.build((mh) {
      mh.anInt = 42;
      expect(mh.anInt, 42); // first read goes well, since cache hangs of mh, but was empty for anInt
      mh.anInt = 1202;
      expect(mh.anInt, 1202); // <-- fail: second read would fail, cache still 42
    });
    // here cache is correct
    expect(headers.anInt, 1202);
```
- test: Add failing tests
- fix: Use raw-value as expando key, and update cache on set value

## Related Issues

Fix: #45 
 
## Pre-Launch Checklist
Please ensure that your PR meets the following requirements before submitting:

- [x] This update focuses on a single feature or bug fix. (For multiple fixes, please submit separate PRs.)
- [x] I have read and followed the [Dart Style Guide](https://dart.dev/guides/language/effective-dart/style) and formatted the code using [dart format](https://dart.dev/tools/dart-format).
- [x] I have referenced at least one issue this PR fixes or is related to.
- [ ] I have updated/added relevant documentation (doc comments with `///`), ensuring consistency with existing project documentation.
- [x] I have added new tests to verify the changes.
- [x] All existing and new tests pass successfully.
- [x] I have documented any breaking changes below.

## Breaking Changes
- [ ] Includes breaking changes.
- [x] No breaking changes.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **Refactor**
  - Enhanced header processing with improved caching strategies for better performance and reliability.
  
- **Tests**
  - Added test cases to ensure the caching logic works as intended, reducing redundant processing when header data remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/lib/src/headers/header_accessor.dart b/lib/src/headers/header_accessor.dart
index f516dee9..4dd21ded 100644
--- a/lib/src/headers/header_accessor.dart
+++ b/lib/src/headers/header_accessor.dart
@@ -28,7 +28,8 @@ final class HeaderAccessor<T extends Object> {
   /// avoiding repeated parsing of the same raw header value.
   ///
   /// This is used internally by [getValueFrom] to implement the caching mechanism.
-  Expando get _cache => _caches.putIfAbsent(this, Expando.new);
+  Expando<T> get _cache =>
+      _caches.putIfAbsent(this, Expando<T>.new) as Expando<T>;
 
   /// The [key] is the name of the HTTP header.
   final String key;
@@ -66,14 +67,14 @@ final class HeaderAccessor<T extends Object> {
     T? Function(Exception)? orElse,
   }) {
     try {
-      var result = _cache[external] as T?;
-      if (result != null) return result; // found in cache
-
       final raw = external[key];
       if (raw == null) return null; // nothing to decode
 
+      var result = _cache[raw];
+      if (result != null) return result; // found in cache
+
       result = decode(raw);
-      _cache[external] = result;
+      _cache[raw] = result;
       return result;
     } on Exception catch (e) {
       if (orElse == null) _throwException(e, key: key);
@@ -93,8 +94,10 @@ final class HeaderAccessor<T extends Object> {
       Header((accessor: this, headers: external));
 
   /// [null] implies removing the header
-  void setValueOn(MutableHeaders external, T? value) =>
-      _setValue(external, key, value);
+  void setValueOn(MutableHeaders external, T? value) {
+    final raw = _setValue(external, key, value);
+    if (raw != null) _cache[raw] = value; // prime cache immediately
+  }
 
   /// Removes the header from the given [external] headers.
   void removeFrom(MutableHeaders external) => external.remove(key);
@@ -186,16 +189,16 @@ Never _throwException(
   );
 }
 
-void _setValue<T>(MutableHeaders headers, String key, T value) {
+Iterable<String>? _setValue<T>(MutableHeaders headers, String key, T value) {
   if (value == null) {
     headers.remove(key);
-  } else {
-    headers[key] = switch (value) {
-      String s => [s],
-      DateTime d => [formatHttpDate(d)],
-      TypedHeader t => [t.toHeaderString()],
-      Iterable<String> i => i,
-      Object o => [o.toString()],
-    };
+    return null;
   }
+  return headers[key] = switch (value) {
+    String s => [s],
+    DateTime d => [formatHttpDate(d)],
+    TypedHeader t => [t.toHeaderString()],
+    Iterable<String> i => i,
+    Object o => [o.toString()],
+  };
 }
diff --git a/test/headers/headers_accessor_test.dart b/test/headers/headers_accessor_test.dart
index 9c18f02b..7bf409f8 100644
--- a/test/headers/headers_accessor_test.dart
+++ b/test/headers/headers_accessor_test.dart
@@ -7,7 +7,7 @@ import 'headers_test_utils.dart';
 
 const _anInt = HeaderAccessor<int>(
   'anInt',
-  HeaderDecoderSingle(int.parse),
+  HeaderDecoderSingle(parseInt),
 );
 
 const _someStrings = HeaderAccessor<List<String>>(
@@ -21,6 +21,7 @@ extension on Headers {
 }
 
 extension on MutableHeaders {
+  int? get anInt => _anInt[this]();
   set anInt(int? value) => _anInt[this].set(value);
 }
 
@@ -145,4 +146,77 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  group('Given a header accessor ', () {
+    late HeaderAccessor<int> accessor;
+    int count = 0;
+
+    setUp(() {
+      count = 0;
+      // This header accessor is not const constructed since we want
+      // a non-const decoder that increment local the local variable count
+      // whenever called. This is not good practice, but useful in the test!
+      accessor = HeaderAccessor('tmp', HeaderDecoderSingle((i) {
+        ++count;
+        return int.parse(i);
+      }));
+    });
+
+    test(
+        'when reading the value from a headers collection twice '
+        'then decode is only called once', () {
+      final headers = Headers.fromMap({
+        accessor.key: ['1202']
+      });
+
+      expect(accessor[headers].value, 1202);
+      expect(count, 1);
+
+      expect(accessor[headers].value, 1202);
+      expect(count, 1);
+    });
+
+    test(
+        'when reading the value from a headers collection '
+        'where the raw value is updated directly '
+        'then decode is only called once per update', () {
+      final headers = Headers.fromMap({
+        accessor.key: ['1202']
+      });
+      final headers2 = headers.transform((mh) => mh[accessor.key] = ['42']);
+
+      expect(accessor[headers].value, 1202);
+      expect(count, 1);
+
+      expect(accessor[headers2].value, 42);
+      expect(count, 2);
+
+      expect(accessor[headers].value, 1202);
+      expect(accessor[headers2].value, 42);
+      expect(count, 2);
+    });
+
+    test(
+        'when reading the value from a headers collection '
+        'where the encoded value is set via the accessor '
+        'then decode is not needed at all', () {
+      final headers = Headers.build((mh) => accessor[mh].set(51));
+      expect(accessor[headers].value, 51);
+      expect(count, 0);
+    });
+  });
 }


Reviews:
user: login: SandPod
body: Nice fix! A couple of questions to understand the implementation and as always, comments around test structure 🔧 ⚒️ 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T05:59:48Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T06:30:56Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T06:33:28Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T06:39:15Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T06:41:41Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T06:45:16Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T06:02:51Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T06:03:07Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T06:03:43Z
user: login: nielsenko
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-07T06:14:21Z
user: login: SandPod
body: LGTM! 🚀

Last non blocking suggestion to better reflect the "Given" of the test.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T06:29:42Z

Review comments:
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
path: test/headers/headers_accessor_test.dart
user: login: SandPod
body: Suggestion(NIT): Break this out into a separate test repeating the same scenario with a matching description that differentiates the tests.
created_at: 2025-04-04T05:42:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,20 +7,27 @@ import 'headers_test_utils.dart';
 
 const _anInt = HeaderAccessor<int>(
   'anInt',
-  HeaderDecoderSingle(int.parse),
+  HeaderDecoderSingle(intParseAndCount),
 );
 
 const _someStrings = HeaderAccessor<List<String>>(
   'someStrings',
   HeaderDecoderMulti(parseStringList),
 );
 
+var _count = 0;
path: test/headers/headers_accessor_test.dart
user: login: SandPod
body: Suggestion: Make this count local inside of a single test.

Because this is a shared variable, we can't add another test without taking into account previous test runs. Please isolate this value so that new tests can rely on this starting out on 0.
created_at: 2025-04-04T05:44:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,20 +7,27 @@ import 'headers_test_utils.dart';
 
 const _anInt = HeaderAccessor<int>(
   'anInt',
-  HeaderDecoderSingle(int.parse),
+  HeaderDecoderSingle(intParseAndCount),
 );
 
 const _someStrings = HeaderAccessor<List<String>>(
   'someStrings',
   HeaderDecoderMulti(parseStringList),
 );
 
+var _count = 0;
path: test/headers/headers_accessor_test.dart
user: login: SandPod
body: (This might mean that `_anInt` needs to be instantiated for each test.)
created_at: 2025-04-04T05:47:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  test(
+      'Given a header accessor '
+      'when reading the value '
+      'then decode is only called if raw value changed', () {
+    final headers = Headers.fromMap({
+      'anInt': ['102']
+    });
+
+    final offset = _count;
+    int decodeCount() => _count - offset; // compensate for previous calls
path: test/headers/headers_accessor_test.dart
user: login: SandPod
body: Suggestion: This should not be required in the test logic.
created_at: 2025-04-04T05:48:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  test(
+      'Given a header accessor '
+      'when reading the value '
+      'then decode is only called if raw value changed', () {
+    final headers = Headers.fromMap({
+      'anInt': ['102']
+    });
+
+    final offset = _count;
+    int decodeCount() => _count - offset; // compensate for previous calls
+
+    expect(headers.anInt, 102); // read first time
+    expect(decodeCount(), 1); // one call to parse
+
+    expect(headers.anInt, 102); // read again
+    expect(decodeCount(), 1); // still only one call to parse, due to cache
path: test/headers/headers_accessor_test.dart
user: login: SandPod
body: Suggestion: Break this test apart into a separate test. (Marking only a subset of lines but this applies to the whole test).

Please isolate the scenarios here so that we don't have this longer widely scoped test. Ideally, there should be no need for comments when a good description is supplied.

This specific test here is basically reading the value twice should result in a single call to decode count. The expect on the `read` value might not even be needed since that is not what is under test? We validate that in the test above right?
created_at: 2025-04-04T05:50:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -93,8 +94,10 @@ final class HeaderAccessor<T extends Object> {
       Header((accessor: this, headers: external));
 
   /// [null] implies removing the header
-  void setValueOn(MutableHeaders external, T? value) =>
-      _setValue(external, key, value);
+  void setValueOn(MutableHeaders external, T? value) {
+    final raw = _setValue(external, key, value);
+    if (raw != null) _cache[raw] = value; // prime cache immediately
path: lib/src/headers/header_accessor.dart
user: login: SandPod
body: Question: Shouldn't we "prime cache" also when we set the value to `null` (Meaning we remove the value right?)
created_at: 2025-04-04T05:56:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -186,16 +189,16 @@ Never _throwException(
   );
 }
 
-void _setValue<T>(MutableHeaders headers, String key, T value) {
+Iterable<String>? _setValue<T>(MutableHeaders headers, String key, T value) {
   if (value == null) {
     headers.remove(key);
-  } else {
-    headers[key] = switch (value) {
-      String s => [s],
-      DateTime d => [formatHttpDate(d)],
-      TypedHeader t => [t.toHeaderString()],
-      Iterable<String> i => i,
-      Object o => [o.toString()],
-    };
+    return null;
   }
+  return headers[key] = switch (value) {
+    String s => [s],
+    DateTime d => [formatHttpDate(d)],
+    TypedHeader t => [t.toHeaderString()],
+    Iterable<String> i => i,
+    Object o => [o.toString()],
path: lib/src/headers/header_accessor.dart
user: login: SandPod
body: Question(For my knowledge): Are we limited to only supporting these value types? Any concern about something we do not cover here?
created_at: 2025-04-04T05:58:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  test(
+      'Given a header accessor '
+      'when reading the value '
+      'then decode is only called if raw value changed', () {
+    final headers = Headers.fromMap({
+      'anInt': ['102']
+    });
+
+    final offset = _count;
+    int decodeCount() => _count - offset; // compensate for previous calls
+
+    expect(headers.anInt, 102); // read first time
+    expect(decodeCount(), 1); // one call to parse
+
+    expect(headers.anInt, 102); // read again
+    expect(decodeCount(), 1); // still only one call to parse, due to cache
path: test/headers/headers_accessor_test.dart
user: login: nielsenko
body: This test is for `Headers` the above is for `MutableHeaders`. Also, unlike the above test, it tests that multiple `Headers` objects, despite sharing state, still get the cached values right with a minimal number of decode calls.

This is not a good candidate for splitting as each expect depends on what happened before.
created_at: 2025-04-04T06:30:56Z
author_association: COLLABORATOR
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  test(
+      'Given a header accessor '
+      'when reading the value '
+      'then decode is only called if raw value changed', () {
+    final headers = Headers.fromMap({
+      'anInt': ['102']
+    });
+
+    final offset = _count;
+    int decodeCount() => _count - offset; // compensate for previous calls
path: test/headers/headers_accessor_test.dart
user: login: nielsenko
body: Yes, if I want to use a const constructed `HeaderAccessor`. Note that internal to a test files, tests are never run in parallel, so this is stable.
created_at: 2025-04-04T06:33:28Z
author_association: COLLABORATOR
diff_hunk: @@ -93,8 +94,10 @@ final class HeaderAccessor<T extends Object> {
       Header((accessor: this, headers: external));
 
   /// [null] implies removing the header
-  void setValueOn(MutableHeaders external, T? value) =>
-      _setValue(external, key, value);
+  void setValueOn(MutableHeaders external, T? value) {
+    final raw = _setValue(external, key, value);
+    if (raw != null) _cache[raw] = value; // prime cache immediately
path: lib/src/headers/header_accessor.dart
user: login: nielsenko
body: No need, the value of an expando dies together with the key, when the GC comes by. If somebody for some reason holds on to the previous raw value somewhere still, then there is no harm in caching the decoded value for that. 
created_at: 2025-04-04T06:39:14Z
author_association: COLLABORATOR
diff_hunk: @@ -186,16 +189,16 @@ Never _throwException(
   );
 }
 
-void _setValue<T>(MutableHeaders headers, String key, T value) {
+Iterable<String>? _setValue<T>(MutableHeaders headers, String key, T value) {
   if (value == null) {
     headers.remove(key);
-  } else {
-    headers[key] = switch (value) {
-      String s => [s],
-      DateTime d => [formatHttpDate(d)],
-      TypedHeader t => [t.toHeaderString()],
-      Iterable<String> i => i,
-      Object o => [o.toString()],
-    };
+    return null;
   }
+  return headers[key] = switch (value) {
+    String s => [s],
+    DateTime d => [formatHttpDate(d)],
+    TypedHeader t => [t.toHeaderString()],
+    Iterable<String> i => i,
+    Object o => [o.toString()],
path: lib/src/headers/header_accessor.dart
user: login: nielsenko
body: Yes, currently custom types will have to implement `TypedHeader` or do _the right thing_ in `.toString()`. This was what I was referring to when I talked about replacing `HeaderDecoder` with a `HeaderCodec` that also holds a _encoder_.
created_at: 2025-04-04T06:41:41Z
author_association: COLLABORATOR
diff_hunk: @@ -7,20 +7,27 @@ import 'headers_test_utils.dart';
 
 const _anInt = HeaderAccessor<int>(
   'anInt',
-  HeaderDecoderSingle(int.parse),
+  HeaderDecoderSingle(intParseAndCount),
 );
 
 const _someStrings = HeaderAccessor<List<String>>(
   'someStrings',
   HeaderDecoderMulti(parseStringList),
 );
 
+var _count = 0;
path: test/headers/headers_accessor_test.dart
user: login: nielsenko
body: Yes, you can add as many tests as you want, both before and after. This test will not be affected as it will always have a given `offset` on entry that we compensate for. Tests are never run in parallel inside a test file.
created_at: 2025-04-04T06:45:16Z
author_association: COLLABORATOR
diff_hunk: @@ -7,20 +7,27 @@ import 'headers_test_utils.dart';
 
 const _anInt = HeaderAccessor<int>(
   'anInt',
-  HeaderDecoderSingle(int.parse),
+  HeaderDecoderSingle(intParseAndCount),
 );
 
 const _someStrings = HeaderAccessor<List<String>>(
   'someStrings',
   HeaderDecoderMulti(parseStringList),
 );
 
+var _count = 0;
path: test/headers/headers_accessor_test.dart
user: login: nielsenko
body: I have refactored the tests to construct a new `HeaderAccessor` per test, and track count in a local variable. 
created_at: 2025-04-07T06:02:51Z
author_association: COLLABORATOR
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  test(
+      'Given a header accessor '
+      'when reading the value '
+      'then decode is only called if raw value changed', () {
+    final headers = Headers.fromMap({
+      'anInt': ['102']
+    });
+
+    final offset = _count;
+    int decodeCount() => _count - offset; // compensate for previous calls
path: test/headers/headers_accessor_test.dart
user: login: nielsenko
body: I have refactored the tests to construct a new HeaderAccessor per test, and track count in a local variable.
created_at: 2025-04-07T06:03:07Z
author_association: COLLABORATOR
diff_hunk: @@ -145,4 +152,59 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  test(
+      'Given a header accessor '
+      'when reading the value '
+      'then decode is only called if raw value changed', () {
+    final headers = Headers.fromMap({
+      'anInt': ['102']
+    });
+
+    final offset = _count;
+    int decodeCount() => _count - offset; // compensate for previous calls
+
+    expect(headers.anInt, 102); // read first time
+    expect(decodeCount(), 1); // one call to parse
+
+    expect(headers.anInt, 102); // read again
+    expect(decodeCount(), 1); // still only one call to parse, due to cache
path: test/headers/headers_accessor_test.dart
user: login: nielsenko
body: I have split this test-case into 3
created_at: 2025-04-07T06:03:43Z
author_association: COLLABORATOR
diff_hunk: @@ -186,16 +189,16 @@ Never _throwException(
   );
 }
 
-void _setValue<T>(MutableHeaders headers, String key, T value) {
+Iterable<String>? _setValue<T>(MutableHeaders headers, String key, T value) {
   if (value == null) {
     headers.remove(key);
-  } else {
-    headers[key] = switch (value) {
-      String s => [s],
-      DateTime d => [formatHttpDate(d)],
-      TypedHeader t => [t.toHeaderString()],
-      Iterable<String> i => i,
-      Object o => [o.toString()],
-    };
+    return null;
   }
+  return headers[key] = switch (value) {
+    String s => [s],
+    DateTime d => [formatHttpDate(d)],
+    TypedHeader t => [t.toHeaderString()],
+    Iterable<String> i => i,
+    Object o => [o.toString()],
path: lib/src/headers/header_accessor.dart
user: login: nielsenko
body: see #44 and #43
created_at: 2025-04-07T06:14:21Z
author_association: COLLABORATOR
diff_hunk: @@ -145,4 +146,77 @@ void main() {
     // but this uncommon approach will not fail until runtime:
     expect(() => header.set(null), throwsA(isA<TypeError>()));
   });
+
+  test(
+      'Given a header accessor '
+      'when updating a value '
+      'then you can read the value immediately', () {
+    Headers.build((mh) {
+      mh.anInt = 42;
+      expect(mh.anInt, 42);
+      mh.anInt = 1202;
+      expect(mh.anInt, 1202);
+      mh['anInt'] = ['51']; // also for raw value updates
+      expect(mh.anInt, 51);
+    });
+  });
+
+  group('Given a header accessor ', () {
path: test/headers/headers_accessor_test.dart
user: login: SandPod
body: Suggestion(NIT): Change to `Given a header accessor that counts decodes`
created_at: 2025-04-07T06:29:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-03T17:14:48Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The changes update the caching mechanism in the header accessor component by introducing explicit type parameters and modifying caching keys. The `_cache` getter now returns `Expando<T>` instead of `Expando`, and the methods for setting and retrieving header values have been adjusted to better prime and query the cache. In addition, a new function wrapping `int.parse` has been added along with a counter variable to track the number of parse operations. Test cases have been introduced to verify that the cache is used appropriately, only re-parsing when the raw header value changes.

## Changes

| File                                           | Changes Summary |
|------------------------------------------------|-----------------|
| `lib/src/headers/…/header_accessor.dart`       | - Updated `_cache` getter to return `Expando<T>` for type safety. <br>- Modified `getValueFrom` to use the `raw` variable as the cache key. <br>- Updated `setValueOn` to call `_setValue` and cache its non-null result immediately. <br>- Changed `_setValue` return type from `void` to `Iterable<String>?`. |
| `test/headers/…/headers_accessor_test.dart`    | - Added a new function `intParseAndCount` which wraps `int.parse`, incrementing a global `_count` with each call. <br>- Declared a new variable `_count` to track parse calls. <br>- Introduced tests to ensure header values are correctly updated and that the parsing function is invoked only when the raw value changes. |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    autonumber
    participant Caller as Caller
    participant HA as HeaderAccessor
    participant Cache as Cache (Expando<T>)
    participant Headers as MutableHeaders

    Caller->>HA: setValueOn(Headers, value)
    HA->>HA: _setValue(Headers, key, value)
    HA->>Cache: Cache result if non-null
    Caller->>HA: getValueFrom(Headers)
    HA->>Cache: Retrieve cached value using raw key
    Cache-->>HA: Return cached header value
    HA-->>Caller: Return header value
```

```mermaid
sequenceDiagram
    autonumber
    participant Test as Test Case
    participant HA as HeaderAccessor
    participant Parser as intParseAndCount

    Test->>HA: Request header value (_anInt)
    HA->>Parser: intParseAndCount(rawValue)
    Parser-->>HA: Return parsed integer (increment _count)
    HA-->>Test: Return header value
```

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=serverpod/relic&utm_content=42):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-03T17:16:17Z
body: ## [Codecov](https://app.codecov.io/gh/serverpod/relic/pull/42?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=serverpod) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 78.53%. Comparing base [(`b0c6140`)](https://app.codecov.io/gh/serverpod/relic/commit/b0c614044605a74875f2117057967a83095bbb87?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=serverpod) to head [(`f64efca`)](https://app.codecov.io/gh/serverpod/relic/commit/f64efca3638ce898651667817431e577c76066be?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=serverpod).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #42      +/-   ##
==========================================
+ Coverage   78.43%   78.53%   +0.09%     
==========================================
  Files          70       70              
  Lines        2272     2273       +1     
  Branches     1181     1181              
==========================================
+ Hits         1782     1785       +3     
+ Misses        490      488       -2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/serverpod/relic/pull/42?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=serverpod).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=serverpod).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: nielsenko
created_at: 2025-04-03T17:26:12Z
body: @coderabbitai review
author_association: COLLABORATOR
user: login: coderabbitai[bot]
created_at: 2025-04-03T17:26:18Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>:white_check_mark: Actions performed</summary>

Review triggered.

> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

</details>
author_association: CONTRIBUTOR