Skip to content

Commit 2576e52

Browse files
kariyclaude
andcommitted
refactor(db): implement macro-based versioning system for database types
Introduces a declarative macro system to simplify adding new database versions. The new `versioned_type!` macro automatically generates versioned enums with all necessary trait implementations, reducing boilerplate from hundreds of lines to just a few lines per version. Key changes: - Add `versioned_type!` macro for generating versioned enums with automatic Compress/Decompress implementations - Migrate existing V6/V7 transaction and block types to use the new macro system - Add comprehensive documentation and examples for adding new versions - Include tests to verify backward compatibility and round-trip serialization This makes database format changes much more manageable - adding a new version now only requires: 1. Incrementing CURRENT_DB_VERSION 2. Adding one line to the versioned type declaration 3. Defining only the types that changed 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 68de11e commit 2576e52

File tree

6 files changed

+587
-78
lines changed

6 files changed

+587
-78
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Database Versioning System
2+
3+
This module provides a macro-based versioning system for database types to ensure backward compatibility when the primitive types change.
4+
5+
## Problem
6+
7+
When primitive types in `katana-primitives` change, it can break the database format, making databases created with previous Katana versions incompatible. The versioning system ensures that:
8+
9+
1. The database is aware of format changes
10+
2. Old data can still be deserialized correctly
11+
3. New versions can be added with minimal boilerplate
12+
13+
## Solution: Macro-Based Versioning
14+
15+
The `versioned_type!` macro automatically generates versioned enums with all necessary trait implementations.
16+
17+
## Usage
18+
19+
### Basic Setup
20+
21+
To create a versioned type, use the `versioned_type!` macro:
22+
23+
```rust
24+
use crate::versioned_type;
25+
26+
versioned_type! {
27+
VersionedTx {
28+
V6 => v6::Tx,
29+
V7 => katana_primitives::transaction::Tx,
30+
}
31+
}
32+
```
33+
34+
This automatically generates:
35+
- The versioned enum with all variants
36+
- `From` trait implementations for conversions
37+
- `Compress` and `Decompress` implementations with fallback chain
38+
- Conversion to/from the latest version
39+
40+
### Adding a New Version
41+
42+
When the primitive types change in a breaking way:
43+
44+
1. **Update the database version** in `crates/storage/db/src/version.rs`:
45+
```rust
46+
pub const CURRENT_DB_VERSION: Version = Version::new(8); // Increment version
47+
```
48+
49+
2. **Create a new version module** (e.g., `v7.rs`) that contains only the types that changed:
50+
```rust
51+
// crates/storage/db/src/models/versioned/transaction/v7.rs
52+
use serde::{Deserialize, Serialize};
53+
54+
#[derive(Debug, Clone, Serialize, Deserialize)]
55+
pub struct NewFieldType {
56+
pub new_field: u64,
57+
// ... other fields
58+
}
59+
60+
// Implement conversion to the current primitive type
61+
impl From<NewFieldType> for katana_primitives::NewFieldType {
62+
fn from(v7: NewFieldType) -> Self {
63+
// Handle conversion
64+
}
65+
}
66+
```
67+
68+
3. **Update the versioned type declaration**:
69+
```rust
70+
versioned_type! {
71+
VersionedTx {
72+
V6 => v6::Tx,
73+
V7 => v7::Tx,
74+
V8 => katana_primitives::transaction::Tx, // Latest version
75+
}
76+
}
77+
```
78+
79+
That's it! The macro handles all the boilerplate.
80+
81+
## How It Works
82+
83+
### Serialization
84+
- New data is always serialized using the latest version variant
85+
- The versioned enum wrapper ensures version information is preserved
86+
87+
### Deserialization
88+
The `Decompress` implementation tries deserialization in this order:
89+
1. First, as the versioned enum itself (for data that was already versioned)
90+
2. Then, as the latest version type (for recent unversioned data)
91+
3. Finally, falling back through older versions in reverse order
92+
93+
This ensures maximum compatibility with both old and new data formats.
94+
95+
### Conversions
96+
- `From<LatestType>` creates a versioned enum with the latest variant
97+
- `From<VersionedType>` converts any version to the latest type
98+
- Each old version module must implement conversion to the current types
99+
100+
## Best Practices
101+
102+
1. **Only define changed types**: In version modules, only include types that actually changed
103+
2. **Preserve field order**: When possible, maintain the same field order for serialization compatibility
104+
3. **Document changes**: Add comments explaining what changed in each version
105+
4. **Test thoroughly**: Add tests for round-trip serialization and cross-version compatibility
106+
107+
## Example: Complete Version Addition
108+
109+
Here's a complete example of adding V8 when a field type changes:
110+
111+
```rust
112+
// 1. Create v7.rs with the old type definition
113+
// crates/storage/db/src/models/versioned/transaction/v7.rs
114+
mod v7 {
115+
use serde::{Deserialize, Serialize};
116+
117+
// This is how ResourceBounds looked in V7
118+
#[derive(Debug, Clone, Serialize, Deserialize)]
119+
pub struct ResourceBounds {
120+
pub max_amount: u64,
121+
pub max_price: u64,
122+
}
123+
124+
// Conversion to the new format
125+
impl From<ResourceBounds> for katana_primitives::ResourceBounds {
126+
fn from(v7: ResourceBounds) -> Self {
127+
Self {
128+
max_amount: v7.max_amount,
129+
max_price_per_unit: v7.max_price, // Field renamed
130+
}
131+
}
132+
}
133+
}
134+
135+
// 2. Update the versioned type
136+
versioned_type! {
137+
VersionedTx {
138+
V6 => v6::Tx,
139+
V7 => v7::Tx, // Now points to our v7 module
140+
V8 => katana_primitives::transaction::Tx, // Latest
141+
}
142+
}
143+
144+
// 3. Update CURRENT_DB_VERSION to 8
145+
```
146+
147+
## Testing
148+
149+
Always test version compatibility:
150+
151+
```rust
152+
#[test]
153+
fn test_v7_to_v8_migration() {
154+
// Create V7 data
155+
let v7_tx = v7::Tx { /* ... */ };
156+
let versioned = VersionedTx::V7(v7_tx);
157+
158+
// Convert to latest
159+
let v8_tx: Tx = versioned.into();
160+
161+
// Verify conversion worked correctly
162+
assert_eq!(v8_tx.field, expected_value);
163+
}
164+
```
165+
166+
## Benefits
167+
168+
This macro-based approach reduces version addition from hundreds of lines to just:
169+
1. One line in the versioned type declaration
170+
2. A module with only the types that changed
171+
3. Conversion implementations for those types
172+
173+
The rest is handled automatically!
Lines changed: 98 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,123 @@
1-
use katana_primitives::block::{self, Header};
2-
use serde::{Deserialize, Serialize};
1+
use katana_primitives::block::Header;
32

4-
use crate::codecs::{Compress, Decompress};
5-
use crate::error::CodecError;
3+
use crate::versioned_type;
64

75
mod v6;
86

9-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10-
#[cfg_attr(test, derive(::arbitrary::Arbitrary))]
11-
pub enum VersionedHeader {
12-
V6(v6::Header),
13-
V7(Header),
7+
// Use the macro to generate the versioned enum and all implementations
8+
versioned_type! {
9+
VersionedHeader {
10+
V6 => v6::Header,
11+
V7 => Header,
12+
}
1413
}
1514

15+
// Manually implement Default for VersionedHeader since Header has Default
1616
impl Default for VersionedHeader {
1717
fn default() -> Self {
1818
Self::V7(Default::default())
1919
}
2020
}
2121

22-
impl From<block::Header> for VersionedHeader {
23-
fn from(header: block::Header) -> Self {
24-
Self::V7(header)
22+
#[cfg(test)]
23+
mod tests {
24+
use katana_primitives::block::Header;
25+
26+
use super::{v6, VersionedHeader};
27+
use crate::codecs::{Compress, Decompress};
28+
29+
#[test]
30+
fn test_versioned_header_v6_to_v7_conversion() {
31+
let v6_header = v6::Header {
32+
parent_hash: Default::default(),
33+
number: 1,
34+
state_diff_commitment: Default::default(),
35+
transactions_commitment: Default::default(),
36+
receipts_commitment: Default::default(),
37+
events_commitment: Default::default(),
38+
state_root: Default::default(),
39+
transaction_count: 0,
40+
events_count: 0,
41+
state_diff_length: 0,
42+
timestamp: 0,
43+
sequencer_address: Default::default(),
44+
l1_gas_prices: Default::default(),
45+
l1_data_gas_prices: Default::default(),
46+
l1_da_mode: katana_primitives::da::L1DataAvailabilityMode::Blob,
47+
protocol_version: Default::default(),
48+
};
49+
50+
let versioned = VersionedHeader::V6(v6_header.clone());
51+
52+
// Convert to latest version
53+
let header: Header = versioned.into();
54+
assert_eq!(header.number, 1);
55+
// V6 doesn't have l2_gas_prices, so it should be set to MIN
56+
assert_eq!(header.l2_gas_prices, katana_primitives::block::GasPrices::MIN);
2557
}
26-
}
2758

28-
impl From<VersionedHeader> for block::Header {
29-
fn from(versioned: VersionedHeader) -> Self {
30-
match versioned {
31-
VersionedHeader::V7(header) => header,
32-
VersionedHeader::V6(header) => header.into(),
33-
}
59+
#[test]
60+
fn test_versioned_header_v7_from_conversion() {
61+
let header = Header { number: 42, ..Default::default() };
62+
let versioned: VersionedHeader = header.clone().into();
63+
64+
assert!(matches!(versioned, VersionedHeader::V7(_)));
65+
66+
// Convert back
67+
let recovered: Header = versioned.into();
68+
assert_eq!(header, recovered);
3469
}
35-
}
3670

37-
impl Compress for VersionedHeader {
38-
type Compressed = Vec<u8>;
39-
fn compress(self) -> Result<Self::Compressed, CodecError> {
40-
postcard::to_stdvec(&self).map_err(|e| CodecError::Compress(e.to_string()))
71+
#[test]
72+
fn test_versioned_header_compress_decompress() {
73+
let original = VersionedHeader::V7(Default::default());
74+
75+
// Compress
76+
let compressed = original.clone().compress().unwrap();
77+
78+
// Decompress
79+
let decompressed = VersionedHeader::decompress(&compressed).unwrap();
80+
81+
assert_eq!(original, decompressed);
4182
}
42-
}
4383

44-
impl Decompress for VersionedHeader {
45-
fn decompress<B: AsRef<[u8]>>(bytes: B) -> Result<Self, CodecError> {
46-
let bytes = bytes.as_ref();
84+
#[test]
85+
fn test_backward_compatibility_header_decompression() {
86+
// Create a V6 header
87+
let v6_header = v6::Header {
88+
parent_hash: Default::default(),
89+
number: 99,
90+
state_diff_commitment: Default::default(),
91+
transactions_commitment: Default::default(),
92+
receipts_commitment: Default::default(),
93+
events_commitment: Default::default(),
94+
state_root: Default::default(),
95+
transaction_count: 0,
96+
events_count: 0,
97+
state_diff_length: 0,
98+
timestamp: 0,
99+
sequencer_address: Default::default(),
100+
l1_gas_prices: Default::default(),
101+
l1_data_gas_prices: Default::default(),
102+
l1_da_mode: katana_primitives::da::L1DataAvailabilityMode::Blob,
103+
protocol_version: Default::default(),
104+
};
47105

48-
if let Ok(header) = postcard::from_bytes::<Self>(bytes) {
49-
return Ok(header);
50-
}
106+
// Serialize it directly (simulating old database data)
107+
let v6_bytes = postcard::to_stdvec(&v6_header).unwrap();
51108

52-
// Try deserializing as V7 first, then fall back to V6
53-
if let Ok(header) = postcard::from_bytes::<Header>(bytes) {
54-
return Ok(VersionedHeader::V7(header));
55-
}
109+
// Should be able to decompress as VersionedHeader
110+
let versioned = VersionedHeader::decompress(&v6_bytes).unwrap();
56111

57-
if let Ok(header) = postcard::from_bytes::<v6::Header>(bytes) {
58-
return Ok(VersionedHeader::V6(header));
112+
match versioned {
113+
VersionedHeader::V6(h) => assert_eq!(h.number, 99),
114+
_ => panic!("Expected V6 header"),
59115
}
116+
}
60117

61-
Err(CodecError::Decompress("failed to deserialize header: unknown format".to_string()))
118+
#[test]
119+
fn test_default_uses_latest_version() {
120+
let versioned = VersionedHeader::default();
121+
assert!(matches!(versioned, VersionedHeader::V7(_)));
62122
}
63123
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Example: How to add version V8 when primitive types change
2+
//
3+
// This file demonstrates the minimal code needed to add a new database version.
4+
// Rename this to v8.rs and uncomment when actually implementing V8.
5+
6+
/*
7+
// Step 1: Define only the types that changed from V7 to V8
8+
use katana_primitives::{Felt, fee};
9+
use serde::{Deserialize, Serialize};
10+
11+
// Example: ResourceBoundsMapping gained a new field in V8
12+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13+
pub struct ResourceBoundsMapping {
14+
pub l1_gas: fee::ResourceBounds,
15+
pub l2_gas: fee::ResourceBounds,
16+
pub l3_gas: fee::ResourceBounds, // New in V8!
17+
}
18+
19+
// Provide conversion to the current primitive type
20+
impl From<ResourceBoundsMapping> for fee::ResourceBoundsMapping {
21+
fn from(v8: ResourceBoundsMapping) -> Self {
22+
// Handle the conversion appropriately
23+
// This is just an example - adapt to your actual types
24+
Self::All(v8.l1_gas, v8.l2_gas, v8.l3_gas)
25+
}
26+
}
27+
28+
// If the whole transaction structure changed, define it here
29+
// Otherwise, you can reuse types from katana_primitives for unchanged parts
30+
use katana_primitives::transaction::{InvokeTxV0, InvokeTxV1, /* ... */};
31+
32+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33+
pub struct InvokeTxV3 {
34+
// ... fields with the new ResourceBoundsMapping
35+
pub resource_bounds: ResourceBoundsMapping,
36+
// ... other fields
37+
}
38+
39+
// ... Rest of the type definitions that changed
40+
41+
// Step 2: Update the version declaration in the parent module:
42+
// In transaction/mod.rs:
43+
// versioned_type! {
44+
// VersionedTx {
45+
// V6 => v6::Tx,
46+
// V7 => v7::Tx,
47+
// V8 => v8::Tx, // Add this line
48+
// V9 => katana_primitives::transaction::Tx, // Latest is now V9
49+
// }
50+
// }
51+
52+
// Step 3: Update CURRENT_DB_VERSION in crates/storage/db/src/version.rs:
53+
// pub const CURRENT_DB_VERSION: Version = Version::new(9);
54+
55+
// That's it! The macro handles everything else automatically.
56+
*/

0 commit comments

Comments
 (0)