Compare commits

...

10 Commits

Author SHA1 Message Date
logaritmisk 2938649d62 fix(db): skip UPDATE and audit on no-op object update (keep updated_at consistent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:40:27 +02:00
logaritmisk a690c60ec6 refactor(db): delete_object via rows_affected; test update/delete-missing and field clearing 2026-06-02 09:36:44 +02:00
logaritmisk 9e1c88b294 feat(db): add catalogue object update/delete with audited field diffs
update_object records only changed fields as audit diffs and skips the
audit entry for no-op updates; delete_object records a Deleted entry.
Both operations are atomic on the caller's connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:32:16 +02:00
logaritmisk 616a6f05c6 refactor(db): DRY object SELECT columns, consistent date json; test date + all-none round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:29:40 +02:00
logaritmisk e0c0187f29 feat(db): add catalogue object create/read/list with audit on create
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:24:03 +02:00
logaritmisk 95357f01dd feat(db): non-empty CHECK constraints on object text columns 2026-06-02 09:21:08 +02:00
logaritmisk c1dda280e2 feat(db): add object table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:18:03 +02:00
logaritmisk bf332ac0ae test(domain): pin Visibility serde output to as_str 2026-06-02 09:16:53 +02:00
logaritmisk 266f914b88 feat(domain): add catalogue object types (Visibility, ObjectInput, CatalogueObject) 2026-06-02 09:13:54 +02:00
logaritmisk ed608c6e37 docs: add Plan 3 (Catalogue core) implementation plan
Typed inventory-minimum object record + CRUD; first consumer of the audit spine
(create/update/delete record audit entries with field-level diffs in the write tx).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:12:41 +02:00
11 changed files with 1419 additions and 2 deletions
+2 -1
View File
@@ -10,7 +10,8 @@ thiserror.workspace = true
domain = { path = "../domain" } domain = { path = "../domain" }
uuid.workspace = true uuid.workspace = true
time.workspace = true time.workspace = true
serde_json.workspace = true
[dev-dependencies] [dev-dependencies]
tokio.workspace = true tokio.workspace = true
serde_json.workspace = true time.workspace = true
+19
View File
@@ -0,0 +1,19 @@
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
CREATE TABLE object (
id UUID PRIMARY KEY,
object_number TEXT NOT NULL UNIQUE CHECK (object_number <> ''),
object_name TEXT NOT NULL CHECK (object_name <> ''),
number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
brief_description TEXT CHECK (brief_description <> ''),
current_location TEXT CHECK (current_location <> ''),
current_owner TEXT CHECK (current_owner <> ''),
recorder TEXT CHECK (recorder <> ''),
recording_date DATE,
visibility TEXT NOT NULL DEFAULT 'draft'
CHECK (visibility IN ('draft', 'internal', 'public')),
-- updated_at is maintained by the repository (set to now() on update).
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX object_visibility_idx ON object (visibility);
+261
View File
@@ -0,0 +1,261 @@
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
//! on the caller's connection, so the change and its audit entry commit together.
use domain::{
AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput,
Visibility,
};
use serde_json::{Value, json};
use sqlx::Row;
use crate::audit;
/// The entity_type recorded in the audit log for catalogue objects.
const ENTITY_TYPE: &str = "object";
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
brief_description, current_location, current_owner, recorder, recording_date, \
visibility, created_at, updated_at";
/// Create an object and record a `created` audit entry, both on `conn`
/// (pass a transaction connection `&mut *tx` so they commit atomically).
pub async fn create_object(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
input: &ObjectInput,
) -> Result<ObjectId, sqlx::Error> {
let id = ObjectId::new();
sqlx::query(
"INSERT INTO object \
(id, object_number, object_name, number_of_objects, brief_description, \
current_location, current_owner, recorder, recording_date, visibility) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(id.to_uuid())
.bind(&input.object_number)
.bind(&input.object_name)
.bind(input.number_of_objects)
.bind(input.brief_description.as_deref())
.bind(input.current_location.as_deref())
.bind(input.current_owner.as_deref())
.bind(input.recorder.as_deref())
.bind(input.recording_date)
.bind(input.visibility.as_str())
.execute(&mut *conn)
.await?;
let changes = creation_changes(input);
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes,
},
)
.await?;
Ok(id)
}
/// Fetch one object by id.
pub async fn object_by_id<'e, E>(
executor: E,
id: ObjectId,
) -> Result<Option<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1");
let row = sqlx::query(&sql)
.bind(id.to_uuid())
.fetch_optional(executor)
.await?;
row.map(map_object).transpose()
}
/// List all objects, ordered by object number.
pub async fn list_objects<'e, E>(executor: E) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
// TODO: add LIMIT/keyset pagination before exposing this via the API.
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number");
let rows = sqlx::query(&sql).fetch_all(executor).await?;
rows.into_iter().map(map_object).collect()
}
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
let visibility_str: String = row.try_get("visibility")?;
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
sqlx::Error::Decode(format!("unknown visibility: {visibility_str}").into())
})?;
Ok(CatalogueObject {
id: ObjectId::from_uuid(row.try_get("id")?),
object_number: row.try_get("object_number")?,
object_name: row.try_get("object_name")?,
number_of_objects: row.try_get("number_of_objects")?,
brief_description: row.try_get("brief_description")?,
current_location: row.try_get("current_location")?,
current_owner: row.try_get("current_owner")?,
recorder: row.try_get("recorder")?,
recording_date: row.try_get("recording_date")?,
visibility,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
/// The mutable fields as `(name, value)` pairs, for building audit diffs.
/// `None` means the field is unset (NULL).
fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
vec![
("object_number", Some(json!(input.object_number))),
("object_name", Some(json!(input.object_name))),
("number_of_objects", Some(json!(input.number_of_objects))),
(
"brief_description",
input.brief_description.as_ref().map(|v| json!(v)),
),
(
"current_location",
input.current_location.as_ref().map(|v| json!(v)),
),
(
"current_owner",
input.current_owner.as_ref().map(|v| json!(v)),
),
("recorder", input.recorder.as_ref().map(|v| json!(v))),
("recording_date", input.recording_date.map(|d| json!(d))),
("visibility", Some(json!(input.visibility.as_str()))),
]
}
/// Audit changes for a newly created object: every set field as an `after` value.
/// Unset (`None`) optional fields are omitted — absence is conveyed by their not
/// appearing, consistent with `FieldChange`'s `None`-means-no-value convention.
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
field_values(input)
.into_iter()
.filter_map(|(field, after)| {
after.map(|a| FieldChange {
field: field.to_owned(),
before: None,
after: Some(a),
})
})
.collect()
}
/// Audit changes between two field sets: only the fields whose value changed.
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
field_values(old)
.into_iter()
.zip(field_values(new))
.filter_map(|((field, before), (_, after))| {
if before != after {
Some(FieldChange {
field: field.to_owned(),
before,
after,
})
} else {
None
}
})
.collect()
}
/// Update an object and record an `updated` audit entry with field-level diffs,
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
/// (no fields changed) records no audit entry.
pub async fn update_object(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: ObjectId,
input: &ObjectInput,
) -> Result<bool, sqlx::Error> {
let Some(old) = object_by_id(&mut *conn, id).await? else {
return Ok(false);
};
let changes = update_changes(&old.to_input(), input);
if changes.is_empty() {
// No-op: don't touch updated_at or the audit log.
return Ok(true);
}
sqlx::query(
"UPDATE object SET \
object_number = $2, object_name = $3, number_of_objects = $4, \
brief_description = $5, current_location = $6, current_owner = $7, \
recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
WHERE id = $1",
)
.bind(id.to_uuid())
.bind(&input.object_number)
.bind(&input.object_name)
.bind(input.number_of_objects)
.bind(input.brief_description.as_deref())
.bind(input.current_location.as_deref())
.bind(input.current_owner.as_deref())
.bind(input.recorder.as_deref())
.bind(input.recording_date)
.bind(input.visibility.as_str())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes,
},
)
.await?;
Ok(true)
}
/// Delete an object and record a `deleted` audit entry, both on `conn`.
/// Returns `false` if the object did not exist.
pub async fn delete_object(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: ObjectId,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM object WHERE id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
if result.rows_affected() == 0 {
return Ok(false);
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
+1
View File
@@ -2,6 +2,7 @@
pub mod audit; pub mod audit;
pub mod authority; pub mod authority;
pub mod catalog;
pub mod vocab; pub mod vocab;
use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::postgres::{PgPool, PgPoolOptions};
+118
View File
@@ -0,0 +1,118 @@
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;
fn sample_input(number: &str) -> ObjectInput {
ObjectInput {
object_number: number.into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: Some("a small vase".into()),
current_location: Some("shelf A1".into()),
current_owner: None,
recorder: Some("anna".into()),
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test]
async fn create_reads_back_and_audits(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-1"))
.await
.unwrap();
tx.commit().await.unwrap();
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.object_number, "LM-1");
assert_eq!(obj.object_name, "vase");
assert_eq!(obj.number_of_objects, 1);
assert_eq!(obj.brief_description.as_deref(), Some("a small vase"));
assert_eq!(obj.visibility, Visibility::Draft);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].action, AuditAction::Created);
assert_eq!(history[0].actor, AuditActor::System);
assert!(
history[0]
.changes
.iter()
.any(|c| c.field == "object_number")
);
}
#[sqlx::test]
async fn list_returns_created_objects(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-1"))
.await
.unwrap();
catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-2"))
.await
.unwrap();
tx.commit().await.unwrap();
let all = catalog::list_objects(db.pool()).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].object_number, "LM-1");
assert_eq!(all[1].object_number, "LM-2");
}
#[sqlx::test]
async fn object_by_id_missing_is_none(pool: PgPool) {
let db = Db::from_pool(pool);
assert!(
catalog::object_by_id(db.pool(), domain::ObjectId::new())
.await
.unwrap()
.is_none()
);
}
#[sqlx::test]
async fn object_with_date_and_all_none_optionals_round_trips(pool: PgPool) {
let db = Db::from_pool(pool);
let date = time::Date::from_calendar_date(2020, time::Month::January, 28).unwrap();
let input = ObjectInput {
object_number: "LM-3".into(),
object_name: "drawing".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: Some(date),
visibility: Visibility::Internal,
};
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &input)
.await
.unwrap();
tx.commit().await.unwrap();
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.recording_date, Some(date));
assert_eq!(obj.brief_description, None);
assert_eq!(obj.current_location, None);
assert_eq!(obj.current_owner, None);
assert_eq!(obj.recorder, None);
assert_eq!(obj.visibility, Visibility::Internal);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert!(
history[0]
.changes
.iter()
.any(|c| c.field == "recording_date")
);
}
+176
View File
@@ -0,0 +1,176 @@
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;
fn base() -> ObjectInput {
ObjectInput {
object_number: "LM-1".into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: None,
current_location: Some("shelf A1".into()),
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test]
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut changed = base();
changed.object_name = "roman vase".into();
changed.visibility = Visibility::Public;
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &changed)
.await
.unwrap();
tx.commit().await.unwrap();
assert!(updated);
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.object_name, "roman vase");
assert_eq!(obj.visibility, Visibility::Public);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(history.len(), 2); // created + updated
let update = &history[1];
assert_eq!(update.action, AuditAction::Updated);
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
fields.sort_unstable();
assert_eq!(fields, vec!["object_name", "visibility"]);
}
#[sqlx::test]
async fn no_op_update_records_no_audit(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &base())
.await
.unwrap();
tx.commit().await.unwrap();
assert!(updated);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(
history.len(),
1,
"a no-op update must not add an audit entry"
);
}
#[sqlx::test]
async fn update_missing_returns_false(pool: PgPool) {
let db = Db::from_pool(pool);
let missing = domain::ObjectId::new();
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut tx, AuditActor::System, missing, &base())
.await
.unwrap();
tx.commit().await.unwrap();
assert!(!updated);
let history = audit::history_for(db.pool(), "object", missing.to_uuid())
.await
.unwrap();
assert!(
history.is_empty(),
"updating a missing object records no audit"
);
}
#[sqlx::test]
async fn delete_missing_returns_false(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let deleted = catalog::delete_object(&mut tx, AuditActor::System, domain::ObjectId::new())
.await
.unwrap();
tx.commit().await.unwrap();
assert!(!deleted);
}
#[sqlx::test]
async fn clearing_a_field_is_audited(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut cleared = base();
cleared.current_location = None;
let mut tx = db.pool().begin().await.unwrap();
catalog::update_object(&mut tx, AuditActor::System, id, &cleared)
.await
.unwrap();
tx.commit().await.unwrap();
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
let update = history.last().unwrap();
let loc = update
.changes
.iter()
.find(|c| c.field == "current_location")
.expect("location change recorded");
assert!(
loc.before.is_some(),
"cleared field should have before = Some"
);
assert!(
loc.after.is_none(),
"cleared field should have after = None"
);
}
#[sqlx::test]
async fn delete_removes_and_audits(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let deleted = catalog::delete_object(&mut tx, AuditActor::System, id)
.await
.unwrap();
tx.commit().await.unwrap();
assert!(deleted);
assert!(
catalog::object_by_id(db.pool(), id)
.await
.unwrap()
.is_none()
);
let history = audit::history_for(db.pool(), "object", id.to_uuid())
.await
.unwrap();
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
}
+12
View File
@@ -19,6 +19,18 @@ async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) {
assert_eq!(regclass.as_deref(), Some("audit_log")); assert_eq!(regclass.as_deref(), Some("audit_log"));
} }
#[sqlx::test]
async fn migrate_creates_object_table(pool: PgPool) {
let db = Db::from_pool(pool);
let regclass: Option<String> = sqlx::query_scalar("SELECT to_regclass('public.object')::text")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(regclass.as_deref(), Some("object"));
}
#[sqlx::test] #[sqlx::test]
async fn migrate_creates_vocabulary_and_authority_tables(pool: PgPool) { async fn migrate_creates_vocabulary_and_authority_tables(pool: PgPool) {
let db = Db::from_pool(pool); let db = Db::from_pool(pool);
+4
View File
@@ -64,6 +64,10 @@ id_newtype!(
/// Identifier for an authority record (person, organisation, or place). /// Identifier for an authority record (person, organisation, or place).
AuthorityId AuthorityId
); );
id_newtype!(
/// Identifier for a catalogue object (or group of objects).
ObjectId
);
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+3 -1
View File
@@ -4,10 +4,12 @@ mod audit;
mod authority; mod authority;
mod id; mod id;
mod label; mod label;
mod object;
mod vocabulary; mod vocabulary;
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
pub use id::{AuthorityId, OrgId, TermId, VocabularyId}; pub use id::{AuthorityId, ObjectId, OrgId, TermId, VocabularyId};
pub use label::{LocalizedLabel, pick_label}; pub use label::{LocalizedLabel, pick_label};
pub use object::{CatalogueObject, ObjectInput, Visibility};
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
+108
View File
@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
use time::{Date, OffsetDateTime};
use crate::ObjectId;
/// Publication state of a catalogue record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
/// Work in progress; not shown anywhere public.
#[default]
Draft,
/// Complete but internal-only.
Internal,
/// Published; eligible for the public API.
Public,
}
impl Visibility {
pub fn as_str(&self) -> &'static str {
match self {
Visibility::Draft => "draft",
Visibility::Internal => "internal",
Visibility::Public => "public",
}
}
pub fn from_db(s: &str) -> Option<Self> {
match s {
"draft" => Some(Visibility::Draft),
"internal" => Some(Visibility::Internal),
"public" => Some(Visibility::Public),
_ => None,
}
}
}
/// The mutable inventory-minimum fields of a catalogue object.
#[derive(Debug, Clone, PartialEq)]
pub struct ObjectInput {
pub object_number: String,
pub object_name: String,
pub number_of_objects: i32,
pub brief_description: Option<String>,
pub current_location: Option<String>,
pub current_owner: Option<String>,
pub recorder: Option<String>,
pub recording_date: Option<Date>,
pub visibility: Visibility,
}
/// A catalogue object (or group of objects), read back from storage.
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogueObject {
pub id: ObjectId,
pub object_number: String,
pub object_name: String,
pub number_of_objects: i32,
pub brief_description: Option<String>,
pub current_location: Option<String>,
pub current_owner: Option<String>,
pub recorder: Option<String>,
pub recording_date: Option<Date>,
pub visibility: Visibility,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
impl CatalogueObject {
/// The mutable fields as an [`ObjectInput`] (used to diff against an update).
pub fn to_input(&self) -> ObjectInput {
ObjectInput {
object_number: self.object_number.clone(),
object_name: self.object_name.clone(),
number_of_objects: self.number_of_objects,
brief_description: self.brief_description.clone(),
current_location: self.current_location.clone(),
current_owner: self.current_owner.clone(),
recorder: self.recorder.clone(),
recording_date: self.recording_date,
visibility: self.visibility,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visibility_round_trips_and_defaults_to_draft() {
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
assert_eq!(Visibility::from_db(v.as_str()), Some(v));
}
assert_eq!(Visibility::from_db("secret"), None);
assert_eq!(Visibility::default(), Visibility::Draft);
}
#[test]
fn visibility_serde_matches_as_str() {
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
assert_eq!(
serde_json::to_value(v).unwrap(),
serde_json::Value::String(v.as_str().to_owned())
);
}
}
}
+715
View File
@@ -0,0 +1,715 @@
# Catalogue Core Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** The typed inventory-minimum catalogue object (Approach C's strongly-typed core, §6.1) with CRUD, and — crucially — the **first real consumer of the audit spine**: every create/update/delete records an audit entry (field-level diffs on update) inside the write transaction.
**Architecture:** `domain` holds `ObjectId`, `Visibility`, `ObjectInput` (mutable fields) and `CatalogueObject` (read model). `db::catalog` owns the `object` table (migration 0003) and the repository. Writes take `&mut PgConnection` and record audit via `db::audit::record` on the same connection, so the change and its audit entry commit atomically. Vocabulary/authority *binding* of fields is deferred to the flexible layer (Plan 4); fields are simple types here. No HTTP, no flexible fields, no search.
**Tech Stack:** Rust 2024, sqlx 0.8 (`time`+`json`), `time::Date`/`OffsetDateTime`, `serde_json` (now a normal dep of `db`, to build audit `FieldChange` values). Tests use `#[sqlx::test]`.
## Design decisions (approved)
- One `object` table = object **or group** (`number_of_objects ≥ 1`).
- Inventory-minimum fields as **simple types** (free text); vocab/authority binding deferred to Plan 4.
- **Audit on every write**, in the write transaction (this plan is the audit spine's first consumer).
- `Visibility` stored now; publish/unpublish transitions + `PublicView` + public API in Plan 7.
- Scope: object CRUD + list in `domain` + `db`.
## Prerequisites
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline (e.g. `postgres://postgres:postgres@localhost:5433/cms_dev`). Shell env does not persist between commands.
## File Structure
```
crates/domain/
src/id.rs + ObjectId via id_newtype!
src/object.rs Visibility, ObjectInput, CatalogueObject (+ to_input)
src/lib.rs re-exports
crates/db/
Cargo.toml serde_json -> normal dependency
migrations/0003_object.sql
src/catalog.rs create/get/list/update/delete + audit integration
src/lib.rs pub mod catalog;
tests/catalog.rs
```
---
## Task 1: `domain` — object types
**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/object.rs`.
- [ ] **Step 1: Add `ObjectId`** to `crates/domain/src/id.rs` — add another `id_newtype!` invocation after the existing ones:
```rust
id_newtype!(
/// Identifier for a catalogue object (or group of objects).
ObjectId
);
```
- [ ] **Step 2: Create `crates/domain/src/object.rs`:**
```rust
use serde::{Deserialize, Serialize};
use time::{Date, OffsetDateTime};
use crate::ObjectId;
/// Publication state of a catalogue record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
/// Work in progress; not shown anywhere public.
#[default]
Draft,
/// Complete but internal-only.
Internal,
/// Published; eligible for the public API.
Public,
}
impl Visibility {
pub fn as_str(&self) -> &'static str {
match self {
Visibility::Draft => "draft",
Visibility::Internal => "internal",
Visibility::Public => "public",
}
}
pub fn from_db(s: &str) -> Option<Self> {
match s {
"draft" => Some(Visibility::Draft),
"internal" => Some(Visibility::Internal),
"public" => Some(Visibility::Public),
_ => None,
}
}
}
/// The mutable inventory-minimum fields of a catalogue object.
#[derive(Debug, Clone, PartialEq)]
pub struct ObjectInput {
pub object_number: String,
pub object_name: String,
pub number_of_objects: i32,
pub brief_description: Option<String>,
pub current_location: Option<String>,
pub current_owner: Option<String>,
pub recorder: Option<String>,
pub recording_date: Option<Date>,
pub visibility: Visibility,
}
/// A catalogue object (or group of objects), read back from storage.
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogueObject {
pub id: ObjectId,
pub object_number: String,
pub object_name: String,
pub number_of_objects: i32,
pub brief_description: Option<String>,
pub current_location: Option<String>,
pub current_owner: Option<String>,
pub recorder: Option<String>,
pub recording_date: Option<Date>,
pub visibility: Visibility,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
impl CatalogueObject {
/// The mutable fields as an [`ObjectInput`] (used to diff against an update).
pub fn to_input(&self) -> ObjectInput {
ObjectInput {
object_number: self.object_number.clone(),
object_name: self.object_name.clone(),
number_of_objects: self.number_of_objects,
brief_description: self.brief_description.clone(),
current_location: self.current_location.clone(),
current_owner: self.current_owner.clone(),
recorder: self.recorder.clone(),
recording_date: self.recording_date,
visibility: self.visibility,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visibility_round_trips_and_defaults_to_draft() {
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
assert_eq!(Visibility::from_db(v.as_str()), Some(v));
}
assert_eq!(Visibility::from_db("secret"), None);
assert_eq!(Visibility::default(), Visibility::Draft);
}
}
```
- [ ] **Step 3: Update `crates/domain/src/lib.rs`** — add `mod object;` (alphabetical, after `mod label;` / before `mod vocabulary;` is fine) and add `ObjectId` to the id re-export and the object types. The id re-export becomes:
```rust
pub use id::{AuthorityId, ObjectId, OrgId, TermId, VocabularyId};
```
and add:
```rust
pub use object::{CatalogueObject, ObjectInput, Visibility};
```
- [ ] **Step 4: Test + lint.** `cargo test -p domain` → all pass (incl. the new visibility test). `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.
- [ ] **Step 5: Commit.**
```bash
git add crates/domain
git commit -m "feat(domain): add catalogue object types (Visibility, ObjectInput, CatalogueObject)"
```
---
## Task 2: `db` migration — object table
**Files:** create `crates/db/migrations/0003_object.sql`; modify `crates/db/tests/migrate.rs`.
- [ ] **Step 1: Create `crates/db/migrations/0003_object.sql`:**
```sql
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
CREATE TABLE object (
id UUID PRIMARY KEY,
object_number TEXT NOT NULL UNIQUE,
object_name TEXT NOT NULL,
number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
brief_description TEXT,
current_location TEXT,
current_owner TEXT,
recorder TEXT,
recording_date DATE,
visibility TEXT NOT NULL DEFAULT 'draft'
CHECK (visibility IN ('draft', 'internal', 'public')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX object_visibility_idx ON object (visibility);
```
- [ ] **Step 2: Extend `crates/db/tests/migrate.rs`** — append:
```rust
#[sqlx::test]
async fn migrate_creates_object_table(pool: PgPool) {
let db = Db::from_pool(pool);
let regclass: Option<String> =
sqlx::query_scalar("SELECT to_regclass('public.object')::text")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(regclass.as_deref(), Some("object"));
}
```
- [ ] **Step 3: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate` → 3 tests pass. `cargo +nightly fmt`; clippy clean.
- [ ] **Step 4: Commit.**
```bash
git add crates/db/migrations crates/db/tests/migrate.rs
git commit -m "feat(db): add object table"
```
---
## Task 3: `db::catalog` — create, read, list (with audit on create)
**Files:** modify `crates/db/Cargo.toml`, `crates/db/src/lib.rs`; create `crates/db/src/catalog.rs`, `crates/db/tests/catalog.rs`.
- [ ] **Step 1: Make `serde_json` a normal dependency of `db`.** In `crates/db/Cargo.toml`, move `serde_json` from `[dev-dependencies]` to `[dependencies]` (it is needed in `catalog.rs` to build audit `FieldChange` values). Result:
```toml
[dependencies]
sqlx.workspace = true
thiserror.workspace = true
domain = { path = "../domain" }
uuid.workspace = true
time.workspace = true
serde_json.workspace = true
[dev-dependencies]
tokio.workspace = true
```
- [ ] **Step 2: Write the failing test** `crates/db/tests/catalog.rs`:
```rust
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;
fn sample_input(number: &str) -> ObjectInput {
ObjectInput {
object_number: number.into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: Some("a small vase".into()),
current_location: Some("shelf A1".into()),
current_owner: None,
recorder: Some("anna".into()),
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test]
async fn create_reads_back_and_audits(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-1"))
.await
.unwrap();
tx.commit().await.unwrap();
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.object_number, "LM-1");
assert_eq!(obj.object_name, "vase");
assert_eq!(obj.number_of_objects, 1);
assert_eq!(obj.brief_description.as_deref(), Some("a small vase"));
assert_eq!(obj.visibility, Visibility::Draft);
// The create was audited within the same transaction.
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].action, AuditAction::Created);
assert_eq!(history[0].actor, AuditActor::System);
assert!(history[0].changes.iter().any(|c| c.field == "object_number"));
}
#[sqlx::test]
async fn list_returns_created_objects(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-1")).await.unwrap();
catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-2")).await.unwrap();
tx.commit().await.unwrap();
let all = catalog::list_objects(db.pool()).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].object_number, "LM-1");
assert_eq!(all[1].object_number, "LM-2");
}
#[sqlx::test]
async fn object_by_id_missing_is_none(pool: PgPool) {
let db = Db::from_pool(pool);
assert!(catalog::object_by_id(db.pool(), domain::ObjectId::new()).await.unwrap().is_none());
}
```
- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog` → FAIL.
- [ ] **Step 4: Implement** `crates/db/src/catalog.rs`:
```rust
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
//! in the caller's transaction.
use domain::{
AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput,
Visibility,
};
use serde_json::{Value, json};
use sqlx::Row;
use crate::audit;
/// The entity_type recorded in the audit log for catalogue objects.
const ENTITY_TYPE: &str = "object";
/// Create an object and record a `created` audit entry, both on `conn`
/// (pass a transaction connection `&mut *tx` so they commit atomically).
pub async fn create_object(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
input: &ObjectInput,
) -> Result<ObjectId, sqlx::Error> {
let id = ObjectId::new();
sqlx::query(
"INSERT INTO object \
(id, object_number, object_name, number_of_objects, brief_description, \
current_location, current_owner, recorder, recording_date, visibility) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(id.to_uuid())
.bind(&input.object_number)
.bind(&input.object_name)
.bind(input.number_of_objects)
.bind(input.brief_description.as_deref())
.bind(input.current_location.as_deref())
.bind(input.current_owner.as_deref())
.bind(input.recorder.as_deref())
.bind(input.recording_date)
.bind(input.visibility.as_str())
.execute(&mut *conn)
.await?;
let changes = creation_changes(input);
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Created,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes,
},
)
.await?;
Ok(id)
}
/// Fetch one object by id.
pub async fn object_by_id<'e, E>(
executor: E,
id: ObjectId,
) -> Result<Option<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let row = sqlx::query(SELECT_OBJECT_BY_ID)
.bind(id.to_uuid())
.fetch_optional(executor)
.await?;
row.map(map_object).transpose()
}
/// List all objects, ordered by object number.
pub async fn list_objects<'e, E>(executor: E) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
// TODO: add LIMIT/keyset pagination before exposing this via the API.
let rows = sqlx::query(SELECT_OBJECTS_ORDERED)
.fetch_all(executor)
.await?;
rows.into_iter().map(map_object).collect()
}
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
brief_description, current_location, current_owner, recorder, recording_date, \
visibility, created_at, updated_at";
const SELECT_OBJECT_BY_ID: &str =
"SELECT id, object_number, object_name, number_of_objects, brief_description, \
current_location, current_owner, recorder, recording_date, visibility, \
created_at, updated_at FROM object WHERE id = $1";
const SELECT_OBJECTS_ORDERED: &str =
"SELECT id, object_number, object_name, number_of_objects, brief_description, \
current_location, current_owner, recorder, recording_date, visibility, \
created_at, updated_at FROM object ORDER BY object_number";
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
let visibility_str: String = row.try_get("visibility")?;
let visibility = Visibility::from_db(&visibility_str)
.ok_or_else(|| sqlx::Error::Decode(format!("unknown visibility: {visibility_str}").into()))?;
Ok(CatalogueObject {
id: ObjectId::from_uuid(row.try_get("id")?),
object_number: row.try_get("object_number")?,
object_name: row.try_get("object_name")?,
number_of_objects: row.try_get("number_of_objects")?,
brief_description: row.try_get("brief_description")?,
current_location: row.try_get("current_location")?,
current_owner: row.try_get("current_owner")?,
recorder: row.try_get("recorder")?,
recording_date: row.try_get("recording_date")?,
visibility,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
})
}
/// The mutable fields as `(name, value)` pairs, for building audit diffs.
/// `None` means the field is unset (NULL).
fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
vec![
("object_number", Some(json!(input.object_number))),
("object_name", Some(json!(input.object_name))),
("number_of_objects", Some(json!(input.number_of_objects))),
("brief_description", input.brief_description.as_ref().map(|v| json!(v))),
("current_location", input.current_location.as_ref().map(|v| json!(v))),
("current_owner", input.current_owner.as_ref().map(|v| json!(v))),
("recorder", input.recorder.as_ref().map(|v| json!(v))),
(
"recording_date",
input.recording_date.and_then(|d| serde_json::to_value(d).ok()),
),
("visibility", Some(json!(input.visibility.as_str()))),
]
}
/// Audit changes for a newly created object: every set field as an `after` value.
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
field_values(input)
.into_iter()
.filter_map(|(field, after)| {
after.map(|a| FieldChange {
field: field.to_owned(),
before: None,
after: Some(a),
})
})
.collect()
}
/// Audit changes between two field sets: only the fields whose value changed.
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
field_values(old)
.into_iter()
.zip(field_values(new))
.filter_map(|((field, before), (_, after))| {
if before != after {
Some(FieldChange { field: field.to_owned(), before, after })
} else {
None
}
})
.collect()
}
```
Note: `OBJECT_COLUMNS` is intentionally unused for now (kept adjacent for documentation of the column set); if clippy flags it as dead code, DELETE the `OBJECT_COLUMNS` const (the two SELECT consts are the live ones). `update_changes` is used in Task 4 — if clippy flags it unused in this task, add `#[allow(dead_code)]` to `update_changes` with a `// used in Task 4 (update_object)` comment, OR implement Task 4 immediately after so it's used. (Recommended: proceed to Task 4 before the final clippy gate.)
Add to `crates/db/src/lib.rs` (top-level): `pub mod catalog;`
- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog` → PASS (3 tests).
- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean (resolve the `OBJECT_COLUMNS`/`update_changes` dead-code note as above).
- [ ] **Step 7: Commit.**
```bash
git add crates/db
git commit -m "feat(db): add catalogue object create/read/list with audit on create"
```
---
## Task 4: `db::catalog` — update & delete (with audit diffs)
**Files:** modify `crates/db/src/catalog.rs`; test `crates/db/tests/catalog_mutations.rs`.
- [ ] **Step 1: Write the failing test** `crates/db/tests/catalog_mutations.rs`:
```rust
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;
fn base() -> ObjectInput {
ObjectInput {
object_number: "LM-1".into(),
object_name: "vase".into(),
number_of_objects: 1,
brief_description: None,
current_location: Some("shelf A1".into()),
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test]
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
tx.commit().await.unwrap();
let mut changed = base();
changed.object_name = "roman vase".into();
changed.visibility = Visibility::Public;
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &changed).await.unwrap();
tx.commit().await.unwrap();
assert!(updated);
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
assert_eq!(obj.object_name, "roman vase");
assert_eq!(obj.visibility, Visibility::Public);
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
assert_eq!(history.len(), 2); // created + updated
let update = &history[1];
assert_eq!(update.action, AuditAction::Updated);
// Exactly the two changed fields are recorded.
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
fields.sort_unstable();
assert_eq!(fields, vec!["object_name", "visibility"]);
}
#[sqlx::test]
async fn no_op_update_records_no_audit(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &base()).await.unwrap();
tx.commit().await.unwrap();
assert!(updated);
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
assert_eq!(history.len(), 1, "a no-op update must not add an audit entry");
}
#[sqlx::test]
async fn update_missing_returns_false(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let updated = catalog::update_object(&mut *tx, AuditActor::System, domain::ObjectId::new(), &base())
.await
.unwrap();
tx.commit().await.unwrap();
assert!(!updated);
}
#[sqlx::test]
async fn delete_removes_and_audits(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let deleted = catalog::delete_object(&mut *tx, AuditActor::System, id).await.unwrap();
tx.commit().await.unwrap();
assert!(deleted);
assert!(catalog::object_by_id(db.pool(), id).await.unwrap().is_none());
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
}
```
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → FAIL (`update_object`/`delete_object` missing).
- [ ] **Step 3: Implement** — append to `crates/db/src/catalog.rs`:
```rust
/// Update an object and record an `updated` audit entry with field-level diffs,
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
/// (no fields changed) records no audit entry.
pub async fn update_object(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: ObjectId,
input: &ObjectInput,
) -> Result<bool, sqlx::Error> {
let Some(old) = object_by_id(&mut *conn, id).await? else {
return Ok(false);
};
sqlx::query(
"UPDATE object SET \
object_number = $2, object_name = $3, number_of_objects = $4, \
brief_description = $5, current_location = $6, current_owner = $7, \
recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
WHERE id = $1",
)
.bind(id.to_uuid())
.bind(&input.object_number)
.bind(&input.object_name)
.bind(input.number_of_objects)
.bind(input.brief_description.as_deref())
.bind(input.current_location.as_deref())
.bind(input.current_owner.as_deref())
.bind(input.recorder.as_deref())
.bind(input.recording_date)
.bind(input.visibility.as_str())
.execute(&mut *conn)
.await?;
let changes = update_changes(&old.to_input(), input);
if !changes.is_empty() {
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes,
},
)
.await?;
}
Ok(true)
}
/// Delete an object and record a `deleted` audit entry, both on `conn`.
/// Returns `false` if the object did not exist.
pub async fn delete_object(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: ObjectId,
) -> Result<bool, sqlx::Error> {
if object_by_id(&mut *conn, id).await?.is_none() {
return Ok(false);
}
sqlx::query("DELETE FROM object WHERE id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
```
If you added a temporary `#[allow(dead_code)]` to `update_changes` in Task 3, remove it now (it is used here). If `OBJECT_COLUMNS` is unused, delete that const.
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → PASS (4 tests).
- [ ] **Step 5: Full workspace check.**
```bash
cargo +nightly fmt --check
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=<url> cargo test --workspace
```
Expected: all green.
- [ ] **Step 6: Commit.**
```bash
git add crates/db
git commit -m "feat(db): add catalogue object update/delete with audited field diffs"
```
---
## Self-Review (completed)
**Spec coverage (§6.1 typed core + audit integration):**
- Inventory-minimum object (object/group via number_of_objects), simple typed fields → Tasks 12. ✓
- CRUD + list → Tasks 34. ✓
- Audit on create/update/delete inside the write transaction, field-level diffs on update → Task 3 (create) + Task 4 (update/delete), verified via `audit::history_for` in tests. ✓
- Visibility stored; transitions/PublicView/public API deferred to Plan 7. ✓ (intentional)
- Vocab/authority binding deferred to Plan 4. ✓ (intentional)
- SQL confined to `db`; `domain` I/O-free. ✓
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`. The `OBJECT_COLUMNS`/`update_changes` dead-code notes are explicit resolution instructions, not placeholders.
**Type consistency:** `ObjectInput`/`CatalogueObject` field names/types identical across `domain` (Task 1), the repo (Tasks 34), and tests. `create_object`/`update_object`/`delete_object` take `(&mut PgConnection, AuditActor, …)`; reads take `impl PgExecutor`. `field_values`/`creation_changes`/`update_changes` operate on the same nine mutable fields; `to_input` (domain) bridges `CatalogueObject``ObjectInput` for diffing. `ENTITY_TYPE = "object"` matches the `"object"` literal used in tests' `history_for` calls.
## Notes for follow-on plans
- The audit `actor` is threaded as a parameter; the API layer (Plan 7+) will pass the authenticated user (Plan 9 introduces `UserId`; until then `AuditActor::System` or a raw user uuid).
- `list_objects` is unpaginated (TODO in code) — add keyset pagination before the API exposes it (same as `audit::history_for`, `vocab::list_terms`, `authority::list_by_kind`).
- Flexible fields (Plan 4) attach to this object via the field-definition registry + JSONB; vocab/authority-bound fields (object_name as TermRef, owner as AuthorityRef) live there.
- `PublicView` projection + visibility transition methods + public read API: Plan 7.