use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, NaiveDateTime, Utc};
use keepsake::{
ActiveRelation, ActiveRelationSource, ApplyKeepsake, AuditDecision, AuditEvent, ExpiryPolicy,
FulfillmentSnapshot, Keepsake, KeepsakeId, KeepsakeRecord, RelationDefinition, RelationId,
RelationKey, RelationSpec, RevokeBySubject, RevokeKeepsake, SubjectRef,
};
use sqlx::{MySql, Row, Transaction};
use uuid::Uuid;
use super::support::{
AuditEventParts, apply_event, audit_event_record, expires_at, parse_state, parse_uuid,
revoke_by_subject_event, revoke_event,
};
use super::{
AppliedKeepsake, AuditCursor, AuditEventRecord, FulfilledExpiryCandidate, MembershipCursor,
MySqlKeepsakeRepository, RelationCache, RepositoryError, RepositoryResult,
TimedExpiryCandidate, validate_limit,
};
impl<C> MySqlKeepsakeRepository<C>
where
C: RelationCache,
{
pub async fn upsert_relation(
&self,
relation: &RelationDefinition,
at: DateTime<Utc>,
) -> RepositoryResult<RelationDefinition> {
let expiry_policy = serde_json::to_value(&relation.expiry)?;
sqlx::query(
r"
insert into keepsake_relation_definitions
(id, kind, `key`, enabled, expiry_policy, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?)
on duplicate key update
enabled = values(enabled),
expiry_policy = values(expiry_policy),
updated_at = values(updated_at)
",
)
.bind(relation.id.to_string())
.bind(relation.key.kind())
.bind(relation.key.name())
.bind(relation.enabled)
.bind(expiry_policy)
.bind(naive_timestamp(at))
.bind(naive_timestamp(at))
.execute(&self.pool)
.await?;
let relation = self.relation_by_key(&relation.key).await?.ok_or(
RepositoryError::RelationDefinitionMissing {
relation_id: relation.id,
},
)?;
self.relation_cache.remove_by_id(relation.id).await;
Ok(relation)
}
pub async fn upsert_relation_spec<Spec>(
&self,
at: DateTime<Utc>,
) -> RepositoryResult<RelationDefinition>
where
Spec: RelationSpec,
{
let relation = RelationDefinition::from_spec::<Spec>(at)?;
let mut tx = self.pool.begin().await?;
let existing = sqlx::query(
r"
select id, kind, `key`, enabled, expiry_policy
from keepsake_relation_definitions
where kind = ? and `key` = ?
for update
",
)
.bind(relation.key.kind())
.bind(relation.key.name())
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
let stored = relation_from_row(&row)?;
if stored.id != relation.id {
return Err(RepositoryError::RelationSpecIdMismatch {
kind: relation.key.kind().to_owned(),
name: relation.key.name().to_owned(),
expected_relation_id: relation.id,
stored_relation_id: stored.id,
});
}
sqlx::query(
r"
update keepsake_relation_definitions
set enabled = ?, expiry_policy = ?, updated_at = ?
where id = ?
",
)
.bind(relation.enabled)
.bind(serde_json::to_value(&relation.expiry)?)
.bind(naive_timestamp(at))
.bind(relation.id.to_string())
.execute(&mut *tx)
.await?;
} else {
sqlx::query(
r"
insert into keepsake_relation_definitions
(id, kind, `key`, enabled, expiry_policy, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?)
",
)
.bind(relation.id.to_string())
.bind(relation.key.kind())
.bind(relation.key.name())
.bind(relation.enabled)
.bind(serde_json::to_value(&relation.expiry)?)
.bind(naive_timestamp(at))
.bind(naive_timestamp(at))
.execute(&mut *tx)
.await?;
}
let row = sqlx::query(
r"
select id, kind, `key`, enabled, expiry_policy
from keepsake_relation_definitions
where id = ?
",
)
.bind(relation.id.to_string())
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
let relation = relation_from_row(&row)?;
self.relation_cache.remove_by_id(relation.id).await;
Ok(relation)
}
pub async fn relation_by_id(
&self,
relation_id: RelationId,
) -> RepositoryResult<Option<RelationDefinition>> {
if let Some(relation) = self.relation_cache.get_by_id(relation_id).await {
return Ok(Some(relation));
}
let row = sqlx::query(
r"
select id, kind, `key`, enabled, expiry_policy
from keepsake_relation_definitions
where id = ?
",
)
.bind(relation_id.to_string())
.fetch_optional(&self.pool)
.await?;
let relation = row.map(|row| relation_from_row(&row)).transpose()?;
if let Some(relation) = &relation {
self.relation_cache.store(relation).await;
}
Ok(relation)
}
pub async fn relation_by_key(
&self,
key: &RelationKey,
) -> RepositoryResult<Option<RelationDefinition>> {
if let Some(relation) = self.relation_cache.get_by_key(key).await {
return Ok(Some(relation));
}
let row = sqlx::query(
r"
select id, kind, `key`, enabled, expiry_policy
from keepsake_relation_definitions
where kind = ? and `key` = ?
",
)
.bind(key.kind())
.bind(key.name())
.fetch_optional(&self.pool)
.await?;
let relation = row.map(|row| relation_from_row(&row)).transpose()?;
if let Some(relation) = &relation {
self.relation_cache.store(relation).await;
}
Ok(relation)
}
pub async fn set_relation_enabled(
&self,
relation_id: RelationId,
enabled: bool,
at: DateTime<Utc>,
) -> RepositoryResult<bool> {
let result = sqlx::query(
r"
update keepsake_relation_definitions
set enabled = ?, updated_at = ?
where id = ?
",
)
.bind(enabled)
.bind(naive_timestamp(at))
.bind(relation_id.to_string())
.execute(&self.pool)
.await?;
let changed = result.rows_affected() == 1;
if changed {
self.relation_cache.remove_by_id(relation_id).await;
}
Ok(changed)
}
pub async fn apply(&self, command: &ApplyKeepsake) -> RepositoryResult<AppliedKeepsake> {
command.subject.validate()?;
command.context.validate()?;
let mut tx = self.pool.begin().await?;
let relation = relation_for_update_tx(&mut tx, command.relation_id).await?;
if let Some(existing) =
active_keepsake_for_subject_relation_tx(&mut tx, &command.subject, command.relation_id)
.await?
{
record_audit_event_tx(&mut tx, &apply_event(command, &existing, true)).await?;
tx.commit().await?;
return Ok(AppliedKeepsake {
keepsake: existing,
duplicate_prevented: true,
});
}
if !relation.enabled {
return Err(RepositoryError::RelationDisabled {
relation_id: command.relation_id,
});
}
sqlx::query(
r"
insert into keepsakes
(id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
expires_at, metadata, created_at, updated_at)
values (?, ?, ?, ?, 'applied', ?, ?, ?, ?, ?, ?)
",
)
.bind(command.id.to_string())
.bind(&command.subject.kind)
.bind(&command.subject.id)
.bind(command.relation_id.to_string())
.bind(serde_json::to_value(&relation.expiry)?)
.bind(naive_timestamp(command.at))
.bind(expires_at(&relation.expiry).map(naive_timestamp))
.bind(serde_json::to_value(&command.metadata)?)
.bind(naive_timestamp(command.at))
.bind(naive_timestamp(command.at))
.execute(&mut *tx)
.await?;
let keepsake = keepsake_by_id_tx(&mut tx, command.id).await?.ok_or(
RepositoryError::RelationDefinitionMissing {
relation_id: command.relation_id,
},
)?;
record_audit_event_tx(&mut tx, &apply_event(command, &keepsake, false)).await?;
tx.commit().await?;
Ok(AppliedKeepsake {
keepsake,
duplicate_prevented: false,
})
}
pub async fn revoke(&self, command: &RevokeKeepsake) -> RepositoryResult<bool> {
command.context.validate()?;
let mut tx = self.pool.begin().await?;
let revoked = revoke_tx(&mut tx, command.keepsake_id, command.at).await?;
if let Some(keepsake) = &revoked {
record_audit_event_tx(&mut tx, &revoke_event(command, keepsake)).await?;
}
tx.commit().await?;
Ok(revoked.is_some())
}
pub async fn revoke_by_subject(
&self,
command: &RevokeBySubject,
) -> RepositoryResult<Option<KeepsakeId>> {
command.subject.validate()?;
command.context.validate()?;
let mut tx = self.pool.begin().await?;
let revoked =
revoke_by_subject_tx(&mut tx, &command.subject, command.relation_id, command.at)
.await?;
let revoked_id = revoked.as_ref().map(Keepsake::id);
if let Some(keepsake) = &revoked {
record_audit_event_tx(&mut tx, &revoke_by_subject_event(command, keepsake)).await?;
}
tx.commit().await?;
Ok(revoked_id)
}
pub async fn append_audit_event(&self, event: &AuditEvent) -> RepositoryResult<i64> {
event.subject.validate()?;
event.actor.validate()?;
let mut tx = self.pool.begin().await?;
let audit_event_id = record_audit_event_tx(&mut tx, event).await?;
tx.commit().await?;
Ok(audit_event_id)
}
pub async fn audit_events_for_keepsake(
&self,
keepsake_id: Uuid,
after: Option<&AuditCursor>,
limit: i64,
) -> RepositoryResult<Vec<AuditEventRecord>> {
let limit = validate_limit(limit)?;
let rows = sqlx::query(
r"
select id, keepsake_id, relation_id, subject_kind, subject_id, actor_kind, actor_id,
event_type, decision, occurred_at
from keepsake_audit_events
where keepsake_id = ?
and (
? is null
or (occurred_at, id) > (?, ?)
)
order by occurred_at, id
limit ?
",
)
.bind(keepsake_id.to_string())
.bind(after.map(|cursor| naive_timestamp(cursor.occurred_at)))
.bind(after.map(|cursor| naive_timestamp(cursor.occurred_at)))
.bind(after.map(|cursor| cursor.id))
.bind(limit)
.fetch_all(&self.pool)
.await?;
hydrate_audit_records(&self.pool, rows).await
}
pub async fn audit_events_for_relation(
&self,
relation_id: RelationId,
after: Option<&AuditCursor>,
limit: i64,
) -> RepositoryResult<Vec<AuditEventRecord>> {
let limit = validate_limit(limit)?;
let rows = sqlx::query(
r"
select id, keepsake_id, relation_id, subject_kind, subject_id, actor_kind, actor_id,
event_type, decision, occurred_at
from keepsake_audit_events
where relation_id = ?
and (
? is null
or (occurred_at, id) > (?, ?)
)
order by occurred_at, id
limit ?
",
)
.bind(relation_id.to_string())
.bind(after.map(|cursor| naive_timestamp(cursor.occurred_at)))
.bind(after.map(|cursor| naive_timestamp(cursor.occurred_at)))
.bind(after.map(|cursor| cursor.id))
.bind(limit)
.fetch_all(&self.pool)
.await?;
hydrate_audit_records(&self.pool, rows).await
}
pub async fn active_for_subject(
&self,
subject: &SubjectRef,
) -> RepositoryResult<Vec<Keepsake>> {
let rows = sqlx::query(
r"
select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
expires_at, fulfilled_at, revoked_at, metadata
from keepsakes
where subject_kind = ? and subject_id = ? and state = 'applied'
order by relation_id, id
",
)
.bind(&subject.kind)
.bind(&subject.id)
.fetch_all(&self.pool)
.await?;
rows.iter().map(keepsake_from_row).collect()
}
pub async fn active_relations_for_subject(
&self,
subject: &SubjectRef,
) -> RepositoryResult<Vec<ActiveRelation>> {
let rows = active_relation_rows_for_subject(&self.pool, subject).await?;
let mut active = Vec::with_capacity(rows.len());
for (keepsake, relation) in rows {
self.relation_cache.store(&relation).await;
active.push(ActiveRelation::new(keepsake, relation)?);
}
Ok(active)
}
pub async fn active_relations_for_subject_by_ids(
&self,
subject: &SubjectRef,
relation_ids: &[RelationId],
) -> RepositoryResult<Vec<ActiveRelation>> {
let requested = relation_ids.iter().copied().collect::<BTreeSet<_>>();
Ok(self
.active_relations_for_subject(subject)
.await?
.into_iter()
.filter(|active| requested.contains(&active.relation().id))
.collect())
}
pub async fn active_relations_for_subject_by_keys(
&self,
subject: &SubjectRef,
keys: &[RelationKey],
) -> RepositoryResult<Vec<ActiveRelation>> {
let requested = keys.iter().collect::<BTreeSet<_>>();
Ok(self
.active_relations_for_subject(subject)
.await?
.into_iter()
.filter(|active| requested.contains(&active.relation().key))
.collect())
}
pub async fn active_membership_scan(
&self,
relation_id: RelationId,
limit: i64,
) -> RepositoryResult<Vec<Keepsake>> {
self.active_membership_scan_after(relation_id, None, limit)
.await
}
pub async fn active_membership_scan_after(
&self,
relation_id: RelationId,
after: Option<&MembershipCursor>,
limit: i64,
) -> RepositoryResult<Vec<Keepsake>> {
let limit = validate_limit(limit)?;
let rows = sqlx::query(
r"
select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
expires_at, fulfilled_at, revoked_at, metadata
from keepsakes
where relation_id = ?
and state = 'applied'
and (
? is null
or (subject_kind, subject_id, id) > (?, ?, ?)
)
order by subject_kind, subject_id, id
limit ?
",
)
.bind(relation_id.to_string())
.bind(after.map(|cursor| cursor.subject_kind.as_str()))
.bind(after.map(|cursor| cursor.subject_kind.as_str()))
.bind(after.map(|cursor| cursor.subject_id.as_str()))
.bind(after.map(|cursor| cursor.keepsake_id.to_string()))
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.iter().map(keepsake_from_row).collect()
}
pub async fn due_timed_expiry(
&self,
now: DateTime<Utc>,
limit: i64,
) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
let limit = validate_limit(limit)?;
let rows = sqlx::query(
r"
select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
from keepsakes k
join keepsake_relation_definitions r on r.id = k.relation_id
where k.state = 'applied'
and r.enabled
and k.expires_at is not null
and k.expires_at <= ?
order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
limit ?
",
)
.bind(naive_timestamp(now))
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.iter().map(timed_expiry_candidate_from_row).collect()
}
#[cfg(feature = "fulfillment-counters")]
pub async fn fulfillment_snapshot(
&self,
keepsake_id: Uuid,
) -> RepositoryResult<FulfillmentSnapshot> {
let mut tx = self.pool.begin().await?;
let snapshot = fulfillment_snapshot_tx(&mut tx, keepsake_id).await?;
tx.commit().await?;
Ok(snapshot)
}
#[cfg(feature = "fulfillment-counters")]
pub async fn due_fulfilled_expiry(
&self,
limit: i64,
) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
let limit = validate_limit(limit)?;
let rows = sqlx::query(
r"
select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
from keepsakes k
join keepsake_relation_definitions r on r.id = k.relation_id
where k.fulfillment_pending = 1
and r.enabled
order by k.relation_id, k.subject_kind, k.subject_id, k.id
limit ?
",
)
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(fulfilled_expiry_candidate_from_row)
.collect()
}
#[cfg(feature = "fulfillment-counters")]
pub async fn expire_due_fulfilled(
&self,
now: DateTime<Utc>,
limit: i64,
) -> RepositoryResult<u64> {
let limit = validate_limit(limit)?;
let target = u64::try_from(limit).map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
let mut expired = 0;
let mut tx = self.pool.begin().await?;
let mut after = None;
while expired < target {
let remaining = i64::try_from(target - expired)
.map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
let candidates =
due_fulfilled_expiry_after_tx(&mut tx, after.as_ref(), remaining).await?;
if candidates.is_empty() {
break;
}
after = candidates.last().map(FulfilledExpiryCursor::from);
for candidate in candidates {
let ExpiryPolicy::WhenFulfilled { policy } = candidate.expiry_policy else {
continue;
};
let snapshot = fulfillment_snapshot_tx(&mut tx, candidate.keepsake_id).await?;
if policy.is_fulfilled(&snapshot) {
let result = sqlx::query(
r"
update keepsakes
set state = 'expired', fulfilled_at = ?, updated_at = ?
where id = ?
and state = 'applied'
and exists (
select 1
from keepsake_relation_definitions r
where r.id = keepsakes.relation_id and r.enabled
)
",
)
.bind(naive_timestamp(now))
.bind(naive_timestamp(now))
.bind(candidate.keepsake_id.to_string())
.execute(&mut *tx)
.await?;
expired += result.rows_affected();
}
}
}
tx.commit().await?;
Ok(expired)
}
pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
let candidates = self.due_timed_expiry(now, limit).await?;
let mut expired = 0;
let mut tx = self.pool.begin().await?;
for candidate in candidates {
let result = sqlx::query(
r"
update keepsakes
set state = 'expired', updated_at = ?
where id = ?
and state = 'applied'
and exists (
select 1
from keepsake_relation_definitions r
where r.id = keepsakes.relation_id and r.enabled
)
",
)
.bind(naive_timestamp(now))
.bind(candidate.keepsake_id.to_string())
.execute(&mut *tx)
.await?;
expired += result.rows_affected();
}
tx.commit().await?;
Ok(expired)
}
#[cfg(feature = "fulfillment-counters")]
pub async fn upsert_counter_projection(
&self,
keepsake_id: Uuid,
key: &str,
value: i64,
observed_at: DateTime<Utc>,
) -> RepositoryResult<()> {
sqlx::query(
r"
insert into keepsake_fulfillment_counters
(keepsake_id, `key`, value, observed_at)
values (?, ?, ?, ?)
on duplicate key update
value = values(value),
observed_at = values(observed_at)
",
)
.bind(keepsake_id.to_string())
.bind(key)
.bind(value)
.bind(naive_timestamp(observed_at))
.execute(&self.pool)
.await?;
Ok(())
}
#[cfg(feature = "fulfillment-counters")]
pub async fn increment_counter_projection(
&self,
keepsake_id: Uuid,
key: &str,
delta: i64,
observed_at: DateTime<Utc>,
) -> RepositoryResult<i64> {
let mut tx = self.pool.begin().await?;
sqlx::query(
r"
insert into keepsake_fulfillment_counters
(keepsake_id, `key`, value, observed_at)
values (?, ?, ?, ?)
on duplicate key update
value = value + values(value),
observed_at = values(observed_at)
",
)
.bind(keepsake_id.to_string())
.bind(key)
.bind(delta)
.bind(naive_timestamp(observed_at))
.execute(&mut *tx)
.await?;
let value: i64 = sqlx::query(
r"
select value
from keepsake_fulfillment_counters
where keepsake_id = ? and `key` = ?
",
)
.bind(keepsake_id.to_string())
.bind(key)
.fetch_one(&mut *tx)
.await?
.try_get("value")?;
tx.commit().await?;
Ok(value)
}
#[cfg(feature = "fulfillment-counters")]
pub async fn upsert_checklist_projection(
&self,
keepsake_id: Uuid,
item: &str,
complete: bool,
observed_at: DateTime<Utc>,
) -> RepositoryResult<()> {
sqlx::query(
r"
insert into keepsake_fulfillment_checklist
(keepsake_id, item, complete, observed_at)
values (?, ?, ?, ?)
on duplicate key update
complete = values(complete),
observed_at = values(observed_at)
",
)
.bind(keepsake_id.to_string())
.bind(item)
.bind(i64::from(complete))
.bind(naive_timestamp(observed_at))
.execute(&self.pool)
.await?;
Ok(())
}
}
impl<C> ActiveRelationSource for MySqlKeepsakeRepository<C>
where
C: RelationCache,
{
type Error = RepositoryError;
async fn active_relations_for_subject<'a>(
&'a self,
subject: &'a SubjectRef,
) -> RepositoryResult<Vec<ActiveRelation>> {
self.active_relations_for_subject(subject).await
}
async fn active_relations_for_subject_by_ids<'a>(
&'a self,
subject: &'a SubjectRef,
relation_ids: &'a [RelationId],
) -> RepositoryResult<Vec<ActiveRelation>> {
self.active_relations_for_subject_by_ids(subject, relation_ids)
.await
}
async fn active_relations_for_subject_by_keys<'a>(
&'a self,
subject: &'a SubjectRef,
keys: &'a [RelationKey],
) -> RepositoryResult<Vec<ActiveRelation>> {
self.active_relations_for_subject_by_keys(subject, keys)
.await
}
}
async fn record_audit_event_tx(
tx: &mut Transaction<'_, MySql>,
event: &AuditEvent,
) -> RepositoryResult<i64> {
let result = sqlx::query(
r"
insert into keepsake_audit_events
(keepsake_id, relation_id, subject_kind, subject_id, actor_kind, actor_id,
event_type, decision, occurred_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
",
)
.bind(event.keepsake_id.to_string())
.bind(event.relation_id.to_string())
.bind(&event.subject.kind)
.bind(&event.subject.id)
.bind(&event.actor.kind)
.bind(&event.actor.id)
.bind(event.event_type.as_str())
.bind(serde_json::to_value(&event.decision)?)
.bind(naive_timestamp(event.at))
.execute(&mut **tx)
.await?;
let audit_event_id = i64::try_from(result.last_insert_id())
.map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
if event.context.attributes.is_empty() {
return Ok(audit_event_id);
}
let mut builder = sqlx::QueryBuilder::<MySql>::new(
"insert into keepsake_audit_context_attributes (audit_event_id, `key`, value) ",
);
builder.push_values(&event.context.attributes, |mut row, (key, value)| {
row.push_bind(audit_event_id)
.push_bind(key.as_str())
.push_bind(value.as_str());
});
builder.build().execute(&mut **tx).await?;
Ok(audit_event_id)
}
async fn hydrate_audit_records(
pool: &sqlx::MySqlPool,
rows: Vec<sqlx::mysql::MySqlRow>,
) -> RepositoryResult<Vec<AuditEventRecord>> {
if rows.is_empty() {
return Ok(Vec::new());
}
let ids = rows
.iter()
.map(|row| row.try_get::<i64, _>("id"))
.collect::<Result<Vec<i64>, _>>()?;
let mut attributes = audit_attributes_by_event(pool, &ids).await?;
rows.into_iter()
.map(|row| {
let id = row.try_get::<i64, _>("id")?;
let decision = serde_json::from_value::<AuditDecision>(row.try_get("decision")?)?;
audit_event_record(AuditEventParts {
id,
event_type: row.try_get("event_type")?,
at: utc_timestamp(row.try_get("occurred_at")?),
actor_kind: row.try_get("actor_kind")?,
actor_id: row.try_get("actor_id")?,
keepsake_id: parse_uuid(row.try_get("keepsake_id")?)?,
subject_kind: row.try_get("subject_kind")?,
subject_id: row.try_get("subject_id")?,
relation_id: parse_uuid(row.try_get("relation_id")?)?,
decision,
attributes: attributes.remove(&id).unwrap_or_default(),
})
})
.collect()
}
async fn audit_attributes_by_event(
pool: &sqlx::MySqlPool,
ids: &[i64],
) -> RepositoryResult<BTreeMap<i64, BTreeMap<String, String>>> {
let mut builder = sqlx::QueryBuilder::<MySql>::new(
"select audit_event_id, `key`, value from keepsake_audit_context_attributes \
where audit_event_id in (",
);
let mut separated = builder.separated(", ");
for id in ids {
separated.push_bind(id);
}
builder.push(")");
let rows = builder
.build_query_as::<(i64, String, String)>()
.fetch_all(pool)
.await?;
let mut attributes = BTreeMap::<i64, BTreeMap<String, String>>::new();
for (event_id, key, value) in rows {
attributes.entry(event_id).or_default().insert(key, value);
}
Ok(attributes)
}
#[cfg(feature = "fulfillment-counters")]
async fn due_fulfilled_expiry_after_tx(
tx: &mut Transaction<'_, MySql>,
after: Option<&FulfilledExpiryCursor>,
limit: i64,
) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
let after_relation_id = after.map(|cursor| cursor.relation_id.to_string());
let after_keepsake_id = after.map(|cursor| cursor.keepsake_id.to_string());
let rows = sqlx::query(
r"
select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
from keepsakes k
join keepsake_relation_definitions r on r.id = k.relation_id
where k.fulfillment_pending = 1
and r.enabled
and (
? is null
or (k.relation_id, k.subject_kind, k.subject_id, k.id) > (?, ?, ?, ?)
)
order by k.relation_id, k.subject_kind, k.subject_id, k.id
limit ?
",
)
.bind(after_relation_id.as_deref())
.bind(after_relation_id.as_deref())
.bind(after.map(|cursor| cursor.subject_kind.as_str()))
.bind(after.map(|cursor| cursor.subject_id.as_str()))
.bind(after_keepsake_id.as_deref())
.bind(limit)
.fetch_all(&mut **tx)
.await?;
rows.iter()
.map(fulfilled_expiry_candidate_from_row)
.collect()
}
async fn relation_for_update_tx(
tx: &mut Transaction<'_, MySql>,
relation_id: RelationId,
) -> RepositoryResult<RelationDefinition> {
let row = sqlx::query(
r"
select id, kind, `key`, enabled, expiry_policy
from keepsake_relation_definitions
where id = ?
for update
",
)
.bind(relation_id.to_string())
.fetch_one(&mut **tx)
.await?;
relation_from_row(&row)
}
async fn active_keepsake_for_subject_relation_tx(
tx: &mut Transaction<'_, MySql>,
subject: &SubjectRef,
relation_id: RelationId,
) -> RepositoryResult<Option<Keepsake>> {
let row = sqlx::query(
r"
select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
expires_at, fulfilled_at, revoked_at, metadata
from keepsakes
where subject_kind = ? and subject_id = ? and relation_id = ? and state = 'applied'
for update
",
)
.bind(&subject.kind)
.bind(&subject.id)
.bind(relation_id.to_string())
.fetch_optional(&mut **tx)
.await?;
row.as_ref().map(keepsake_from_row).transpose()
}
async fn keepsake_by_id_tx(
tx: &mut Transaction<'_, MySql>,
keepsake_id: Uuid,
) -> RepositoryResult<Option<Keepsake>> {
let row = sqlx::query(
r"
select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
expires_at, fulfilled_at, revoked_at, metadata
from keepsakes
where id = ?
",
)
.bind(keepsake_id.to_string())
.fetch_optional(&mut **tx)
.await?;
row.as_ref().map(keepsake_from_row).transpose()
}
async fn revoke_tx(
tx: &mut Transaction<'_, MySql>,
keepsake_id: Uuid,
at: DateTime<Utc>,
) -> RepositoryResult<Option<Keepsake>> {
let result = sqlx::query(
r"
update keepsakes
set state = 'revoked', revoked_at = ?, updated_at = ?
where id = ? and state = 'applied'
",
)
.bind(naive_timestamp(at))
.bind(naive_timestamp(at))
.bind(keepsake_id.to_string())
.execute(&mut **tx)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
keepsake_by_id_tx(tx, keepsake_id).await
}
async fn revoke_by_subject_tx(
tx: &mut Transaction<'_, MySql>,
subject: &SubjectRef,
relation_id: RelationId,
at: DateTime<Utc>,
) -> RepositoryResult<Option<Keepsake>> {
let row = sqlx::query(
r"
select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
expires_at, fulfilled_at, revoked_at, metadata
from keepsakes
where subject_kind = ? and subject_id = ? and relation_id = ? and state = 'applied'
for update
",
)
.bind(&subject.kind)
.bind(&subject.id)
.bind(relation_id.to_string())
.fetch_optional(&mut **tx)
.await?;
let Some(keepsake) = row.as_ref().map(keepsake_from_row).transpose()? else {
return Ok(None);
};
revoke_tx(tx, keepsake.id(), at).await
}
async fn active_relation_rows_for_subject(
pool: &sqlx::MySqlPool,
subject: &SubjectRef,
) -> RepositoryResult<Vec<(Keepsake, RelationDefinition)>> {
let rows = sqlx::query(
r"
select
k.id,
k.subject_kind,
k.subject_id,
k.relation_id,
k.state,
k.expiry_policy,
k.applied_at,
k.expires_at,
k.fulfilled_at,
k.revoked_at,
k.metadata,
r.id as relation_definition_id,
r.kind as relation_kind,
r.`key` as relation_key,
r.enabled as relation_enabled,
r.expiry_policy as relation_expiry_policy
from keepsakes k
join keepsake_relation_definitions r on r.id = k.relation_id
where k.subject_kind = ? and k.subject_id = ? and k.state = 'applied'
order by k.relation_id, k.id
",
)
.bind(&subject.kind)
.bind(&subject.id)
.fetch_all(pool)
.await?;
rows.iter()
.map(|row| {
Ok((
keepsake_from_row(row)?,
relation_definition_from_active_row(row)?,
))
})
.collect()
}
#[cfg(feature = "fulfillment-counters")]
async fn fulfillment_snapshot_tx(
tx: &mut Transaction<'_, MySql>,
keepsake_id: Uuid,
) -> RepositoryResult<FulfillmentSnapshot> {
let counter_rows = sqlx::query(
r"
select `key`, value
from keepsake_fulfillment_counters
where keepsake_id = ?
",
)
.bind(keepsake_id.to_string())
.fetch_all(&mut **tx)
.await?;
let checklist_rows = sqlx::query(
r"
select item, complete
from keepsake_fulfillment_checklist
where keepsake_id = ?
",
)
.bind(keepsake_id.to_string())
.fetch_all(&mut **tx)
.await?;
snapshot_from_rows(&counter_rows, &checklist_rows)
}
fn relation_from_row(row: &sqlx::mysql::MySqlRow) -> RepositoryResult<RelationDefinition> {
let expiry = serde_json::from_value::<ExpiryPolicy>(row.try_get("expiry_policy")?)?;
Ok(RelationDefinition::new(
parse_uuid(row.try_get("id")?)?,
RelationKey::new(
row.try_get::<String, _>("kind")?,
row.try_get::<String, _>("key")?,
)?,
row.try_get("enabled")?,
expiry,
)?)
}
fn keepsake_from_row(row: &sqlx::mysql::MySqlRow) -> RepositoryResult<Keepsake> {
let metadata = serde_json::from_value::<BTreeMap<String, String>>(row.try_get("metadata")?)?;
let expiry = serde_json::from_value::<ExpiryPolicy>(row.try_get("expiry_policy")?)?;
Ok(KeepsakeRecord {
id: parse_uuid(row.try_get("id")?)?,
subject: SubjectRef {
kind: row.try_get("subject_kind")?,
id: row.try_get("subject_id")?,
},
relation_id: parse_uuid(row.try_get("relation_id")?)?,
state: parse_state(row.try_get("state")?)?,
expiry,
applied_at: utc_timestamp(row.try_get("applied_at")?),
expires_at: optional_utc_timestamp(row.try_get("expires_at")?),
fulfilled_at: optional_utc_timestamp(row.try_get("fulfilled_at")?),
revoked_at: optional_utc_timestamp(row.try_get("revoked_at")?),
metadata,
}
.try_into()?)
}
fn relation_definition_from_active_row(
row: &sqlx::mysql::MySqlRow,
) -> RepositoryResult<RelationDefinition> {
let expiry = serde_json::from_value::<ExpiryPolicy>(row.try_get("relation_expiry_policy")?)?;
Ok(RelationDefinition::new(
parse_uuid(row.try_get("relation_definition_id")?)?,
RelationKey::new(
row.try_get::<String, _>("relation_kind")?,
row.try_get::<String, _>("relation_key")?,
)?,
row.try_get("relation_enabled")?,
expiry,
)?)
}
fn timed_expiry_candidate_from_row(
row: &sqlx::mysql::MySqlRow,
) -> RepositoryResult<TimedExpiryCandidate> {
Ok(TimedExpiryCandidate {
keepsake_id: parse_uuid(row.try_get("keepsake_id")?)?,
relation_id: parse_uuid(row.try_get("relation_id")?)?,
subject_kind: row.try_get("subject_kind")?,
subject_id: row.try_get("subject_id")?,
due_at: utc_timestamp(row.try_get("due_at")?),
})
}
#[cfg(feature = "fulfillment-counters")]
fn fulfilled_expiry_candidate_from_row(
row: &sqlx::mysql::MySqlRow,
) -> RepositoryResult<FulfilledExpiryCandidate> {
Ok(FulfilledExpiryCandidate {
keepsake_id: parse_uuid(row.try_get("keepsake_id")?)?,
relation_id: parse_uuid(row.try_get("relation_id")?)?,
subject_kind: row.try_get("subject_kind")?,
subject_id: row.try_get("subject_id")?,
expiry_policy: serde_json::from_value(row.try_get("expiry_policy")?)?,
})
}
#[cfg(feature = "fulfillment-counters")]
#[derive(Debug, Clone)]
struct FulfilledExpiryCursor {
relation_id: Uuid,
subject_kind: String,
subject_id: String,
keepsake_id: Uuid,
}
#[cfg(feature = "fulfillment-counters")]
impl From<&FulfilledExpiryCandidate> for FulfilledExpiryCursor {
fn from(candidate: &FulfilledExpiryCandidate) -> Self {
Self {
relation_id: candidate.relation_id,
subject_kind: candidate.subject_kind.clone(),
subject_id: candidate.subject_id.clone(),
keepsake_id: candidate.keepsake_id,
}
}
}
#[cfg(feature = "fulfillment-counters")]
fn snapshot_from_rows(
counter_rows: &[sqlx::mysql::MySqlRow],
checklist_rows: &[sqlx::mysql::MySqlRow],
) -> RepositoryResult<FulfillmentSnapshot> {
let mut counters = BTreeMap::new();
for row in counter_rows {
counters.insert(row.try_get("key")?, row.try_get("value")?);
}
let mut checklist = BTreeMap::new();
for row in checklist_rows {
checklist.insert(
row.try_get("item")?,
row.try_get::<i64, _>("complete")? != 0,
);
}
Ok(FulfillmentSnapshot {
counters,
checklist,
})
}
const fn naive_timestamp(value: DateTime<Utc>) -> NaiveDateTime {
value.naive_utc()
}
const fn utc_timestamp(value: NaiveDateTime) -> DateTime<Utc> {
DateTime::from_naive_utc_and_offset(value, Utc)
}
fn optional_utc_timestamp(value: Option<NaiveDateTime>) -> Option<DateTime<Utc>> {
value.map(utc_timestamp)
}