- 1
//! Durable user/workspace presentation definitions. - 2
//! - 3
//! The library is a replaceable projection, not session truth. Session - 4
//! selection receipts remain append-only in `vak-session`; this file stores - 5
//! only the current reusable definitions and activation pointers. - 6
- 7
use std::fs; - 8
use std::path::{Path, PathBuf}; - 9
- 10
use vak_presentation::{PresentationLibrary, StoredPresentation}; - 11
- 12
const PRESENTATION_STORE_SCHEMA: u16 = 1; - 13
- 14
#[derive(Debug, serde::Serialize, serde::Deserialize)] - 15
struct PresentationDisk { - 16
schema_version: u16, - 17
definitions: Vec<StoredPresentation>, - 18
activations: Vec<vak_presentation::PresentationActivation>, - 19
#[serde(default)] - 20
suppressions: Vec<vak_presentation::PresentationSuppression>, - 21
} - 22
- 23
#[derive(Debug, thiserror::Error)] - 24
pub enum PresentationStoreError { - 25
#[error("presentation store io error: {0}")] - 26
Io(#[from] std::io::Error), - 27
#[error("presentation store json error: {0}")] - 28
Json(#[from] serde_json::Error), - 29
#[error("presentation store validation error: {0}")] - 30
Invalid(String), - 31
} - 32
- 33
#[derive(Debug, Clone)] - 34
pub struct PresentationStore { - 35
path: PathBuf, - 36
} - 37
- 38
impl PresentationStore { - 39
pub fn new(path: impl Into<PathBuf>) -> Self { - 40
Self { path: path.into() } - 41
} - 42
- 43
pub fn path(&self) -> &Path { - 44
&self.path - 45
} - 46
- 47
pub fn load(&self) -> Result<PresentationLibrary, PresentationStoreError> { - 48
if !self.path.exists() { - 49
return Ok(PresentationLibrary::default()); - 50
} - 51
let bytes = fs::read(&self.path)?; - 52
let disk: PresentationDisk = serde_json::from_slice(&bytes)?; - 53
if disk.schema_version != PRESENTATION_STORE_SCHEMA { - 54
return Err(PresentationStoreError::Invalid(format!( - 55
"unsupported presentation store schema {}", - 56
disk.schema_version - 57
))); - 58
} - 59
vak_presentation::PresentationLibrary::from_parts_with_suppressions( - 60
disk.definitions, - 61
disk.activations, - 62
disk.suppressions, - 63
) - 64
.map_err(|error| PresentationStoreError::Invalid(error.to_string())) - 65
} - 66
- 67
pub fn save(&self, library: &PresentationLibrary) -> Result<(), PresentationStoreError> { - 68
if let Some(parent) = self.path.parent() { - 69
fs::create_dir_all(parent)?; - 70
} - 71
let disk = PresentationDisk { - 72
schema_version: PRESENTATION_STORE_SCHEMA, - 73
definitions: library.definitions().cloned().collect(), - 74
activations: library.activations().to_vec(), - 75
suppressions: library.suppressions().to_vec(), - 76
}; - 77
let bytes = serde_json::to_vec_pretty(&disk)?; - 78
let nonce = std::time::SystemTime::now() - 79
.duration_since(std::time::UNIX_EPOCH) - 80
.map(|duration| duration.as_nanos()) - 81
.unwrap_or(0); - 82
let temp = self - 83
.path - 84
.with_extension(format!("tmp-{}-{nonce}", std::process::id())); - 85
fs::write(&temp, bytes)?; - 86
fs::rename(temp, &self.path)?; - 87
Ok(()) - 88
} - 89
- 90
pub fn register_pack( - 91
&self, - 92
records: impl IntoIterator<Item = StoredPresentation>, - 93
) -> Result<usize, PresentationStoreError> { - 94
let mut library = self.load()?; - 95
let mut count = 0; - 96
for mut record in records { - 97
// Registration is preview-only; activation is a separate, - 98
// explicit operation owned by the control plane. - 99
record.enabled = false; - 100
library - 101
.register(record) - 102
.map_err(|error| PresentationStoreError::Invalid(error.to_string()))?; - 103
count += 1; - 104
} - 105
self.save(&library)?; - 106
Ok(count) - 107
} - 108
- 109
pub fn revoke_plugin(&self, plugin_id: &str) -> Result<usize, PresentationStoreError> { - 110
let mut library = self.load()?; - 111
let removed = library.revoke_plugin(plugin_id); - 112
self.save(&library)?; - 113
Ok(removed) - 114
} - 115
} - 116
- 117
#[cfg(test)] - 118
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] - 119
mod tests { - 120
use super::*; - 121
use tempfile::tempdir; - 122
use vak_presentation::{LibraryScope, PresentationOrigin, PresentationSpec, digest}; - 123
- 124
#[test] - 125
fn missing_store_is_empty_and_roundtrips_atomically() { - 126
let dir = tempdir().expect("tempdir"); - 127
let store = PresentationStore::new(dir.path().join("nested/presentations.json")); - 128
let library = store.load().expect("load"); - 129
store.save(&library).expect("save"); - 130
assert_eq!(store.load().expect("reload"), library); - 131
} - 132
- 133
#[test] - 134
fn pack_registration_and_plugin_revocation_are_atomic() { - 135
let dir = tempdir().expect("tempdir"); - 136
let store = PresentationStore::new(dir.path().join("presentations.json")); - 137
let spec: PresentationSpec = serde_json::from_value(serde_json::json!({ - 138
"schema_version": 1, - 139
"id": "demo.card", - 140
"revision": 1, - 141
"accepts": ["demo"], - 142
"root": { "primitive": "title", "props": {}, "children": [] } - 143
})) - 144
.expect("spec"); - 145
let record = StoredPresentation { - 146
digest: digest(&spec).expect("digest"), - 147
spec, - 148
origin: PresentationOrigin { - 149
scope: LibraryScope::Workspace, - 150
owner: "workspace".into(), - 151
plugin_id: Some("demo-pack".into()), - 152
generation: Some("1".into()), - 153
}, - 154
enabled: true, - 155
}; - 156
assert_eq!(store.register_pack([record]).expect("register"), 1); - 157
assert!( - 158
!store - 159
.load() - 160
.expect("load after register") - 161
.definitions() - 162
.next() - 163
.expect("definition") - 164
.enabled - 165
); - 166
assert_eq!(store.revoke_plugin("demo-pack").expect("revoke"), 1); - 167
assert!(store.load().expect("load").definitions().next().is_none()); - 168
} - 169
- 170
#[test] - 171
fn activation_is_durable_and_revoke_clears_it_immediately() { - 172
let dir = tempdir().expect("tempdir"); - 173
let store = PresentationStore::new(dir.path().join("presentations.json")); - 174
let spec: PresentationSpec = serde_json::from_value(serde_json::json!({ - 175
"schema_version": 1, - 176
"id": "next.turn.card", - 177
"revision": 1, - 178
"accepts": ["demo"], - 179
"root": { "primitive": "title", "props": {}, "children": [] } - 180
})) - 181
.expect("spec"); - 182
let record = StoredPresentation { - 183
digest: digest(&spec).expect("digest"), - 184
spec: spec.clone(), - 185
origin: PresentationOrigin { - 186
scope: LibraryScope::Workspace, - 187
owner: "workspace".into(), - 188
plugin_id: Some("next-turn-pack".into()), - 189
generation: Some("1".into()), - 190
}, - 191
enabled: false, - 192
}; - 193
store.register_pack([record]).expect("register preview"); - 194
- 195
// Registration is preview-only; activation is explicit and persists - 196
// for the next turn without requiring a process restart. - 197
let mut library = store.load().expect("load preview"); - 198
assert!( - 199
library - 200
.select("demo", LibraryScope::Workspace, "workspace") - 201
.is_none() - 202
); - 203
library - 204
.activate("next.turn.card", 1, LibraryScope::Workspace, "workspace") - 205
.expect("activate"); - 206
store.save(&library).expect("save activation"); - 207
let reloaded = store.load().expect("reload activation"); - 208
assert_eq!( - 209
reloaded - 210
.select("demo", LibraryScope::Workspace, "workspace") - 211
.map(|entry| entry.spec.id.as_str()), - 212
Some("next.turn.card") - 213
); - 214
- 215
// Revoke is level-triggered: the persisted projection is removed - 216
// immediately while historical session receipts remain elsewhere. - 217
assert_eq!(store.revoke_plugin("next-turn-pack").expect("revoke"), 1); - 218
let revoked = store.load().expect("reload revoked"); - 219
assert!(revoked.activations().is_empty()); - 220
assert!(revoked.get("next.turn.card", 1).is_none()); - 221
} - 222
- 223
#[test] - 224
fn live_pack_lifecycle_exports_imports_and_preserves_history_projection() { - 225
let source_dir = tempdir().expect("tempdir"); - 226
let source = PresentationStore::new(source_dir.path().join("presentations.json")); - 227
let spec = PresentationSpec { - 228
schema_version: 1, - 229
id: "live.pack.card".into(), - 230
revision: 1, - 231
accepts: vec!["live-pack-value".into()], - 232
root: vak_presentation::SpecNode { - 233
primitive: vak_presentation::Primitive::Title, - 234
props: std::collections::BTreeMap::new(), - 235
children: Vec::new(), - 236
each: None, - 237
item: None, - 238
}, - 239
fallback: Default::default(), - 240
accessibility: Default::default(), - 241
metadata: Default::default(), - 242
}; - 243
let record = StoredPresentation { - 244
digest: digest(&spec).expect("digest"), - 245
spec, - 246
origin: PresentationOrigin { - 247
scope: LibraryScope::Workspace, - 248
owner: "workspace-live".into(), - 249
plugin_id: Some("live-pack".into()), - 250
generation: Some("1".into()), - 251
}, - 252
enabled: true, - 253
}; - 254
- 255
// Author/validate/import is preview-only; the enabled bit from an - 256
// external pack cannot grant activation. - 257
assert_eq!(source.register_pack([record.clone()]).expect("import"), 1); - 258
let preview = source.load().expect("preview"); - 259
assert!(!preview.get("live.pack.card", 1).expect("record").enabled); - 260
assert!( - 261
preview - 262
.select("live-pack-value", LibraryScope::Workspace, "workspace-live") - 263
.is_none() - 264
); - 265
- 266
// Export is a durable projection round-trip, not a second selection - 267
// contract. A fresh store can import it without a process restart. - 268
let exported = serde_json::to_vec(&( - 269
preview.definitions().cloned().collect::<Vec<_>>(), - 270
preview.activations().to_vec(), - 271
)) - 272
.expect("export"); - 273
let (definitions, _activations): ( - 274
Vec<StoredPresentation>, - 275
Vec<vak_presentation::PresentationActivation>, - 276
) = serde_json::from_slice(&exported).expect("import export"); - 277
let restored_dir = tempdir().expect("tempdir"); - 278
let restored = PresentationStore::new(restored_dir.path().join("presentations.json")); - 279
assert_eq!( - 280
restored.register_pack(definitions).expect("restore pack"), - 281
1 - 282
); - 283
- 284
// Explicit enable is visible on the next selection immediately. - 285
let mut library = restored.load().expect("load restored"); - 286
library - 287
.activate( - 288
"live.pack.card", - 289
1, - 290
LibraryScope::Workspace, - 291
"workspace-live", - 292
) - 293
.expect("enable"); - 294
restored.save(&library).expect("persist enable"); - 295
assert_eq!( - 296
restored - 297
.load() - 298
.expect("next turn") - 299
.select("live-pack-value", LibraryScope::Workspace, "workspace-live") - 300
.map(|selected| selected.spec.id.as_str()), - 301
Some("live.pack.card") - 302
); - 303
- 304
// Revoke removes only the live projection; an already-recorded - 305
// selection receipt would remain in the append-only session ledger. - 306
assert_eq!(restored.revoke_plugin("live-pack").expect("revoke"), 1); - 307
assert!( - 308
restored - 309
.load() - 310
.expect("post revoke") - 311
.get("live.pack.card", 1) - 312
.is_none() - 313
); - 314
} - 315
- 316
#[test] - 317
fn unsupported_store_schema_fails_closed() { - 318
let dir = tempdir().expect("tempdir"); - 319
let path = dir.path().join("presentations.json"); - 320
std::fs::write( - 321
&path, - 322
serde_json::json!({ "schema_version": 99, "definitions": [], "activations": [] }) - 323
.to_string(), - 324
) - 325
.expect("write"); - 326
let error = PresentationStore::new(path) - 327
.load() - 328
.expect_err("schema must fail"); - 329
assert!( - 330
error - 331
.to_string() - 332
.contains("unsupported presentation store schema") - 333
); - 334
} - 335
} - 336
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.