kanban-persistence-json
JSON file storage backend for the kanban workspace. Implements StoreFactory and PersistenceStore from kanban-persistence.
JsonFileStore
instance_id is a random UUID generated per process; used for conflict detection.
Envelope Format
V6 is the current on-disk format and is what save writes. The reader accepts envelopes with version in the range 2..=6 and migrates anything below 6 up to 6 before returning the snapshot. Anything below 2 or above 6 is rejected with PersistenceError::Serialization.
V1 was a bare Snapshot JSON object with no envelope (flat, no version field).
#[serde(deny_unknown_fields)] is deliberately NOT applied to the envelope: pre-KAN-405 builds wrote top-level commands / undo_cursor / baseline_data / command_schema_version fields, and tolerant deserialisation lets those files still load. The load path actively scrubs those legacy fields from disk on the next load, rather than leaving "dust" until the next mutation.
Save Flow
- Check for conflict: compare current file metadata (size + mtime) against the last-seen value
- Stamp
PersistenceMetadatawith this store'sinstance_idand the current time - Wrap the snapshot in a V6 envelope
- Pretty-print to JSON bytes
- Write atomically via
AtomicWriter(temp file + rename) for crash safety - Update the cached
FileMetadataafter a successful write
The atomic rename means a crash at any point leaves either the old file or the new file intact, always a complete consistent file on disk.
Load Flow
- Detect the current on-disk format version via
Migrator::detect_version - If
< V7, run the migration chain in order, each step writing back atomically:- V1 -> V2: wrap the bare V1
Snapshotin an envelope, side-stepping through a<path>.v1.backupfile that is removed once the new file is written - V2 -> V3: in-place transform via
transform_v2_to_v3_value - V3 -> V4 and V4 -> V5: version bump only, no shape change on disk (the reader simply accepts these versions and the chain hands them on)
- V5 -> V6 (split-graph): see below
- V6 -> V7 (spawns-bucket rename): see below
- V1 -> V2: wrap the bare V1
- Read the bytes, parse as a
JsonEnvelope, and validate2 <= version <= 7 - Detect any pre-KAN-405 legacy fields on the raw value and rewrite the file with a clean envelope if any are present (errors are logged but non-fatal; the in-memory load still succeeds)
- Extract
envelope.dataas theStoreSnapshot
A load_sync variant performs the same chain through synchronous helpers (migrate_v1_to_v2_sync, migrate_v2_to_v3_sync, split_graph_sync, v6_to_v7_rename_sync) so non-async callers see the same migration semantics.
A clean V7 file with no legacy fields is read but not rewritten, so mtimes stay stable and version-controlled kanban files don't churn.
V6 split-graph migration
V6 splits the single data.graph.cards.edges list (used by V3, V4 and V5 on disk) into three sub-graphs keyed by the original edge_type:
"data":
Each migrated edge has its edge_type, direction, and weight keys removed (the sub-graph encodes the kind, and the new per-kind edge structs no longer carry those fields). source, target, created_at, and archived_at are preserved. Migrated Blocks edges get severity: "Medium" and RelatesTo edges get kind: "General" as defaults. Unknown or missing edge_type values and non-object entries in the legacy edge list are rejected with a clear diagnostic.
transform_to_v6_split_graph_value is idempotent: invoked on an already-V6 envelope it returns without touching data.graph, so re-running the migration cannot silently wipe a populated split graph.
V7 spawns-bucket rename
V7 renames the spawns sub-graph key from parent_child to spawns so the wire format matches the SpawnsEdge struct, the spawns_edges() accessor on DependencyGraph, and the SQLite spawns_edges table:
"data":
Pure key rename: edge contents are untouched. The transform writes a <path>.v6.backup before rewriting and removes it on success. transform_v6_to_v7_value is idempotent and tolerates a missing data.graph (only the version field is bumped).
Conflict Detection
FileMetadata captures the file's size and modification time at last load/save. On the next save the current file metadata is compared:
- If they match: file unchanged since last save, safe to write
- If they differ: another instance wrote the file, return
PersistenceError::ConflictDetected
JsonStoreFactory
The actual StoreFactory trait (defined in kanban-persistence/src/registry.rs) has only three methods:
JsonStoreFactory implements it as:
Backend selection is by content sniffing, not by file extension. The registry reads the first 32 bytes of the file and asks each registered factory whether the header looks like its format. JsonStoreFactory::matches_content skips an optional UTF-8 BOM and any leading ASCII whitespace, then accepts the header if the first significant byte is { or [. This lets a .json file with SQLite contents be routed to the SQLite backend (and vice versa), and means a misleading file extension cannot trick the registry.
Dependencies
| Crate | Purpose |
|---|---|
kanban-persistence |
PersistenceStore, StoreFactory traits, FormatVersion |
kanban-domain |
Snapshot type |
serde_json |
JSON parsing |
tokio |
Async I/O |
tempfile |
Temp file for atomic writes |