Skip to content

Commit 77e89ae

Browse files
committed
feat(op-work-packages): update path with optimistic locking
Add the WorkPackage update write path on top of the #66 CreateService. - UpdateWorkPackage — a sparse patch write-model (Deserialize); each Some field is applied, None leaves the value untouched. (Un-setting a nullable FK to NULL is a documented follow-up — needs Option<Option<_>>.) - WorkPackageStore::update — persist changes to an existing row. - WorkPackageService::update(id, expected_lock_version, changes) — optimistic locking: NotFound if absent, Conflict{expected,actual} if the caller's version is stale, Validation if the patch would blank the subject; on success applies the patch, bumps lock_version, refreshes updated_at, persists, returns the entity. - WorkPackageError::Conflict variant. - MemoryWorkPackageStore::update. 4 new update tests (apply+bump, stale-version conflict, NotFound, blank- subject rejection) -> 16 tests pass. fmt clean; op-work-packages clippy-clean (0 warnings in this crate; transitive op-core -D-warnings lints pre-existing). Follow-up: emit a change event on create/update so the composition layer can journal it (op-work-packages <-> op-journals), without coupling the crate to op-journals.
1 parent 832004a commit 77e89ae

2 files changed

Lines changed: 215 additions & 2 deletions

File tree

crates/op-work-packages/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ pub mod work_package_service;
1313

1414
pub use work_package::{DoneRatio, WorkPackage};
1515
pub use work_package_service::{
16-
MemoryWorkPackageStore, NewWorkPackage, WorkPackageError, WorkPackageResult,
16+
MemoryWorkPackageStore, NewWorkPackage, UpdateWorkPackage, WorkPackageError, WorkPackageResult,
1717
WorkPackageService, WorkPackageStore,
1818
};

crates/op-work-packages/src/work_package_service.rs

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ use std::sync::atomic::{AtomicI64, Ordering};
1313
use std::sync::{Arc, RwLock};
1414

1515
use async_trait::async_trait;
16+
use chrono::Utc;
1617
use op_core::traits::Id;
1718
use op_core::types::Formattable;
1819
use serde::Deserialize;
1920
use thiserror::Error;
2021

21-
use crate::work_package::WorkPackage;
22+
use crate::work_package::{DoneRatio, WorkPackage};
2223

2324
/// Work package service errors.
2425
#[derive(Debug, Error)]
@@ -29,6 +30,15 @@ pub enum WorkPackageError {
2930
/// The write-model failed validation (e.g. blank subject).
3031
#[error("validation failed: {0}")]
3132
Validation(String),
33+
/// Optimistic-lock conflict — the caller's expected `lock_version` is
34+
/// stale (someone else updated the row first).
35+
#[error("version conflict: expected {expected}, found {actual}")]
36+
Conflict {
37+
/// The `lock_version` the caller believed was current.
38+
expected: i32,
39+
/// The `lock_version` actually stored.
40+
actual: i32,
41+
},
3242
/// The underlying store failed.
3343
#[error("database error: {0}")]
3444
Database(String),
@@ -110,13 +120,75 @@ impl NewWorkPackage {
110120
}
111121
}
112122

123+
/// Update write-model — a sparse patch of the mutable attributes. Each `Some`
124+
/// field is applied; `None` leaves the current value untouched.
125+
///
126+
/// Un-setting a nullable FK back to `NULL` (e.g. clearing an assignee) is a
127+
/// deliberate follow-up — this shape only *sets* values, matching the common
128+
/// PATCH case; the `None`-means-"no change" convention can't also express
129+
/// "change to null" without an `Option<Option<_>>`, which lands later.
130+
#[derive(Debug, Clone, Default, Deserialize)]
131+
pub struct UpdateWorkPackage {
132+
/// New subject (must stay non-blank — validated on apply).
133+
#[serde(default)]
134+
pub subject: Option<String>,
135+
/// New description (markdown source).
136+
#[serde(default)]
137+
pub description: Option<String>,
138+
/// New type FK.
139+
#[serde(default)]
140+
pub type_id: Option<Id>,
141+
/// New status FK.
142+
#[serde(default)]
143+
pub status_id: Option<Id>,
144+
/// New priority FK.
145+
#[serde(default)]
146+
pub priority_id: Option<Id>,
147+
/// New assignee FK.
148+
#[serde(default)]
149+
pub assigned_to_id: Option<Id>,
150+
/// New progress `0..=100` (clamped).
151+
#[serde(default)]
152+
pub done_ratio: Option<u8>,
153+
}
154+
155+
impl UpdateWorkPackage {
156+
/// Apply the set (`Some`) fields onto `wp`, leaving `None` fields as-is.
157+
/// Does not touch `lock_version` / timestamps — the service owns those.
158+
fn apply_to(&self, wp: &mut WorkPackage) {
159+
if let Some(subject) = &self.subject {
160+
wp.subject = subject.clone();
161+
}
162+
if let Some(body) = &self.description {
163+
wp.description = Formattable::markdown(body.clone());
164+
}
165+
if let Some(type_id) = self.type_id {
166+
wp.type_id = type_id;
167+
}
168+
if let Some(status_id) = self.status_id {
169+
wp.status_id = status_id;
170+
}
171+
if let Some(priority_id) = self.priority_id {
172+
wp.priority_id = Some(priority_id);
173+
}
174+
if let Some(assigned_to_id) = self.assigned_to_id {
175+
wp.assigned_to_id = Some(assigned_to_id);
176+
}
177+
if let Some(done_ratio) = self.done_ratio {
178+
wp.done_ratio = DoneRatio::new(done_ratio);
179+
}
180+
}
181+
}
182+
113183
/// Persistence port for work packages (mirrors OpenProject's repository).
114184
#[async_trait]
115185
pub trait WorkPackageStore: Send + Sync {
116186
/// Insert a new work package; returns the assigned id.
117187
async fn insert(&self, wp: &WorkPackage) -> WorkPackageResult<Id>;
118188
/// Fetch by id.
119189
async fn get(&self, id: Id) -> WorkPackageResult<Option<WorkPackage>>;
190+
/// Persist changes to an existing work package (matched by `wp.id`).
191+
async fn update(&self, wp: &WorkPackage) -> WorkPackageResult<()>;
120192
/// All work packages in a project.
121193
async fn list_for_project(&self, project_id: Id) -> WorkPackageResult<Vec<WorkPackage>>;
122194
}
@@ -165,6 +237,43 @@ impl WorkPackageService {
165237
.ok_or(WorkPackageError::NotFound(id))
166238
}
167239

240+
/// Apply a sparse update with **optimistic locking**.
241+
/// `expected_lock_version` must equal the stored row's `lock_version`,
242+
/// otherwise [`WorkPackageError::Conflict`] (someone else wrote first). On
243+
/// success the patch is applied, `lock_version` is bumped, `updated_at`
244+
/// refreshed, and the row persisted; the updated entity is returned.
245+
///
246+
/// # Errors
247+
///
248+
/// [`WorkPackageError::NotFound`] if `id` is absent;
249+
/// [`WorkPackageError::Conflict`] on a stale `expected_lock_version`;
250+
/// [`WorkPackageError::Validation`] if the patch would blank the subject;
251+
/// [`WorkPackageError::Database`] on store failure.
252+
pub async fn update(
253+
&self,
254+
id: Id,
255+
expected_lock_version: i32,
256+
changes: UpdateWorkPackage,
257+
) -> WorkPackageResult<WorkPackage> {
258+
let mut wp = self.find(id).await?;
259+
if wp.lock_version != expected_lock_version {
260+
return Err(WorkPackageError::Conflict {
261+
expected: expected_lock_version,
262+
actual: wp.lock_version,
263+
});
264+
}
265+
changes.apply_to(&mut wp);
266+
if wp.subject.trim().is_empty() {
267+
return Err(WorkPackageError::Validation(
268+
"subject can't be blank".to_string(),
269+
));
270+
}
271+
wp.lock_version += 1;
272+
wp.updated_at = Utc::now();
273+
self.store.update(&wp).await?;
274+
Ok(wp)
275+
}
276+
168277
/// All work packages in a project.
169278
///
170279
/// # Errors
@@ -218,6 +327,19 @@ impl WorkPackageStore for MemoryWorkPackageStore {
218327
Ok(rows.iter().find(|wp| wp.id == Some(id)).cloned())
219328
}
220329

330+
async fn update(&self, wp: &WorkPackage) -> WorkPackageResult<()> {
331+
let mut rows = self
332+
.rows
333+
.write()
334+
.map_err(|e| WorkPackageError::Database(e.to_string()))?;
335+
let slot = rows
336+
.iter_mut()
337+
.find(|existing| existing.id == wp.id)
338+
.ok_or(WorkPackageError::NotFound(wp.id.unwrap_or(0)))?;
339+
*slot = wp.clone();
340+
Ok(())
341+
}
342+
221343
async fn list_for_project(&self, project_id: Id) -> WorkPackageResult<Vec<WorkPackage>> {
222344
let rows = self
223345
.rows
@@ -321,4 +443,95 @@ mod tests {
321443
assert!(wp.id.is_none());
322444
assert_eq!(wp.status_id, 3);
323445
}
446+
447+
#[tokio::test]
448+
async fn update_applies_changes_and_bumps_lock_version() {
449+
let svc = service();
450+
let created = svc.create(input()).await.unwrap();
451+
let id = created.id.unwrap();
452+
assert_eq!(created.lock_version, 0);
453+
454+
let changes = UpdateWorkPackage {
455+
subject: Some("Renamed".to_string()),
456+
status_id: Some(9),
457+
done_ratio: Some(150), // clamps to 100
458+
..Default::default()
459+
};
460+
let updated = svc.update(id, 0, changes).await.unwrap();
461+
assert_eq!(updated.subject, "Renamed");
462+
assert_eq!(updated.status_id, 9);
463+
assert_eq!(updated.done_ratio.value(), 100);
464+
assert_eq!(updated.lock_version, 1, "lock_version bumps on update");
465+
// Persisted: a re-read sees the new state + version.
466+
let reread = svc.find(id).await.unwrap();
467+
assert_eq!(reread.subject, "Renamed");
468+
assert_eq!(reread.lock_version, 1);
469+
}
470+
471+
#[tokio::test]
472+
async fn update_rejects_stale_lock_version_as_conflict() {
473+
let svc = service();
474+
let id = svc.create(input()).await.unwrap().id.unwrap();
475+
// First update at version 0 → now version 1.
476+
svc.update(
477+
id,
478+
0,
479+
UpdateWorkPackage {
480+
subject: Some("v1".into()),
481+
..Default::default()
482+
},
483+
)
484+
.await
485+
.unwrap();
486+
// Second update still claiming version 0 → conflict (actual is 1).
487+
let err = svc
488+
.update(
489+
id,
490+
0,
491+
UpdateWorkPackage {
492+
subject: Some("v2".into()),
493+
..Default::default()
494+
},
495+
)
496+
.await
497+
.unwrap_err();
498+
assert!(
499+
matches!(
500+
err,
501+
WorkPackageError::Conflict {
502+
expected: 0,
503+
actual: 1
504+
}
505+
),
506+
"stale version → Conflict, got {err:?}"
507+
);
508+
}
509+
510+
#[tokio::test]
511+
async fn update_unknown_id_is_not_found() {
512+
let svc = service();
513+
let err = svc
514+
.update(999, 0, UpdateWorkPackage::default())
515+
.await
516+
.unwrap_err();
517+
assert!(matches!(err, WorkPackageError::NotFound(999)), "{err:?}");
518+
}
519+
520+
#[tokio::test]
521+
async fn update_rejects_blanking_the_subject() {
522+
let svc = service();
523+
let id = svc.create(input()).await.unwrap().id.unwrap();
524+
let err = svc
525+
.update(
526+
id,
527+
0,
528+
UpdateWorkPackage {
529+
subject: Some(" ".into()),
530+
..Default::default()
531+
},
532+
)
533+
.await
534+
.unwrap_err();
535+
assert!(matches!(err, WorkPackageError::Validation(_)), "{err:?}");
536+
}
324537
}

0 commit comments

Comments
 (0)