refactor: normalize review and comment storage

This commit is contained in:
2026-10-18 10:29:27 +02:00
parent 3c270ed7b5
commit b062a79bb5
5 changed files with 351 additions and 93 deletions
+227 -44
View File
@@ -281,12 +281,36 @@ fn directory_from(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf,
.ok_or_else(|| "Cannot locate review storage: HOME must be an absolute directory".into())
}
// ponytail: whole-review JSON commits; normalize comment rows if large reviews make writes costly.
const SCHEMA: &str = "CREATE TABLE reviews (
const REVIEWS_SCHEMA: &str = "CREATE TABLE reviews (
id INTEGER PRIMARY KEY CHECK (id > 0),
root TEXT NOT NULL,
revision INTEGER NOT NULL CHECK (revision > 0),
document TEXT NOT NULL CHECK (length(CAST(document AS BLOB)) <= 67108864)
summary TEXT NOT NULL,
verdict TEXT CHECK (verdict IN ('looks_good', 'changes_requested'))
) STRICT";
// SQLite integers are signed; decimal text preserves the model's full u64 comment IDs.
const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
id TEXT NOT NULL CHECK (
length(id) BETWEEN 1 AND 20 AND id NOT GLOB '*[^0-9]*'
AND substr(id, 1, 1) BETWEEN '1' AND '9'
AND (length(id) < 20 OR id <= '18446744073709551615')
),
position INTEGER NOT NULL CHECK (position >= 0),
body TEXT NOT NULL,
resolved INTEGER NOT NULL CHECK (resolved IN (0, 1)),
anchor_revision TEXT NOT NULL CHECK (length(anchor_revision) > 0),
path TEXT NOT NULL,
side TEXT NOT NULL CHECK (side IN ('before', 'after')),
start_row INTEGER NOT NULL CHECK (start_row >= 0),
start_column INTEGER NOT NULL CHECK (start_column >= 0),
end_row INTEGER NOT NULL CHECK (end_row >= 0),
end_column INTEGER NOT NULL CHECK (end_column >= 0),
content TEXT NOT NULL,
PRIMARY KEY (review_id, id),
UNIQUE (review_id, position),
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
) STRICT";
pub struct Store {
@@ -316,6 +340,9 @@ impl Store {
)
.map_err(database_error)?;
Self::check_schema(&connection)?;
connection
.pragma_update(None, "foreign_keys", true)
.map_err(database_error)?;
Ok(Some(Self { connection }))
}
@@ -330,7 +357,7 @@ impl Store {
.map_err(database_error)?
.collect::<Result<_, _>>()
.map_err(database_error)?;
if version != 1 || schema != [SCHEMA] {
if version != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_SCHEMA] {
return Err(format!(
"Unsupported review database schema (version {version}); existing data was not changed"
));
@@ -378,6 +405,9 @@ impl Store {
| OpenFlags::SQLITE_OPEN_NOFOLLOW,
)
.map_err(database_error)?;
connection
.pragma_update(None, "foreign_keys", true)
.map_err(database_error)?;
connection
.busy_timeout(std::time::Duration::ZERO)
.map_err(database_error)?;
@@ -385,9 +415,14 @@ impl Store {
let transaction = connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error)?;
transaction.execute_batch(SCHEMA).map_err(database_error)?;
transaction
.pragma_update(None, "user_version", 1)
.execute_batch(REVIEWS_SCHEMA)
.map_err(database_error)?;
transaction
.execute_batch(COMMENTS_SCHEMA)
.map_err(database_error)?;
transaction
.pragma_update(None, "user_version", 2)
.map_err(database_error)?;
transaction.commit().map_err(database_error)?;
} else {
@@ -407,17 +442,24 @@ impl Store {
let root_text = root
.to_str()
.ok_or("Review repository root must be UTF-8")?;
let mut statement = self
let transaction = self
.connection
.prepare(
"SELECT id, root, revision, document FROM reviews WHERE root = ?1 ORDER BY id DESC",
)
.unchecked_transaction()
.map_err(database_error)?;
let mut rows = statement.query([root_text]).map_err(database_error)?;
let mut reviews = Vec::new();
while let Some(row) = rows.next().map_err(database_error)? {
reviews.push(stored_review(row, &root)?);
}
let reviews = {
let mut statement = transaction
.prepare(
"SELECT id, root, revision, summary, verdict FROM reviews WHERE root = ?1 ORDER BY id DESC",
)
.map_err(database_error)?;
let mut rows = statement.query([root_text]).map_err(database_error)?;
let mut reviews = Vec::new();
while let Some(row) = rows.next().map_err(database_error)? {
reviews.push(stored_review(&transaction, row, &root)?);
}
reviews
};
transaction.commit().map_err(database_error)?;
Ok(reviews)
}
@@ -426,18 +468,25 @@ impl Store {
let root_text = root
.to_str()
.ok_or("Review repository root must be UTF-8")?;
let mut statement = self
let transaction = self
.connection
.prepare("SELECT id, root, revision, document FROM reviews WHERE id = ?1 AND root = ?2")
.unchecked_transaction()
.map_err(database_error)?;
let mut rows = statement
.query(params![id, root_text])
.map_err(database_error)?;
let row = rows
.next()
.map_err(database_error)?
.ok_or("Review does not exist in this repository")?;
stored_review(row, &root)
let review = {
let mut statement = transaction
.prepare("SELECT id, root, revision, summary, verdict FROM reviews WHERE id = ?1 AND root = ?2")
.map_err(database_error)?;
let mut rows = statement
.query(params![id, root_text])
.map_err(database_error)?;
let row = rows
.next()
.map_err(database_error)?
.ok_or("Review does not exist in this repository")?;
stored_review(&transaction, row, &root)?
};
transaction.commit().map_err(database_error)?;
Ok(review)
}
pub fn save(&mut self, review: &mut Review) -> Result<(), String> {
@@ -457,19 +506,34 @@ impl Store {
None if review.storage_revision == 0 => 1,
_ => return Err("Invalid review storage identity".into()),
};
let bytes = serde_json::to_vec(review).map_err(|error| error.to_string())?;
if bytes.len() > LIMIT {
let mut size = review.root.as_os_str().len() + review.summary.len();
for comment in &review.comments {
size = size
.checked_add(
comment.body.len()
+ comment.anchor.revision.len()
+ comment.anchor.path.as_os_str().len()
+ comment.anchor.content.len()
+ 64,
)
.ok_or("Review exceeds the 64 MiB limit")?;
}
if size > LIMIT {
return Err("Review exceeds the 64 MiB limit".into());
}
let document = std::str::from_utf8(&bytes).map_err(|error| error.to_string())?;
let verdict = match review.verdict {
Some(Verdict::LooksGood) => Some("looks_good"),
Some(Verdict::ChangesRequested) => Some("changes_requested"),
None => None,
};
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(database_error)?;
let id = if let Some(id) = review.storage_id {
let changed = transaction.execute(
"UPDATE reviews SET revision = ?1, document = ?2 WHERE id = ?3 AND root = ?4 AND revision = ?5",
params![revision, document, id, root, review.storage_revision],
"UPDATE reviews SET revision = ?1, summary = ?2, verdict = ?3 WHERE id = ?4 AND root = ?5 AND revision = ?6",
params![revision, review.summary, verdict, id, root, review.storage_revision],
).map_err(database_error)?;
if changed != 1 {
return Err(
@@ -480,12 +544,46 @@ impl Store {
} else {
transaction
.execute(
"INSERT INTO reviews (root, revision, document) VALUES (?1, ?2, ?3)",
params![root, revision, document],
"INSERT INTO reviews (root, revision, summary, verdict) VALUES (?1, ?2, ?3, ?4)",
params![root, revision, review.summary, verdict],
)
.map_err(database_error)?;
transaction.last_insert_rowid()
};
// ponytail: replace comment rows per save; diff rows if snapshot writes become costly.
transaction
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
.map_err(database_error)?;
{
let mut insert = transaction.prepare(
"INSERT INTO comments (review_id, id, position, body, resolved, anchor_revision, path, side,
start_row, start_column, end_row, end_column, content)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
).map_err(database_error)?;
for (position, comment) in review.comments.iter().enumerate() {
let anchor = &comment.anchor;
insert
.execute(params![
id,
comment.id.to_string(),
sql_index(position)?,
comment.body,
comment.resolved,
anchor.revision,
anchor.path.to_str().unwrap(),
match anchor.side {
Side::Before => "before",
Side::After => "after",
},
sql_index(anchor.start.row)?,
sql_index(anchor.start.column)?,
sql_index(anchor.end.row)?,
sql_index(anchor.end.column)?,
anchor.content,
])
.map_err(database_error)?;
}
}
transaction.commit().map_err(database_error)?;
review.storage_id = Some(id);
review.storage_revision = revision;
@@ -497,26 +595,111 @@ fn database_error(error: rusqlite::Error) -> String {
format!("Review database: {error}")
}
fn stored_review(row: &rusqlite::Row<'_>, root: &Path) -> Result<Review, String> {
fn sql_index(value: usize) -> Result<i64, String> {
i64::try_from(value).map_err(|_| "Comment coordinate exceeds SQLite's integer range".into())
}
fn stored_index(row: &rusqlite::Row<'_>, index: usize) -> Result<usize, String> {
usize::try_from(row.get::<_, i64>(index).map_err(database_error)?)
.map_err(|_| "Stored comment coordinate exceeds this platform's range".into())
}
fn stored_review(
connection: &Connection,
row: &rusqlite::Row<'_>,
root: &Path,
) -> Result<Review, String> {
let id: i64 = row.get(0).map_err(database_error)?;
let stored_root: String = row.get(1).map_err(database_error)?;
let stored_root = row
.get_ref(1)
.map_err(database_error)?
.as_str()
.map_err(|error| error.to_string())?;
let revision: i64 = row.get(2).map_err(database_error)?;
if id <= 0 || revision <= 0 || Path::new(&stored_root) != root {
if id <= 0 || revision <= 0 || Path::new(stored_root) != root {
return Err("Invalid review database identity".into());
}
let document = row.get_ref(3).map_err(database_error)?;
let bytes = document
let summary = row
.get_ref(3)
.map_err(database_error)?
.as_str()
.map_err(|error| format!("Invalid stored review: {error}"))?
.as_bytes();
if bytes.len() > LIMIT {
.map_err(|error| error.to_string())?;
let comment_bytes: i64 = connection
.query_row(
"SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0)
FROM comments WHERE review_id = ?1",
[id],
|row| row.get(0),
)
.map_err(database_error)?;
if comment_bytes < 0
|| comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
{
return Err("Review exceeds the 64 MiB limit".into());
}
let mut review = Review::from_json(bytes)?;
review.validate()?;
if review.root != root {
return Err("Stored review belongs to a different repository".into());
let mut review = Review {
root: root.to_owned(),
..Review::default()
};
review.summary = summary.to_owned();
review.verdict = match row
.get::<_, Option<String>>(4)
.map_err(database_error)?
.as_deref()
{
Some("looks_good") => Some(Verdict::LooksGood),
Some("changes_requested") => Some(Verdict::ChangesRequested),
None => None,
_ => return Err("Invalid stored review verdict".into()),
};
let mut statement = connection
.prepare(
"SELECT id, position, body, resolved, anchor_revision, path, side,
start_row, start_column, end_row, end_column, content
FROM comments WHERE review_id = ?1 ORDER BY position",
)
.map_err(database_error)?;
let mut rows = statement.query([id]).map_err(database_error)?;
while let Some(row) = rows.next().map_err(database_error)? {
if stored_index(row, 1)? != review.comments.len() {
return Err("Invalid stored comment order".into());
}
let side = match row.get::<_, String>(6).map_err(database_error)?.as_str() {
"before" => Side::Before,
"after" => Side::After,
_ => return Err("Invalid stored comment side".into()),
};
let resolved = match row.get::<_, i64>(3).map_err(database_error)? {
0 => false,
1 => true,
_ => return Err("Invalid stored comment resolution".into()),
};
review.comments.push(Comment {
id: row
.get::<_, String>(0)
.map_err(database_error)?
.parse::<u64>()
.map_err(|_| "Invalid stored comment ID")?,
body: row.get(2).map_err(database_error)?,
resolved,
anchor: Anchor {
revision: row.get(4).map_err(database_error)?,
path: PathBuf::from(row.get::<_, String>(5).map_err(database_error)?),
side,
start: crate::plugin::TextPosition {
row: stored_index(row, 7)?,
column: stored_index(row, 8)?,
},
end: crate::plugin::TextPosition {
row: stored_index(row, 9)?,
column: stored_index(row, 10)?,
},
content: row.get(11).map_err(database_error)?,
},
});
}
review.validate()?;
review.storage_id = Some(id);
review.storage_revision = revision;
Ok(review)