11"""
2- Chain persistence: save and load the blockchain and state to/from JSON .
2+ Chain persistence: save and load the blockchain and state to/from SQLite .
33
44Design:
5- - blockchain.json holds the full list of serialised blocks
6- - state .json holds the accounts dict (includes off-chain credits)
5+ - data.db holds the full chain snapshot, account state, and small metadata.
6+ - legacy data .json snapshots can still be loaded for backward compatibility.
77
8- Both files are written atomically (temp → rename) to prevent corruption
9- on crash. On load, chain integrity is verified before the data is trusted.
10-
11- Usage:
8+ The public API intentionally stays the same:
129 from minichain.persistence import save, load
1310
1411 save(blockchain, path="data/")
1512 blockchain = load(path="data/")
1613"""
1714
15+ from __future__ import annotations
16+
1817import json
19- import os
20- import tempfile
2118import logging
22- import copy
19+ import os
20+ import sqlite3
21+ from typing import Any
2322
2423from .block import Block
2524from .chain import Blockchain , validate_block_link_and_hash
2625
2726logger = logging .getLogger (__name__ )
2827
29- _DATA_FILE = "data.json"
28+ _DB_FILE = "data.db"
29+ _LEGACY_DATA_FILE = "data.json"
30+
31+
32+ def persistence_exists (path : str = "." ) -> bool :
33+ """Return True if a SQLite or legacy JSON snapshot exists inside *path*."""
34+ return os .path .exists (os .path .join (path , _DB_FILE )) or os .path .exists (
35+ os .path .join (path , _LEGACY_DATA_FILE )
36+ )
3037
3138
3239# ---------------------------------------------------------------------------
3340# Public API
3441# ---------------------------------------------------------------------------
3542
36- def save (blockchain : Blockchain , path : str = "." ) -> None :
37- """
38- Persist the blockchain and account state to a JSON file inside *path*.
3943
40- Uses atomic write (write-to-temp → rename) with fsync so a crash mid-save
41- never corrupts the existing file. Chain and state are saved together to
42- prevent torn snapshots.
43- """
44+ def save (blockchain : Blockchain , path : str = "." ) -> None :
45+ """Persist the blockchain and account state to SQLite inside *path*."""
4446 os .makedirs (path , exist_ok = True )
47+ db_path = os .path .join (path , _DB_FILE )
4548
46- with blockchain ._lock : # Thread-safe: hold lock while serialising
49+ with blockchain ._lock :
4750 chain_data = [block .to_dict () for block in blockchain .chain ]
48- state_data = copy .deepcopy (blockchain .state .accounts )
49-
50- snapshot = {
51- "chain" : chain_data ,
52- "state" : state_data
53- }
51+ state_data = json .loads (json .dumps (blockchain .state .accounts ))
5452
55- _atomic_write_json ( os . path . join ( path , _DATA_FILE ), snapshot )
53+ _save_snapshot_to_sqlite ( db_path , { "chain" : chain_data , "state" : state_data } )
5654
5755 logger .info (
5856 "Saved %d blocks and %d accounts to '%s'" ,
@@ -63,42 +61,33 @@ def save(blockchain: Blockchain, path: str = ".") -> None:
6361
6462
6563def load (path : str = "." ) -> Blockchain :
66- """
67- Restore a Blockchain from the JSON file inside *path*.
64+ """Restore a Blockchain from SQLite inside *path* (with legacy JSON fallback)."""
65+ db_path = os .path .join (path , _DB_FILE )
66+ legacy_path = os .path .join (path , _LEGACY_DATA_FILE )
6867
69- Steps:
70- 1. Load and deserialise blocks from data.json
71- 2. Verify chain integrity (genesis, linkage, hashes)
72- 3. Load account state
73-
74- Raises:
75- FileNotFoundError: if data.json is missing.
76- ValueError: if data is invalid or integrity checks fail.
77- """
78- data_path = os .path .join (path , _DATA_FILE )
79- snapshot = _read_json (data_path )
68+ if os .path .exists (db_path ):
69+ snapshot = _load_snapshot_from_sqlite (db_path )
70+ elif os .path .exists (legacy_path ):
71+ snapshot = _read_legacy_json (legacy_path )
72+ else :
73+ raise FileNotFoundError (f"Persistence file not found in '{ path } '" )
8074
8175 if not isinstance (snapshot , dict ):
82- raise ValueError (f"Invalid snapshot data in '{ data_path } '" )
76+ raise ValueError (f"Invalid snapshot data in '{ path } '" )
8377
8478 raw_blocks = snapshot .get ("chain" )
8579 raw_accounts = snapshot .get ("state" )
8680
8781 if not isinstance (raw_blocks , list ) or not raw_blocks :
88- raise ValueError (f"Invalid or empty chain data in '{ data_path } '" )
82+ raise ValueError (f"Invalid or empty chain data in '{ path } '" )
8983 if not isinstance (raw_accounts , dict ):
90- raise ValueError (f"Invalid accounts data in '{ data_path } '" )
84+ raise ValueError (f"Invalid accounts data in '{ path } '" )
9185
9286 blocks = [_deserialize_block (b ) for b in raw_blocks ]
93-
94- # --- Integrity verification ---
9587 _verify_chain_integrity (blocks )
9688
97- # --- Rebuild blockchain properly (no __new__ hack) ---
98- blockchain = Blockchain () # creates genesis + fresh state
99- blockchain .chain = blocks # replace with loaded chain
100-
101- # Restore state
89+ blockchain = Blockchain ()
90+ blockchain .chain = blocks
10291 blockchain .state .accounts = raw_accounts
10392
10493 logger .info (
@@ -114,14 +103,13 @@ def load(path: str = ".") -> Blockchain:
114103# Integrity verification
115104# ---------------------------------------------------------------------------
116105
117- def _verify_chain_integrity (blocks : list ) -> None :
106+
107+ def _verify_chain_integrity (blocks : list [Block ]) -> None :
118108 """Verify genesis, hash linkage, and block hashes."""
119- # Check genesis
120109 genesis = blocks [0 ]
121110 if genesis .index != 0 or genesis .hash != "0" * 64 :
122111 raise ValueError ("Invalid genesis block" )
123112
124- # Check linkage and hashes for every subsequent block
125113 for i in range (1 , len (blocks )):
126114 block = blocks [i ]
127115 prev = blocks [i - 1 ]
@@ -132,46 +120,112 @@ def _verify_chain_integrity(blocks: list) -> None:
132120
133121
134122# ---------------------------------------------------------------------------
135- # Helpers
123+ # SQLite helpers
136124# ---------------------------------------------------------------------------
137125
138- def _atomic_write_json (filepath : str , data ) -> None :
139- """Write JSON atomically with fsync for durability."""
140- dir_name = os .path .dirname (filepath ) or "."
141- fd , tmp_path = tempfile .mkstemp (dir = dir_name , suffix = ".tmp" )
126+
127+ def _connect (db_path : str ) -> sqlite3 .Connection :
128+ conn = sqlite3 .connect (db_path )
129+ conn .row_factory = sqlite3 .Row
130+ conn .execute ("PRAGMA foreign_keys = ON" )
131+ return conn
132+
133+
134+ def _initialize_schema (conn : sqlite3 .Connection ) -> None :
135+ conn .executescript (
136+ """
137+ CREATE TABLE IF NOT EXISTS blocks (
138+ height INTEGER PRIMARY KEY,
139+ block_json TEXT NOT NULL
140+ );
141+
142+ CREATE TABLE IF NOT EXISTS accounts (
143+ address TEXT PRIMARY KEY,
144+ account_json TEXT NOT NULL
145+ );
146+
147+ CREATE TABLE IF NOT EXISTS metadata (
148+ key TEXT PRIMARY KEY,
149+ value TEXT NOT NULL
150+ );
151+ """
152+ )
153+
154+
155+ def _save_snapshot_to_sqlite (db_path : str , snapshot : dict [str , Any ]) -> None :
156+ conn = _connect (db_path )
142157 try :
143- with os .fdopen (fd , "w" , encoding = "utf-8" ) as f :
144- json .dump (data , f , indent = 2 )
145- f .flush ()
146- os .fsync (f .fileno ()) # Ensure data is on disk
147- os .replace (tmp_path , filepath ) # Atomic rename
148-
149- # Attempt to fsync the directory so the rename is durable
150- if hasattr (os , "O_DIRECTORY" ):
151- try :
152- dir_fd = os .open (dir_name , os .O_RDONLY | os .O_DIRECTORY )
153- try :
154- os .fsync (dir_fd )
155- finally :
156- os .close (dir_fd )
157- except OSError :
158- pass # Directory fsync not supported on all platforms
159-
160- except BaseException :
161- try :
162- os .unlink (tmp_path )
163- except OSError :
164- pass
165- raise
158+ _initialize_schema (conn )
159+ with conn :
160+ conn .execute ("DELETE FROM blocks" )
161+ conn .execute ("DELETE FROM accounts" )
162+ conn .execute ("DELETE FROM metadata" )
163+
164+ for block in snapshot ["chain" ]:
165+ conn .execute (
166+ "INSERT INTO blocks (height, block_json) VALUES (?, ?)" ,
167+ (int (block ["index" ]), json .dumps (block , sort_keys = True )),
168+ )
169+
170+ for address , account in sorted (snapshot ["state" ].items ()):
171+ conn .execute (
172+ "INSERT INTO accounts (address, account_json) VALUES (?, ?)" ,
173+ (address , json .dumps (account , sort_keys = True )),
174+ )
175+
176+ conn .execute (
177+ "INSERT INTO metadata (key, value) VALUES (?, ?)" ,
178+ ("chain_length" , str (len (snapshot ["chain" ]))),
179+ )
180+ finally :
181+ conn .close ()
182+
183+
184+ def _load_snapshot_from_sqlite (db_path : str ) -> dict [str , Any ]:
185+ try :
186+ conn = _connect (db_path )
187+ except sqlite3 .DatabaseError as exc :
188+ raise ValueError (f"Invalid SQLite persistence data in '{ db_path } '" ) from exc
189+
190+ try :
191+ _initialize_schema (conn )
192+ block_rows = conn .execute (
193+ "SELECT block_json FROM blocks ORDER BY height ASC"
194+ ).fetchall ()
195+ account_rows = conn .execute (
196+ "SELECT address, account_json FROM accounts ORDER BY address ASC"
197+ ).fetchall ()
198+ except sqlite3 .DatabaseError as exc :
199+ raise ValueError (f"Invalid SQLite persistence data in '{ db_path } '" ) from exc
200+ finally :
201+ conn .close ()
202+
203+ try :
204+ chain = [json .loads (row ["block_json" ]) for row in block_rows ]
205+ state = {
206+ row ["address" ]: json .loads (row ["account_json" ])
207+ for row in account_rows
208+ }
209+ except json .JSONDecodeError as exc :
210+ raise ValueError (f"Invalid persisted JSON payload in '{ db_path } '" ) from exc
166211
212+ return {"chain" : chain , "state" : state }
167213
168- def _read_json (filepath : str ):
169- if not os .path .exists (filepath ):
170- raise FileNotFoundError (f"Persistence file not found: '{ filepath } '" )
214+
215+ # ---------------------------------------------------------------------------
216+ # Legacy JSON helpers
217+ # ---------------------------------------------------------------------------
218+
219+
220+ def _read_legacy_json (filepath : str ) -> dict [str , Any ]:
171221 with open (filepath , "r" , encoding = "utf-8" ) as f :
172222 return json .load (f )
173223
174224
175- def _deserialize_block (data : dict ) -> Block :
176- """Reconstruct a Block (including its transactions) from a plain dict."""
225+ # ---------------------------------------------------------------------------
226+ # Block deserialisation
227+ # ---------------------------------------------------------------------------
228+
229+
230+ def _deserialize_block (data : dict [str , Any ]) -> Block :
177231 return Block .from_dict (data )
0 commit comments