diff --git a/src/codegen/overlay.rs b/src/codegen/overlay.rs index 20e6612..7dd4a29 100644 --- a/src/codegen/overlay.rs +++ b/src/codegen/overlay.rs @@ -168,6 +168,10 @@ fn generate_lineage_table() -> String { /// point-in-time queries and rollback. Each version records when it /// became active (`valid_from`) and when it was superseded (`valid_to`). fn generate_temporal_table() -> String { + // The partial UNIQUE index enforces "at most one current version per + // (entity, table)" at the storage layer — two concurrent writers can no + // longer both insert a row with `valid_to IS NULL` for the same entity. + // The CHECK ensures valid_to never precedes valid_from. Closes #41. "-- Temporal: version history with point-in-time support\n\ CREATE TABLE IF NOT EXISTS verisimdb_temporal_versions (\n\ \x20 entity_id TEXT NOT NULL,\n\ @@ -177,9 +181,10 @@ fn generate_temporal_table() -> String { \x20 valid_to TEXT, -- ISO 8601, NULL if current\n\ \x20 snapshot TEXT NOT NULL, -- JSON serialisation of entity state\n\ \x20 operation TEXT NOT NULL, -- insert, update, rollback\n\ - \x20 PRIMARY KEY (entity_id, table_name, version)\n\ + \x20 PRIMARY KEY (entity_id, table_name, version),\n\ + \x20 CHECK (valid_to IS NULL OR valid_to >= valid_from)\n\ );\n\ - CREATE INDEX IF NOT EXISTS idx_temporal_current ON verisimdb_temporal_versions(entity_id, table_name) WHERE valid_to IS NULL;\n\n" + CREATE UNIQUE INDEX IF NOT EXISTS idx_temporal_current ON verisimdb_temporal_versions(entity_id, table_name) WHERE valid_to IS NULL;\n\n" .to_string() } @@ -331,6 +336,35 @@ mod tests { ); } + /// The "current version" partial index must be UNIQUE and the + /// `valid_to >= valid_from` CHECK must be present (closes #41). + /// Two concurrent writers must not be able to leave two rows with + /// `valid_to IS NULL` for the same `(entity_id, table_name)`. + #[test] + fn test_temporal_table_has_unique_partial_index_and_valid_to_check() { + let schema = test_schema(); + let octad = OctadConfig { + enable_provenance: false, + enable_lineage: false, + enable_temporal: true, + enable_access_control: false, + enable_constraints: false, + enable_simulation: false, + }; + let ddl = generate_sidecar_schema(&schema, &octad); + assert!(ddl.contains("verisimdb_temporal_versions")); + assert!( + ddl.contains( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_temporal_current ON verisimdb_temporal_versions(entity_id, table_name) WHERE valid_to IS NULL" + ), + "temporal current-version index must be UNIQUE" + ); + assert!( + ddl.contains("CHECK (valid_to IS NULL OR valid_to >= valid_from)"), + "temporal valid_to ordering CHECK missing" + ); + } + /// Lineage edges must refuse self-loops at the storage layer /// (closes #42). The DAG claim in the README would be unenforced /// without this check. diff --git a/src/codegen/query.rs b/src/codegen/query.rs index dff08f4..2694a87 100644 --- a/src/codegen/query.rs +++ b/src/codegen/query.rs @@ -180,7 +180,13 @@ fn generate_provenance_view( table_name ); - // Use a subquery to get the latest provenance entry per entity. + // Select the latest provenance row per entity via ROW_NUMBER() over a + // PARTITION-by-entity / ORDER-by-timestamp-DESC window. The previous + // correlated MAX(timestamp) subquery referenced the outer + // `verisimdb_provenance_log` table by base-name, which is ambiguous + // when the same table appears in nested scopes and produced wrong + // (or no) "latest" rows depending on planner choices. Closes #40. + // ROW_NUMBER is supported on PostgreSQL (always) and SQLite ≥ 3.25. format!( "{comment}\ CREATE VIEW IF NOT EXISTS verisimdb_{table_name}_with_provenance AS\n\ @@ -193,14 +199,13 @@ fn generate_provenance_view( FROM {table_name}\n\ LEFT JOIN (\n\ \x20 SELECT entity_id, operation, actor, timestamp, hash\n\ - \x20 FROM verisimdb_provenance_log\n\ - \x20 WHERE table_name = '{table_name}'\n\ - \x20 AND timestamp = (\n\ - \x20 SELECT MAX(p2.timestamp)\n\ - \x20 FROM verisimdb_provenance_log p2\n\ - \x20 WHERE p2.entity_id = verisimdb_provenance_log.entity_id\n\ - \x20 AND p2.table_name = '{table_name}'\n\ - \x20 )\n\ + \x20 FROM (\n\ + \x20 SELECT entity_id, operation, actor, timestamp, hash,\n\ + \x20 ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY timestamp DESC) AS _rn\n\ + \x20 FROM verisimdb_provenance_log\n\ + \x20 WHERE table_name = '{table_name}'\n\ + \x20 ) ranked\n\ + \x20 WHERE _rn = 1\n\ ) prov ON prov.entity_id = ({entity_id_expr});\n\n", columns = column_list.join(",\n"), ) @@ -398,6 +403,35 @@ mod tests { assert!(interceptor.access_filter.is_none()); } + /// The provenance view must use a window-function `ROW_NUMBER()` pattern + /// instead of the old (broken) correlated MAX(timestamp) subquery. + /// Closes #40. + #[test] + fn test_provenance_view_uses_window_function_for_latest_per_entity() { + let schema = test_schema(); + let octad = OctadConfig { + enable_provenance: true, + enable_lineage: false, + enable_temporal: false, + enable_access_control: false, + enable_constraints: false, + enable_simulation: false, + }; + let interceptors = generate_interceptors(&schema, &octad, DatabaseBackend::SQLite); + let view = interceptors[0] + .provenance_view + .as_ref() + .expect("provenance view present when enabled"); + assert!( + view.contains("ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY timestamp DESC)"), + "provenance view should use window-function latest-per-entity" + ); + assert!( + !view.contains("MAX(p2.timestamp)"), + "old correlated MAX(timestamp) pattern must be gone" + ); + } + #[test] fn test_provenance_view_references_table() { let schema = test_schema();