shardline_index/postgres/
mod.rs1mod index_store;
2mod record_store;
3
4use serde_json::Error as JsonError;
5use shardline_protocol::{HashParseError, RangeError};
6use shardline_storage::ObjectKeyError;
7use sqlx::{Error as SqlxError, PgPool};
8use thiserror::Error;
9
10use crate::{QuarantineCandidateError, RetentionHoldError, WebhookDeliveryError};
11
12#[derive(Debug, Clone)]
14pub struct PostgresIndexStore {
15 pool: PgPool,
16}
17
18impl PostgresIndexStore {
19 #[must_use]
21 pub const fn new(pool: PgPool) -> Self {
22 Self { pool }
23 }
24
25 #[must_use]
27 pub const fn pool(&self) -> &PgPool {
28 &self.pool
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct PostgresRecordLocator {
35 record_key: String,
36 kind: PostgresRecordKind,
37 scope_key: String,
38 file_id: String,
39 content_hash: Option<String>,
40}
41
42impl PostgresRecordLocator {
43 #[must_use]
45 pub fn record_key(&self) -> &str {
46 &self.record_key
47 }
48
49 #[must_use]
51 pub fn file_id(&self) -> &str {
52 &self.file_id
53 }
54
55 #[must_use]
57 pub fn content_hash(&self) -> Option<&str> {
58 self.content_hash.as_deref()
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct PostgresRecordStore {
65 pool: PgPool,
66}
67
68impl PostgresRecordStore {
69 #[must_use]
71 pub const fn new(pool: PgPool) -> Self {
72 Self { pool }
73 }
74
75 #[must_use]
77 pub const fn pool(&self) -> &PgPool {
78 &self.pool
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
83pub(super) enum PostgresRecordKind {
84 Latest,
85 Version,
86}
87
88impl PostgresRecordKind {
89 const fn as_str(self) -> &'static str {
90 match self {
91 Self::Latest => "latest",
92 Self::Version => "version",
93 }
94 }
95
96 fn parse(value: &str) -> Result<Self, PostgresMetadataStoreError> {
97 match value {
98 "latest" => Ok(Self::Latest),
99 "version" => Ok(Self::Version),
100 _other => Err(PostgresMetadataStoreError::InvalidRecordKind),
101 }
102 }
103}
104
105#[derive(Debug, Error)]
107pub enum PostgresMetadataStoreError {
108 #[error("postgres metadata store operation failed")]
110 Sqlx(#[source] Box<SqlxError>),
111 #[error("postgres metadata json operation failed")]
113 Json(#[from] JsonError),
114 #[error("stored hash value was invalid")]
116 HashParse(#[from] HashParseError),
117 #[error("stored object key was invalid")]
119 ObjectKey(#[from] ObjectKeyError),
120 #[error("stored chunk range was invalid")]
122 Range(#[from] RangeError),
123 #[error("stored retention hold was invalid")]
125 RetentionHold(#[from] RetentionHoldError),
126 #[error("stored quarantine candidate was invalid")]
128 QuarantineCandidate(#[from] QuarantineCandidateError),
129 #[error("stored webhook delivery was invalid")]
131 WebhookDelivery(#[from] WebhookDeliveryError),
132 #[error("stored integer exceeded the supported range")]
134 IntegerOutOfRange,
135 #[error("postgres record locator was not found")]
137 RecordNotFound,
138 #[error("stored record kind was invalid")]
140 InvalidRecordKind,
141 #[error("invalid repository type: {0}")]
143 InvalidRepoType(String),
144}
145
146impl From<SqlxError> for PostgresMetadataStoreError {
147 fn from(value: SqlxError) -> Self {
148 Self::Sqlx(Box::new(value))
149 }
150}
151
152pub(super) fn u64_to_i64(value: u64) -> Result<i64, PostgresMetadataStoreError> {
153 i64::try_from(value).map_err(|_error| PostgresMetadataStoreError::IntegerOutOfRange)
154}
155
156pub(super) fn i64_to_u64(value: i64) -> Result<u64, PostgresMetadataStoreError> {
157 u64::try_from(value).map_err(|_error| PostgresMetadataStoreError::IntegerOutOfRange)
158}
159
160#[cfg(test)]
161mod tests {
162 use shardline_protocol::{ChunkRange, RepositoryProvider, RepositoryScope, ShardlineHash};
163
164 use super::PostgresRecordKind;
165 use super::index_store::PostgresFileReconstructionRecord;
166 use super::record_store::record_locator;
167 use crate::{
168 FileId, FileReconstruction, FileRecord, ReconstructionTerm, RepositoryRecordScope, XorbId,
169 record_key::record_key as shared_record_key,
170 record_key::{
171 repository_record_scope_key as shared_repository_record_scope_key,
172 repository_scope_key as shared_repository_scope_key,
173 },
174 xet_hash_hex_string,
175 };
176
177 #[test]
178 fn postgres_reconstruction_record_roundtrips_domain_terms() {
179 let hash = ShardlineHash::from_bytes([11; 32]);
180 let range = ChunkRange::new(1, 4);
181 assert!(range.is_ok());
182 let Ok(range) = range else {
183 return;
184 };
185 let reconstruction =
186 FileReconstruction::new(vec![ReconstructionTerm::new(XorbId::new(hash), range, 256)]);
187
188 let record = PostgresFileReconstructionRecord::from_domain(&reconstruction);
189 let restored = record.into_domain();
190
191 assert!(matches!(restored, Ok(ref restored) if restored == &reconstruction));
192 }
193
194 #[test]
195 fn postgres_record_keys_distinguish_scope_file_and_kind_without_parsing() {
196 let first_scope =
197 RepositoryScope::new(RepositoryProvider::GitHub, "team:a", "asset", Some("main"));
198 let second_scope =
199 RepositoryScope::new(RepositoryProvider::GitHub, "team", "a:asset", Some("main"));
200 assert!(first_scope.is_ok());
201 assert!(second_scope.is_ok());
202 let (Ok(first_scope), Ok(second_scope)) = (first_scope, second_scope) else {
203 return;
204 };
205
206 let first_key = shared_repository_scope_key(Some(&first_scope));
207 let second_key = shared_repository_scope_key(Some(&second_scope));
208
209 assert_ne!(first_key, second_key);
210 assert_ne!(
211 shared_record_key(
212 PostgresRecordKind::Latest.as_str(),
213 &first_key,
214 "file",
215 None
216 ),
217 shared_record_key(
218 PostgresRecordKind::Version.as_str(),
219 &first_key,
220 "file",
221 Some("a".repeat(64).as_str())
222 )
223 );
224 }
225
226 #[test]
227 fn postgres_latest_locator_ignores_content_hash_for_stable_head_keys() {
228 let scope = RepositoryScope::new(RepositoryProvider::GitLab, "team", "assets", None);
229 assert!(scope.is_ok());
230 let Ok(scope) = scope else {
231 return;
232 };
233 let first = file_record(scope.clone(), "a");
234 let second = file_record(scope, "b");
235 let first_key = record_locator(PostgresRecordKind::Latest, &first, None);
236 let second_key = record_locator(PostgresRecordKind::Latest, &second, None);
237
238 assert_eq!(first_key, second_key);
239 }
240
241 #[test]
242 fn postgres_repository_scope_key_prefix_matches_all_repository_revisions_only() {
243 let repository = RepositoryRecordScope::new(RepositoryProvider::GitHub, "team", "assets");
244 let revisionless = RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", None);
245 let revisioned =
246 RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
247 let other = RepositoryScope::new(RepositoryProvider::GitHub, "team", "other", Some("main"));
248 assert!(revisionless.is_ok());
249 assert!(revisioned.is_ok());
250 assert!(other.is_ok());
251 let (Ok(revisionless), Ok(revisioned), Ok(other)) = (revisionless, revisioned, other)
252 else {
253 return;
254 };
255
256 let repository_key = shared_repository_record_scope_key(&repository);
257 let revisionless_key = shared_repository_scope_key(Some(&revisionless));
258 let revisioned_key = shared_repository_scope_key(Some(&revisioned));
259 let other_key = shared_repository_scope_key(Some(&other));
260
261 assert_eq!(repository_key, revisionless_key);
262 assert!(revisioned_key.starts_with(&repository_key));
263 assert!(!other_key.starts_with(&repository_key));
264 }
265
266 #[test]
267 fn postgres_lifecycle_migrations_reject_inverted_timelines() {
268 let metadata_migration =
269 include_str!("../../migrations/20260417000000_metadata_store.up.sql");
270 let retention_migration =
271 include_str!("../../migrations/20260417010000_retention_holds.up.sql");
272
273 assert!(metadata_migration.contains(
274 "CHECK (delete_after_unix_seconds >= first_seen_unreachable_at_unix_seconds)"
275 ));
276 assert!(retention_migration.contains("release_after_unix_seconds >= held_at_unix_seconds"));
277 }
278
279 fn file_record(scope: RepositoryScope, content_seed: &str) -> FileRecord {
280 let hash = ShardlineHash::from_bytes([12; 32]);
281 let file_id = xet_hash_hex_string(FileId::new(hash).hash());
282 FileRecord {
283 file_id,
284 content_hash: content_seed.repeat(64),
285 total_bytes: 0,
286 chunk_size: 0,
287 repository_scope: Some(scope),
288 chunks: Vec::new(),
289 }
290 }
291}