-
Notifications
You must be signed in to change notification settings - Fork 0
feat(persistence): persist exact text_segment byte spans without 0007 #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4ff422b
93d3678
5dd50ff
c4ed064
81abe04
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,10 +35,14 @@ use crate::retention_sql::{ | |
| insert_evidence_tombstone_sql, insert_legal_hold_sql, insert_retention_policy_sql, | ||
| select_active_analysis_document_sql, | ||
| }; | ||
| use crate::segment_sql::{ | ||
| TextSegmentRecord, insert_text_segment_sql, select_text_segment_by_id_sql, | ||
| select_text_segments_for_document_as_of_sql, | ||
| }; | ||
| use crate::sql_session::{SqlSession, apply_sql_batch}; | ||
| use crate::tenant_session::set_session_tenant_sql; | ||
| use crate::{MigrationContractError, PersistenceError}; | ||
| use temporal_core::{EventTime, SystemTime}; | ||
| use temporal_core::{EventTime, KnowledgeCutoff, SystemTime}; | ||
| use uuid::Uuid; | ||
|
|
||
| /// Fail-closed live document/audit repository backed by [`SqlSession`]. | ||
|
|
@@ -320,6 +324,47 @@ impl<S: SqlSession> LiveDocumentRepository<S> { | |
| self.session.execute(&sql) | ||
| } | ||
|
|
||
| /// Insert an exact-span text segment under the active tenant. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns inverted/negative span validation or transport failures. | ||
| pub fn insert_text_segment( | ||
| &mut self, | ||
| record: &TextSegmentRecord, | ||
| ) -> Result<(), PersistenceError> { | ||
| self.bind_session_tenant(record.tenant_record_id)?; | ||
| let sql = insert_text_segment_sql(record)?; | ||
| self.session.execute(&sql) | ||
| } | ||
|
|
||
| /// Look up one text segment by primary key. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns transport failures. | ||
| pub fn submit_text_segment_by_id( | ||
| &mut self, | ||
| text_segment_id: Uuid, | ||
| ) -> Result<(), PersistenceError> { | ||
| let sql = select_text_segment_by_id_sql(text_segment_id); | ||
| self.session.execute(&sql) | ||
| } | ||
|
|
||
| /// Look up cutoff-eligible text segments for one document identity. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns transport failures. | ||
| pub fn submit_text_segments_for_document_as_of( | ||
| &mut self, | ||
| document_record_id: Uuid, | ||
| knowledge_cutoff: &KnowledgeCutoff, | ||
| ) -> Result<(), PersistenceError> { | ||
| let sql = select_text_segments_for_document_as_of_sql(document_record_id, knowledge_cutoff); | ||
| self.session.execute(&sql) | ||
|
Comment on lines
+345
to
+365
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Text-segment lookups also skip tenant binding, but this matches sibling read methods
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| /// Insert a bitemporal event-instance version. | ||
| /// | ||
| /// # Errors | ||
|
|
@@ -523,9 +568,10 @@ mod tests { | |
| use crate::migration::MigrationCatalog; | ||
| use crate::model_run_sql::{CorpusSplitManifestRecord, ModelArtifactRecord, ModelRunRecord}; | ||
| use crate::relation_sql::EventRelationRecord; | ||
| use crate::segment_sql::TextSegmentRecord; | ||
| use crate::sql_session::RecordingSqlSession; | ||
| use crate::{MigrationContractError, PersistenceError}; | ||
| use temporal_core::{AvailableTime, EventTime, SystemTime}; | ||
| use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff, SystemTime}; | ||
|
|
||
| fn sample_record() -> DocumentRecord { | ||
| DocumentRecord { | ||
|
|
@@ -670,6 +716,42 @@ mod tests { | |
| ); | ||
| } | ||
|
|
||
| fn exercise_text_segment(repo: &mut LiveDocumentRepository<RecordingSqlSession>) { | ||
| let segment = TextSegmentRecord { | ||
| text_segment_id: uuid::Uuid::from_u128(7), | ||
| tenant_record_id: uuid::Uuid::nil(), | ||
| document_record_id: uuid::Uuid::from_u128(11), | ||
| start_byte: 0, | ||
| end_byte: 5, | ||
| system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), | ||
| available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"), | ||
| }; | ||
| repo.insert_text_segment(&segment).expect("segment insert"); | ||
| let executed = repo.session().executed(); | ||
| let segment_bind = executed | ||
| .iter() | ||
| .rposition(|sql| sql.contains("tepp.current_tenant_record_id")) | ||
| .expect("text segment insert must bind tenant session"); | ||
| assert!(executed[segment_bind + 1].contains("INSERT INTO text_segment")); | ||
| repo.submit_text_segment_by_id(segment.text_segment_id) | ||
| .expect("segment by id"); | ||
| let cutoff = KnowledgeCutoff::parse_rfc3339("2026-02-01T00:00:00Z").expect("cutoff"); | ||
| repo.submit_text_segments_for_document_as_of(segment.document_record_id, &cutoff) | ||
| .expect("segments as of"); | ||
| let mut inverted = segment.clone(); | ||
| inverted.end_byte = 0; | ||
| assert_eq!( | ||
| repo.insert_text_segment(&inverted), | ||
| Err(PersistenceError::InvalidTextSegment) | ||
| ); | ||
| assert!( | ||
| repo.session() | ||
| .executed() | ||
| .iter() | ||
| .any(|sql| sql.contains("INSERT INTO text_segment")) | ||
| ); | ||
| } | ||
|
|
||
| fn exercise_event_mention(repo: &mut LiveDocumentRepository<RecordingSqlSession>) { | ||
| let mention = EventMentionRecord { | ||
| event_mention_id: uuid::Uuid::from_u128(2), | ||
|
|
@@ -987,6 +1069,7 @@ mod tests { | |
| exercise_membership_assignment(&mut repo); | ||
| exercise_event_relation(&mut repo); | ||
| exercise_event_mention(&mut repo); | ||
| exercise_text_segment(&mut repo); | ||
| exercise_event_instance(&mut repo); | ||
| exercise_source_artifact(&mut repo); | ||
| exercise_retention_legal_hold(&mut repo); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| //! SQL contracts for exact-span `text_segment` rows (ADR 0008 / ADR 0013). | ||
|
|
||
| use crate::PersistenceError; | ||
| use temporal_core::{AvailableTime, KnowledgeCutoff, SystemTime}; | ||
| use uuid::Uuid; | ||
|
|
||
| /// One append-only exact-span observation on a document. | ||
| /// | ||
| /// Maps to physical `text_segment` from migration `0006`. Byte offsets are | ||
| /// half-open `[start_byte, end_byte)` over the document UTF-8 bytes. | ||
| /// `document_record_id` is required; a foreign key remains a later migration | ||
| /// (`#45` owns `0007`). | ||
|
Comment on lines
+7
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 text_segment lacks an append-only mutation trigger
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| #[derive(Clone, Debug, Eq, PartialEq)] | ||
| pub struct TextSegmentRecord { | ||
| /// Segment identity used by membership and mention observed units. | ||
| pub text_segment_id: Uuid, | ||
| /// Owning tenant boundary. | ||
| pub tenant_record_id: Uuid, | ||
| /// Document whose UTF-8 bytes this span indexes. | ||
| pub document_record_id: Uuid, | ||
| /// Inclusive start offset in UTF-8 bytes; must be `>= 0`. | ||
| pub start_byte: i64, | ||
| /// Exclusive end offset in UTF-8 bytes; must be `> start_byte`. | ||
| pub end_byte: i64, | ||
| /// System/record time when the span was asserted. | ||
| pub system_time: SystemTime, | ||
| /// Availability time of the span evidence. | ||
| pub available_time: AvailableTime, | ||
| } | ||
|
|
||
| impl TextSegmentRecord { | ||
| /// Fail-closed half-open byte-span validation. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`PersistenceError::InvalidTextSegment`] when `start_byte` is | ||
| /// negative or `end_byte` is not strictly greater than `start_byte`. | ||
| pub fn validate(&self) -> Result<(), PersistenceError> { | ||
| if self.start_byte < 0 || self.end_byte <= self.start_byte { | ||
| return Err(PersistenceError::InvalidTextSegment); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
Comment on lines
+38
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Adapter validation duplicates but does not weaken the DB CHECK constraint
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| /// Render insert SQL for a validated text segment. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`PersistenceError::InvalidTextSegment`] before any SQL is produced. | ||
| pub fn insert_text_segment_sql(record: &TextSegmentRecord) -> Result<String, PersistenceError> { | ||
| record.validate()?; | ||
| Ok(format!( | ||
| "INSERT INTO text_segment (\ | ||
| text_segment_id, tenant_record_id, document_record_id, \ | ||
| start_byte, end_byte, system_time, available_time\ | ||
| ) VALUES (\ | ||
| '{segment}'::uuid, '{tenant}'::uuid, '{document}'::uuid, \ | ||
| {start_byte}, {end_byte}, '{system}'::timestamptz, '{available}'::timestamptz\ | ||
| )", | ||
| segment = record.text_segment_id, | ||
| tenant = record.tenant_record_id, | ||
| document = record.document_record_id, | ||
| start_byte = record.start_byte, | ||
| end_byte = record.end_byte, | ||
| system = record.system_time.to_rfc3339(), | ||
| available = record.available_time.to_rfc3339(), | ||
| )) | ||
| } | ||
|
|
||
| /// Render selection of one text segment by primary key. | ||
| #[must_use] | ||
| pub fn select_text_segment_by_id_sql(text_segment_id: Uuid) -> String { | ||
| format!( | ||
| "SELECT text_segment_id, tenant_record_id, document_record_id, \ | ||
| start_byte, end_byte, system_time, available_time \ | ||
| FROM text_segment \ | ||
| WHERE text_segment_id = '{text_segment_id}'::uuid \ | ||
| LIMIT 1" | ||
| ) | ||
| } | ||
|
|
||
| /// Render cutoff-eligible segments for one document identity. | ||
| /// | ||
| /// Enforces `available_time <= knowledge_cutoff` so a historical fit cannot | ||
| /// consume a span that was unavailable at the declared cutoff. | ||
| #[must_use] | ||
| pub fn select_text_segments_for_document_as_of_sql( | ||
| document_record_id: Uuid, | ||
| knowledge_cutoff: &KnowledgeCutoff, | ||
| ) -> String { | ||
| format!( | ||
| "SELECT text_segment_id, tenant_record_id, document_record_id, \ | ||
| start_byte, end_byte, system_time, available_time \ | ||
| FROM text_segment \ | ||
| WHERE document_record_id = '{document_record_id}'::uuid \ | ||
| AND available_time <= '{cutoff}'::timestamptz \ | ||
| ORDER BY start_byte, text_segment_id", | ||
| cutoff = knowledge_cutoff.to_rfc3339(), | ||
| ) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| TextSegmentRecord, insert_text_segment_sql, select_text_segment_by_id_sql, | ||
| select_text_segments_for_document_as_of_sql, | ||
| }; | ||
| use crate::PersistenceError; | ||
| use temporal_core::{AvailableTime, KnowledgeCutoff, SystemTime}; | ||
| use uuid::Uuid; | ||
|
|
||
| fn sample() -> TextSegmentRecord { | ||
| TextSegmentRecord { | ||
| text_segment_id: Uuid::from_u128(1), | ||
| tenant_record_id: Uuid::from_u128(2), | ||
| document_record_id: Uuid::from_u128(3), | ||
| start_byte: 0, | ||
| end_byte: 5, | ||
| system_time: SystemTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("s"), | ||
| available_time: AvailableTime::parse_rfc3339("2026-01-01T00:00:00Z").expect("a"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn validate_and_select_helpers_cover_local_branches() { | ||
| let record = sample(); | ||
| record.validate().expect("valid"); | ||
| let insert = insert_text_segment_sql(&record).expect("insert"); | ||
| assert!(insert.contains("0, 5")); | ||
| assert_eq!( | ||
| insert_text_segment_sql(&TextSegmentRecord { | ||
| start_byte: 0, | ||
| end_byte: 0, | ||
| ..record.clone() | ||
| }), | ||
| Err(PersistenceError::InvalidTextSegment) | ||
| ); | ||
| let by_id = select_text_segment_by_id_sql(record.text_segment_id); | ||
| assert!(by_id.contains("LIMIT 1")); | ||
| let cutoff = KnowledgeCutoff::parse_rfc3339("2026-01-01T00:00:00Z").expect("c"); | ||
| let as_of = select_text_segments_for_document_as_of_sql(record.document_record_id, &cutoff); | ||
| assert!(as_of.contains("available_time <=")); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.