Skip to content

Commit 3ddc959

Browse files
committed
docs: Add comprehensive schema versioning & migration guides
Added complete documentation for schema versioning and evolution: Guides: - Schema Versioning Guide - semantic versioning, breaking vs non-breaking changes, deprecation strategies, version tracking with #[version] - Schema Migrations Guide - 4 migration strategies (rewrite, dual-schema, lazy, version discriminator), testing, rollback plans Examples: - Adding Optional Field - safe non-breaking change pattern - Changing Field Type - breaking change with migration instruction - Deprecating a Field - 3-phase deprecation with dual-field pattern Updates: - Updated astro.config.mjs navigation with new guides and examples - All cross-references verified and links working Resolves #1
1 parent 8203d42 commit 3ddc959

File tree

6 files changed

+2620
-10
lines changed

6 files changed

+2620
-10
lines changed

astro.config.mjs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export default defineConfig({
6262
label: 'Guides',
6363
items: [
6464
{ label: 'Using npm Package', slug: 'guides/npm-package', badge: { text: 'New', variant: 'success' } },
65+
{ label: 'Schema Versioning', slug: 'guides/versioning', badge: { text: 'New', variant: 'success' } },
66+
{ label: 'Schema Migrations', slug: 'guides/schema-migrations', badge: { text: 'New', variant: 'success' } },
6567
{ label: 'Migration Guide', slug: 'guides/migration-guide' },
6668
{ label: 'Vision', slug: 'guides/vision', badge: { text: 'New', variant: 'success' } },
6769
{ label: 'Future', slug: 'guides/future', badge: { text: 'New', variant: 'success' } },
@@ -73,16 +75,14 @@ export default defineConfig({
7375
{ label: 'IntelliJ IDEA', slug: 'editors/intellij', badge: { text: 'New', variant: 'success' } },
7476
],
7577
},
76-
// {
77-
// label: 'Examples',
78-
// items: [
79-
// { label: 'Gaming Platform', slug: 'examples/gaming' },
80-
// { label: 'NFT Marketplace', slug: 'examples/nft-marketplace' },
81-
// { label: 'DeFi Staking', slug: 'examples/defi-staking' },
82-
// { label: 'DAO Governance', slug: 'examples/dao-governance' },
83-
// { label: 'Token Vesting', slug: 'examples/token-vesting' },
84-
// ],
85-
// },
78+
{
79+
label: 'Examples',
80+
items: [
81+
{ label: 'Adding Optional Field', slug: 'examples/versioning/adding-optional-field', badge: { text: 'New', variant: 'success' } },
82+
{ label: 'Changing Field Type', slug: 'examples/versioning/changing-field-type', badge: { text: 'New', variant: 'success' } },
83+
{ label: 'Deprecating a Field', slug: 'examples/versioning/deprecating-field', badge: { text: 'New', variant: 'success' } },
84+
],
85+
},
8686
{
8787
label: 'Changelog',
8888
link: '/changelog/',
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
---
2+
title: 'Example: Adding Optional Field'
3+
description: Safe, non-breaking schema change by adding an optional field at the end
4+
---
5+
6+
This example shows how to safely add a new field to your schema without breaking existing on-chain accounts.
7+
8+
**Scenario:** Add a `nickname` field to `PlayerAccount` without requiring data migration.
9+
10+
---
11+
12+
## Initial Schema (v1.0.0)
13+
14+
**File:** `player_v1.lumos`
15+
16+
```rust
17+
#[solana]
18+
#[account]
19+
#[version("1.0.0")]
20+
struct PlayerAccount {
21+
wallet: PublicKey,
22+
level: u16,
23+
experience: u64,
24+
}
25+
```
26+
27+
**Generated Rust:**
28+
```rust
29+
use anchor_lang::prelude::*;
30+
31+
#[account]
32+
pub struct PlayerAccount {
33+
pub wallet: Pubkey,
34+
pub level: u16,
35+
pub experience: u64,
36+
}
37+
38+
// Account size: 8 (discriminator) + 32 + 2 + 8 = 50 bytes
39+
```
40+
41+
**Generated TypeScript:**
42+
```typescript
43+
import { PublicKey } from '@solana/web3.js';
44+
import * as borsh from '@coral-xyz/borsh';
45+
46+
export interface PlayerAccount {
47+
wallet: PublicKey;
48+
level: number;
49+
experience: number;
50+
}
51+
52+
export const PlayerAccountBorshSchema = borsh.struct([
53+
borsh.publicKey('wallet'),
54+
borsh.u16('level'),
55+
borsh.u64('experience'),
56+
]);
57+
```
58+
59+
---
60+
61+
## Updated Schema (v1.1.0)
62+
63+
**File:** `player_v1.1.lumos`
64+
65+
```rust
66+
#[solana]
67+
#[account]
68+
#[version("1.1.0")]
69+
struct PlayerAccount {
70+
wallet: PublicKey,
71+
level: u16,
72+
experience: u64,
73+
nickname: Option<String>, // ✅ New optional field at end
74+
}
75+
```
76+
77+
**Why This is Safe:**
78+
-**Old accounts deserialize correctly** - Missing field defaults to `None`
79+
-**No byte layout change** for existing fields
80+
-**Backward compatible** - v1.0.0 programs can read v1.1.0 data (just ignore nickname)
81+
-**No migration required** - Existing accounts work immediately
82+
83+
---
84+
85+
## Verify Safety with `lumos diff`
86+
87+
```bash
88+
lumos diff player_v1.lumos player_v1.1.lumos
89+
90+
# Output:
91+
# Non-Breaking Changes:
92+
# + PlayerAccount.nickname: Option<String> (new field)
93+
#
94+
# Recommendation: Increment MINOR version (1.0.0 → 1.1.0)
95+
# Migration Required: No
96+
```
97+
98+
---
99+
100+
## Generated Code Changes
101+
102+
**New Rust:**
103+
```rust
104+
#[account]
105+
pub struct PlayerAccount {
106+
pub wallet: Pubkey,
107+
pub level: u16,
108+
pub experience: u64,
109+
pub nickname: Option<String>, // New field
110+
}
111+
112+
// New size: 8 + 32 + 2 + 8 + (1 + 4 + N) bytes
113+
// - 1 byte: Option discriminant (0 = None, 1 = Some)
114+
// - 4 bytes: String length prefix
115+
// - N bytes: String data (variable)
116+
```
117+
118+
**New TypeScript:**
119+
```typescript
120+
export interface PlayerAccount {
121+
wallet: PublicKey;
122+
level: number;
123+
experience: number;
124+
nickname: string | undefined; // New field
125+
}
126+
127+
export const PlayerAccountBorshSchema = borsh.struct([
128+
borsh.publicKey('wallet'),
129+
borsh.u16('level'),
130+
borsh.u64('experience'),
131+
borsh.option(borsh.string(), 'nickname'), // New field
132+
]);
133+
```
134+
135+
---
136+
137+
## Deserialization Behavior
138+
139+
### Old Account (v1.0.0) Read by New Program (v1.1.0)
140+
141+
```rust
142+
// Account data (50 bytes): [wallet][level][experience]
143+
// No nickname field present
144+
145+
let account = PlayerAccount::try_from_slice(&data)?;
146+
// ✅ Succeeds!
147+
148+
assert_eq!(account.nickname, None); // Defaults to None
149+
```
150+
151+
**How Borsh Handles This:**
152+
- `Option<T>` is encoded as: `1 byte discriminant + T data (if Some)`
153+
- If bytes don't exist, Borsh deserializes as `None`
154+
- No error, clean default behavior
155+
156+
---
157+
158+
## Program Logic Updates
159+
160+
### Creating New Accounts (v1.1.0)
161+
162+
```rust
163+
pub fn create_player(
164+
ctx: Context<CreatePlayer>,
165+
nickname: Option<String>
166+
) -> Result<()> {
167+
let player = &mut ctx.accounts.player;
168+
169+
player.wallet = ctx.accounts.signer.key();
170+
player.level = 1;
171+
player.experience = 0;
172+
player.nickname = nickname; // ✅ Can be None or Some
173+
174+
Ok(())
175+
}
176+
```
177+
178+
### Reading Existing Accounts
179+
180+
```rust
181+
pub fn get_display_name(player: &PlayerAccount) -> String {
182+
player.nickname.clone().unwrap_or_else(|| {
183+
// Fallback for old accounts without nickname
184+
format!("Player {}", &player.wallet.to_string()[..8])
185+
})
186+
}
187+
```
188+
189+
### Updating Nickname
190+
191+
```rust
192+
pub fn set_nickname(
193+
ctx: Context<SetNickname>,
194+
nickname: String
195+
) -> Result<()> {
196+
let player = &mut ctx.accounts.player;
197+
198+
// Works for both old and new accounts
199+
player.nickname = Some(nickname);
200+
201+
Ok(())
202+
}
203+
```
204+
205+
---
206+
207+
## TypeScript SDK Updates
208+
209+
### Fetching Accounts
210+
211+
```typescript
212+
// Old accounts (v1.0.0)
213+
const player = await program.account.playerAccount.fetch(playerPubkey);
214+
console.log(player.nickname); // undefined (old account)
215+
216+
// New accounts (v1.1.0)
217+
const newPlayer = await program.account.playerAccount.fetch(newPlayerPubkey);
218+
console.log(newPlayer.nickname); // "CryptoKnight" (new account)
219+
```
220+
221+
### Display Logic
222+
223+
```typescript
224+
function getDisplayName(player: PlayerAccount): string {
225+
return player.nickname ?? `Player ${player.wallet.toBase58().slice(0, 8)}`;
226+
}
227+
```
228+
229+
---
230+
231+
## Account Reallocation (If Needed)
232+
233+
If you want to add data to existing accounts (e.g., set nickname for old accounts):
234+
235+
```rust
236+
use anchor_lang::prelude::*;
237+
238+
#[derive(Accounts)]
239+
pub struct SetNicknameWithRealloc<'info> {
240+
#[account(
241+
mut,
242+
realloc = 8 + 32 + 2 + 8 + 1 + 4 + 20, // Max nickname: 20 chars
243+
realloc::payer = payer,
244+
realloc::zero = false,
245+
)]
246+
pub player: Account<'info, PlayerAccount>,
247+
248+
#[account(mut)]
249+
pub payer: Signer<'info>,
250+
251+
pub system_program: Program<'info, System>,
252+
}
253+
254+
pub fn set_nickname_with_realloc(
255+
ctx: Context<SetNicknameWithRealloc>,
256+
nickname: String,
257+
) -> Result<()> {
258+
require!(nickname.len() <= 20, ErrorCode::NicknameTooLong);
259+
260+
let player = &mut ctx.accounts.player;
261+
player.nickname = Some(nickname);
262+
263+
Ok(())
264+
}
265+
```
266+
267+
**Rent Cost for Realloc:**
268+
```typescript
269+
// Calculate rent for 20-character nickname
270+
const additionalBytes = 1 + 4 + 20; // Option + length + data
271+
const rentPerByte = await connection.getMinimumBalanceForRentExemption(1) -
272+
await connection.getMinimumBalanceForRentExemption(0);
273+
const totalRent = rentPerByte * additionalBytes;
274+
console.log(`Rent cost: ${totalRent / LAMPORTS_PER_SOL} SOL`);
275+
```
276+
277+
---
278+
279+
## Testing
280+
281+
### Backward Compatibility Test
282+
283+
```rust
284+
#[test]
285+
fn test_old_account_deserializes_with_new_schema() {
286+
use borsh::{BorshSerialize, BorshDeserialize};
287+
use solana_program::pubkey::Pubkey;
288+
289+
// Simulate v1.0.0 account (no nickname)
290+
#[derive(BorshSerialize, BorshDeserialize)]
291+
struct PlayerAccountV1 {
292+
wallet: Pubkey,
293+
level: u16,
294+
experience: u64,
295+
}
296+
297+
let v1_account = PlayerAccountV1 {
298+
wallet: Pubkey::new_unique(),
299+
level: 10,
300+
experience: 500,
301+
};
302+
303+
let bytes = borsh::to_vec(&v1_account).unwrap();
304+
305+
// Deserialize with v1.1.0 schema
306+
let v1_1_account = PlayerAccount::try_from_slice(&bytes).unwrap();
307+
308+
assert_eq!(v1_1_account.wallet, v1_account.wallet);
309+
assert_eq!(v1_1_account.level, 10);
310+
assert_eq!(v1_1_account.experience, 500);
311+
assert_eq!(v1_1_account.nickname, None); // ✅ Defaults to None
312+
}
313+
```
314+
315+
---
316+
317+
## Deployment Checklist
318+
319+
- [x] **Version incremented** (1.0.0 → 1.1.0)
320+
- [x] **Optional field added at end** (not in middle)
321+
- [x] **`lumos diff` shows non-breaking** change
322+
- [x] **Backward compatibility tested**
323+
- [x] **Program logic handles `None` case**
324+
- [x] **TypeScript SDK regenerated**
325+
- [x] **Docs updated** with new field
326+
- [ ] **Deploy to devnet** for final testing
327+
- [ ] **Deploy to mainnet**
328+
329+
---
330+
331+
## Key Takeaways
332+
333+
**Always append optional fields** - Never insert in middle
334+
**Use `Option<T>` for new fields** - Allows backward compatibility
335+
**Test old accounts deserialize** - Write compatibility tests
336+
**Handle `None` gracefully** - Provide sensible defaults
337+
**This is a MINOR version bump** - Not breaking
338+
339+
---
340+
341+
## Next Steps
342+
343+
- 📖 [Schema Versioning Guide](/guides/versioning) - Full versioning rules
344+
- 📖 [Changing Field Type Example](/examples/versioning/changing-field-type) - Breaking change
345+
- 📖 [Deprecating Field Example](/examples/versioning/deprecating-field) - Phased migration
346+
347+
---
348+
349+
**This pattern is production-ready and safe for mainnet deployments.** 🚀

0 commit comments

Comments
 (0)