Skip to content

Commit 67291a1

Browse files
feat(sidecar): JSON-family storage backend — plain / JSON-LD / NDJSON (V-L2-F3, #146) (#148)
Implement a json sidecar store covering plain JSON, JSON-LD, and NDJSON, with full parity to the SQLite runtime path (provenance incl. forks, temporal versioning, drift, gc). `[sidecar].storage = "json"` + a new `[sidecar].format` key; `sidecar::StorageKind::resolve` is the single backend resolver. Re-opens the capability dropped in #112/#144. Closes #146
1 parent 871db26 commit 67291a1

8 files changed

Lines changed: 1756 additions & 143 deletions

File tree

src/abi/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,12 @@ impl AccessPolicy {
460460
/// with validation and path resolution.
461461
#[derive(Debug, Clone, Serialize, Deserialize)]
462462
pub struct SidecarConfig {
463-
/// Storage backend: "sqlite" (default) or "postgres"/"postgresql".
463+
/// Storage backend: "sqlite" (default), "postgres"/"postgresql", or
464+
/// "json" (see `format`).
464465
pub storage: String,
466+
/// On-disk encoding for the `json` store: "plain" (default), "ld"
467+
/// (JSON-LD), or "ndjson". Ignored for sql backends. V-L2-F3 (#146).
468+
pub format: String,
465469
/// File path for the sidecar database.
466470
pub path: String,
467471
}
@@ -471,6 +475,7 @@ impl SidecarConfig {
471475
pub fn default_sqlite() -> Self {
472476
Self {
473477
storage: "sqlite".to_string(),
478+
format: "plain".to_string(),
474479
path: ".verisim/sidecar.db".to_string(),
475480
}
476481
}

src/codegen/overlay.rs

Lines changed: 14 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -67,47 +67,24 @@ fn must_validate_identifier(name: &str) -> &str {
6767
// SQL dialect (V-L2-F1, #45)
6868
// ---------------------------------------------------------------------------
6969

70-
/// The SQL dialect the sidecar DDL is emitted for. Selected from the
71-
/// manifest's `[sidecar].storage`. The table bodies are written in the
72-
/// portable subset both engines accept (`CREATE TABLE IF NOT EXISTS`,
73-
/// `CHECK`, partial unique indexes, `CURRENT_TIMESTAMP`); the only
74-
/// genuinely dialect-divergent fragment is the metadata upsert
75-
/// (`INSERT OR IGNORE` vs `INSERT … ON CONFLICT DO NOTHING`), which lives
76-
/// in the [`sqlite`] / [`postgres`] modules.
70+
/// The SQL dialect the sidecar DDL is emitted for. Selected (via
71+
/// [`crate::sidecar::StorageKind`]) from the manifest's `[sidecar].storage`.
72+
/// The table bodies are written in the portable subset both engines accept
73+
/// (`CREATE TABLE IF NOT EXISTS`, `CHECK`, partial unique indexes,
74+
/// `CURRENT_TIMESTAMP`); the only genuinely dialect-divergent fragment is
75+
/// the metadata upsert (`INSERT OR IGNORE` vs `INSERT … ON CONFLICT DO
76+
/// NOTHING`), which lives in the [`sqlite`] / [`postgres`] modules.
7777
///
78-
/// [`from_storage`](SqlDialect::from_storage) is the single source of
79-
/// truth for which `[sidecar].storage` values are accepted; `generate`,
80-
/// `validate`, and `doctor` all defer to it.
78+
/// Storage-string resolution lives in [`crate::sidecar::StorageKind::resolve`]
79+
/// (the single source of truth) — it maps `sqlite`/`postgres` to a dialect
80+
/// via [`StorageKind::sql_dialect`](crate::sidecar::StorageKind::sql_dialect)
81+
/// and `json` to the non-SQL [`crate::sidecar::json`] store.
8182
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
8283
pub enum SqlDialect {
8384
Sqlite,
8485
Postgres,
8586
}
8687

87-
impl SqlDialect {
88-
/// Map a `[sidecar].storage` value to a dialect (case-insensitive):
89-
/// `sqlite` → [`SqlDialect::Sqlite`]; `postgres`/`postgresql` →
90-
/// [`SqlDialect::Postgres`]. Every other value is rejected rather than
91-
/// silently emitting SQLite DDL regardless of the backend (V-L2-F1).
92-
///
93-
/// The octad data layer is intrinsically relational (hash-chains under
94-
/// `BEGIN IMMEDIATE`, partial-unique temporal indexes, `CHECK`
95-
/// constraints, recursive-CTE lineage acyclicity), so the
96-
/// never-implemented `json` document store was dropped rather than
97-
/// built (V-L2-F2, #112). It is now an unsupported value like any
98-
/// other — no special-casing, no "coming soon" pointer.
99-
pub fn from_storage(storage: &str) -> anyhow::Result<Self> {
100-
match storage.to_lowercase().as_str() {
101-
"sqlite" => Ok(SqlDialect::Sqlite),
102-
"postgres" | "postgresql" => Ok(SqlDialect::Postgres),
103-
other => anyhow::bail!(
104-
"unsupported [sidecar].storage {other:?}; supported values are \
105-
\"sqlite\" (default) and \"postgres\"/\"postgresql\"."
106-
),
107-
}
108-
}
109-
}
110-
11188
// ---------------------------------------------------------------------------
11289
// Overlay generation
11390
// ---------------------------------------------------------------------------
@@ -870,33 +847,7 @@ mod tests {
870847
assert!(!p.contains("Seed metadata from parsed schema"));
871848
}
872849

873-
#[test]
874-
fn test_storage_to_dialect_mapping() {
875-
assert_eq!(
876-
SqlDialect::from_storage("sqlite").unwrap(),
877-
SqlDialect::Sqlite
878-
);
879-
assert_eq!(
880-
SqlDialect::from_storage("postgres").unwrap(),
881-
SqlDialect::Postgres
882-
);
883-
assert_eq!(
884-
SqlDialect::from_storage("PostgreSQL").unwrap(),
885-
SqlDialect::Postgres
886-
);
887-
// V-L2-F2 (#112): the json store was dropped, never implemented. It
888-
// is now rejected like any other unsupported value, and the error
889-
// advertises only the supported stores — it must NOT imply json is
890-
// planned (no "#112" / "not implemented" pointer).
891-
let json_err = SqlDialect::from_storage("json").unwrap_err().to_string();
892-
assert!(
893-
json_err.contains("unsupported") && json_err.contains("sqlite"),
894-
"json must be rejected as an unsupported store, got: {json_err}"
895-
);
896-
assert!(
897-
!json_err.contains("#112") && !json_err.to_lowercase().contains("not implemented"),
898-
"the dropped json store must not be advertised as planned, got: {json_err}"
899-
);
900-
assert!(SqlDialect::from_storage("mariadb").is_err());
901-
}
850+
// Storage-string resolution (incl. the json family) is owned by
851+
// `crate::sidecar::StorageKind` and tested there; `SqlDialect` is now a
852+
// plain dialect tag with no string parsing of its own.
902853
}

src/gc.rs

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,52 @@ impl GcReport {
3939
}
4040

4141
/// Purge sidecar rows older than the retention bound. `dry_run = true`
42-
/// reports what would be deleted without changing the DB.
42+
/// reports what would be deleted without changing the store.
4343
///
44-
/// Returns `Err` if the sidecar storage is not SQLite (unsupported in
45-
/// this cut) or if the file is unreachable.
44+
/// Dispatches on the resolved [`StorageKind`](crate::sidecar::StorageKind):
45+
/// the `sqlite` and `json` (plain/ld/ndjson) backends are implemented;
46+
/// `postgres` gc is not yet. Returns `Err` for an unsupported storage or an
47+
/// unreachable store.
4648
pub fn run_gc(manifest: &Manifest, dry_run: bool) -> Result<GcReport> {
47-
if manifest.sidecar.storage != "sqlite" {
48-
bail!(
49-
"verisimiser gc currently only supports the SQLite sidecar backend; \
50-
[sidecar].storage is {:?}",
51-
manifest.sidecar.storage
52-
);
49+
use crate::sidecar::StorageKind;
50+
match StorageKind::resolve(&manifest.sidecar.storage, &manifest.sidecar.format)? {
51+
StorageKind::Sqlite => run_gc_sqlite(manifest, dry_run),
52+
StorageKind::Json(format) => run_gc_json(manifest, dry_run, format),
53+
StorageKind::Postgres => bail!(
54+
"verisimiser gc supports the sqlite and json sidecar backends; \
55+
gc for [sidecar].storage = \"postgres\" is not yet implemented"
56+
),
5357
}
58+
}
5459

60+
/// JSON-family gc: load the store, purge in memory, persist iff applying.
61+
/// The per-dimension semantics (incl. keeping the current temporal version)
62+
/// live in [`crate::sidecar::json::JsonStore::gc_purge`].
63+
fn run_gc_json(
64+
manifest: &Manifest,
65+
dry_run: bool,
66+
format: crate::sidecar::JsonFormat,
67+
) -> Result<GcReport> {
68+
let sidecar_path = &manifest.sidecar.path;
69+
let mut store = crate::sidecar::json::JsonStore::open(sidecar_path, format)
70+
.with_context(|| format!("opening json sidecar at {}", sidecar_path))?;
71+
let counts = store.gc_purge(&manifest.retention, dry_run);
72+
if !dry_run {
73+
store
74+
.save()
75+
.with_context(|| format!("saving json sidecar at {}", sidecar_path))?;
76+
}
77+
Ok(GcReport {
78+
sidecar: sidecar_path.clone(),
79+
dry_run,
80+
provenance_deleted: counts.provenance,
81+
temporal_deleted: counts.temporal,
82+
lineage_deleted: counts.lineage,
83+
})
84+
}
85+
86+
/// SQLite gc (the reference path).
87+
fn run_gc_sqlite(manifest: &Manifest, dry_run: bool) -> Result<GcReport> {
5588
let sidecar_path = &manifest.sidecar.path;
5689
let conn = Connection::open(sidecar_path)
5790
.with_context(|| format!("opening sidecar at {}", sidecar_path))?;
@@ -148,6 +181,7 @@ mod tests {
148181
.unwrap();
149182
m.sidecar = SidecarConfig {
150183
storage: storage.to_string(),
184+
format: "plain".to_string(),
151185
path: sidecar_path.to_string(),
152186
};
153187
m.retention = retention;
@@ -308,15 +342,67 @@ mod tests {
308342
}
309343

310344
#[test]
311-
fn gc_rejects_non_sqlite_backend() {
312-
// `postgres` is a valid generate-time dialect, but gc is SQLite-only
313-
// and must refuse rather than silently no-op. (The `json` value was
314-
// dropped as a storage option entirely in V-L2-F2 / #112.)
345+
fn gc_rejects_postgres_backend() {
346+
// `postgres` is a valid generate-time dialect, but gc is not yet
347+
// implemented for it and must refuse rather than silently no-op.
348+
// (The json family *is* now supported — see the json gc test below.)
315349
let m = fixture("/dev/null", RetentionConfig::default(), "postgres");
316350
let err = run_gc(&m, true).unwrap_err();
317351
assert!(
318-
err.to_string().contains("only supports the SQLite sidecar"),
319-
"expected explicit unsupported-backend error; got: {err}"
352+
err.to_string().contains("not yet implemented"),
353+
"expected explicit postgres-unsupported error; got: {err}"
354+
);
355+
}
356+
357+
#[test]
358+
fn gc_json_backend_purges_old_rows_and_persists() {
359+
use crate::sidecar::JsonFormat;
360+
use crate::sidecar::json::{JsonStore, ProvenanceRow, SidecarData, encode};
361+
362+
let dir = tempfile::tempdir().unwrap();
363+
let sidecar = dir.path().join("sidecar.json");
364+
let sidecar_str = sidecar.to_str().unwrap();
365+
366+
// Seed one aged + one fresh provenance row directly (deterministic
367+
// timestamps; the append API always stamps "now").
368+
let aged = ProvenanceRow {
369+
hash: "old".into(),
370+
previous_hash: String::new(),
371+
entity_id: "e".into(),
372+
table_name: "t".into(),
373+
operation: "insert".into(),
374+
actor: "a".into(),
375+
timestamp: "2020-01-01T00:00:00+00:00".into(),
376+
before_snapshot: None,
377+
transformation: None,
378+
};
379+
let fresh = ProvenanceRow {
380+
hash: "new".into(),
381+
timestamp: "9999-01-01T00:00:00+00:00".into(),
382+
..aged.clone()
383+
};
384+
let data = SidecarData {
385+
provenance_log: vec![aged, fresh],
386+
..Default::default()
387+
};
388+
std::fs::write(&sidecar, encode(&data, JsonFormat::Plain).unwrap()).unwrap();
389+
390+
let m = fixture(
391+
sidecar_str,
392+
RetentionConfig {
393+
provenance_days: 30,
394+
temporal_days: 30,
395+
lineage_days: 30,
396+
},
397+
"json",
320398
);
399+
let report = run_gc(&m, false).unwrap();
400+
assert_eq!(report.provenance_deleted, 1, "old provenance row purged");
401+
assert_eq!(report.total(), 1);
402+
403+
// The purge was persisted: reopening shows only the fresh row.
404+
let reopened = JsonStore::open(&sidecar, JsonFormat::Plain).unwrap();
405+
assert_eq!(reopened.data().provenance_log.len(), 1);
406+
assert_eq!(reopened.data().provenance_log[0].hash, "new");
321407
}
322408
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod doctor;
1313
pub mod gc;
1414
pub mod intercept;
1515
pub mod manifest;
16+
pub mod sidecar;
1617
pub mod tier1;
1718
pub mod tier2;
1819

0 commit comments

Comments
 (0)