use arkhe_kernel::abi::{EntityId, Tick, TypeCode};
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use crate::action::ActionCompute;
use crate::actor::ActorId;
use crate::brand::{ShellBrand, ShellId};
use crate::context::{ActionContext, ActionError};
use crate::entry::EntryId;
use crate::space::SpaceId;
use crate::ArkheAction;
use crate::ArkheComponent;
use crate::arkhe_pure;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ActivityId(EntityId);
impl ActivityId {
#[inline]
#[must_use]
pub fn new(id: EntityId) -> Self {
Self(id)
}
#[inline]
#[must_use]
pub fn get(self) -> EntityId {
self.0
}
}
pub struct CanonicalVerb<const C: u32> {
_private: (),
}
pub struct ShellVerb<const C: u32> {
_private: (),
}
impl<const C: u32> ShellVerb<C> {
#[inline]
#[must_use]
pub const fn try_new() -> Option<Self> {
if C >= 0x0002_0400 && C <= 0x0002_FFFF {
Some(Self { _private: () })
} else {
None
}
}
}
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct VerbCode(TypeCode);
impl VerbCode {
#[inline]
#[must_use]
pub const fn canonical<const C: u32>(_witness: CanonicalVerb<C>) -> Self {
Self(TypeCode(C))
}
#[inline]
#[must_use]
pub const fn shell<const C: u32>(_witness: ShellVerb<C>) -> Self {
Self(TypeCode(C))
}
#[inline]
#[must_use]
pub const fn code(self) -> TypeCode {
self.0
}
}
pub mod canonical_verbs {
use super::CanonicalVerb;
pub const LIKE: CanonicalVerb<0x0002_0001> = CanonicalVerb { _private: () };
pub const FOLLOW: CanonicalVerb<0x0002_0002> = CanonicalVerb { _private: () };
pub const BOOKMARK: CanonicalVerb<0x0002_0003> = CanonicalVerb { _private: () };
pub const REPORT: CanonicalVerb<0x0002_0004> = CanonicalVerb { _private: () };
pub const MUTE: CanonicalVerb<0x0002_0005> = CanonicalVerb { _private: () };
pub const BLOCK: CanonicalVerb<0x0002_0006> = CanonicalVerb { _private: () };
pub const PIN: CanonicalVerb<0x0002_0007> = CanonicalVerb { _private: () };
pub const FLAG: CanonicalVerb<0x0002_0008> = CanonicalVerb { _private: () };
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum TargetKind {
Entry(EntryId) = 0,
Actor(ActorId) = 1,
Space(SpaceId) = 2,
Activity(ActivityId) = 3,
Extension {
type_code: TypeCode,
id: EntityId,
} = 4,
}
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
pub struct TargetKey {
pub kind_code: u8,
pub type_code: TypeCode,
pub id: EntityId,
pub target_shell_id: ShellId,
}
impl TargetKind {
#[must_use]
pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
self.key_with_shell(ShellId([0u8; 16]))
}
#[must_use]
pub fn key_with_shell(&self, target_shell_id: ShellId) -> TargetKey {
let (kind_code, type_code, id) = match self {
Self::Entry(e) => (1u8, TypeCode(0), e.get()),
Self::Actor(a) => (2u8, TypeCode(0), a.get()),
Self::Space(s) => (3u8, TypeCode(0), s.get()),
Self::Activity(a) => (4u8, TypeCode(0), a.get()),
Self::Extension { type_code, id } => (5u8, *type_code, *id),
};
TargetKey {
kind_code,
type_code,
id,
target_shell_id,
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
pub struct EntityShellId {
pub schema_version: u16,
pub shell_id: ShellId,
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum ActivityStatus {
Active = 0,
Retracted {
at: Tick,
} = 1,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
pub struct ActivityRecord {
pub schema_version: u16,
pub shell_id: ShellId,
pub actor: ActorId,
pub verb: VerbCode,
pub target: TargetKind,
pub at_tick: Tick,
pub status: ActivityStatus,
pub extra_bytes: Bytes,
}
#[derive(Clone)]
pub struct Activity<'s> {
brand: ShellBrand<'s>,
inner: ActivityRecord,
}
impl<'s> Activity<'s> {
#[inline]
#[must_use]
pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
Self { brand, inner }
}
#[inline]
#[must_use]
pub fn inner(&self) -> &ActivityRecord {
&self.inner
}
#[inline]
#[must_use]
pub fn brand(&self) -> ShellBrand<'s> {
self.brand
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
pub struct SubmitActivity {
pub schema_version: u16,
pub record: ActivityRecord,
pub idempotency_key: Option<[u8; 16]>,
}
impl SubmitActivity {
#[inline]
#[must_use]
pub fn from_branded(a: Activity<'_>) -> Self {
Self {
schema_version: 1,
record: a.inner,
idempotency_key: None,
}
}
#[inline]
#[must_use]
pub fn with_idempotency_key(mut self, key: [u8; 16]) -> Self {
self.idempotency_key = Some(key);
self
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
pub struct RetractActivity {
pub schema_version: u16,
pub activity: ActivityId,
}
impl ActionCompute for SubmitActivity {
#[arkhe_pure]
fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
ctx.ensure_actor_eligible(self.record.actor, ctx.tick())?;
if let Some(key) = self.idempotency_key {
if ctx.idempotency_lookup(&key).is_some() {
return Err(ActionError::IdempotencyConflict(key));
}
}
let predicted = ctx.preview_next_id_for::<ActivityRecord>()?;
if let TargetKind::Activity(target) = &self.record.target {
if target.get() == predicted {
return Err(ActionError::InvalidInput("activity self-loop"));
}
}
let activity_entity = ctx.spawn_entity_for::<ActivityRecord>()?;
ctx.set_component(activity_entity, &self.record)?;
Ok(())
}
}
impl ActionCompute for RetractActivity {
#[arkhe_pure]
fn compute<'i>(&self, _ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
Ok(())
}
}
pub const APPEAL_MAX_DEPTH_CAP: u8 = 8;
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::action::ArkheAction;
use crate::component::ArkheComponent;
fn ent(v: u64) -> EntityId {
EntityId::new(v).unwrap()
}
#[test]
fn verb_code_canonical_extraction() {
let vc = VerbCode::canonical(canonical_verbs::LIKE);
assert_eq!(vc.code(), TypeCode(0x0002_0001));
}
#[test]
fn verb_code_canonical_vs_shell_differ() {
let like = VerbCode::canonical(canonical_verbs::LIKE);
let custom = VerbCode::shell(ShellVerb::<0x0002_0400>::try_new().unwrap());
assert_ne!(like, custom);
}
#[test]
fn shell_verb_try_new_rejects_out_of_range_const() {
assert!(ShellVerb::<0x0001_0000>::try_new().is_none());
assert!(ShellVerb::<0x0003_0000>::try_new().is_none());
assert!(ShellVerb::<0x0002_0400>::try_new().is_some());
assert!(ShellVerb::<0x0002_FFFF>::try_new().is_some());
}
#[test]
fn activity_record_serde_roundtrip_postcard() {
let rec = ActivityRecord {
schema_version: 1,
shell_id: ShellId([0u8; 16]),
actor: ActorId::new(ent(1)),
verb: VerbCode::canonical(canonical_verbs::LIKE),
target: TargetKind::Entry(EntryId::new(ent(2))),
at_tick: Tick(5),
status: ActivityStatus::Active,
extra_bytes: Bytes::new(),
};
let bytes = postcard::to_stdvec(&rec).unwrap();
let back: ActivityRecord = postcard::from_bytes(&bytes).unwrap();
assert_eq!(rec, back);
}
#[test]
fn submit_activity_preserves_record_on_branded_unwrap() {
ShellBrand::run(|brand| {
let rec = ActivityRecord {
schema_version: 1,
shell_id: ShellId([0u8; 16]),
actor: ActorId::new(ent(1)),
verb: VerbCode::canonical(canonical_verbs::FOLLOW),
target: TargetKind::Actor(ActorId::new(ent(2))),
at_tick: Tick(0),
status: ActivityStatus::Active,
extra_bytes: Bytes::new(),
};
let activity = Activity::new(brand, rec.clone());
let submit = SubmitActivity::from_branded(activity);
assert_eq!(submit.record, rec);
assert!(submit.idempotency_key.is_none());
});
}
#[test]
fn submit_activity_with_idempotency_key_attaches() {
let rec = ActivityRecord {
schema_version: 1,
shell_id: ShellId([0u8; 16]),
actor: ActorId::new(ent(1)),
verb: VerbCode::canonical(canonical_verbs::LIKE),
target: TargetKind::Entry(EntryId::new(ent(2))),
at_tick: Tick(0),
status: ActivityStatus::Active,
extra_bytes: Bytes::new(),
};
let submit = SubmitActivity {
schema_version: 1,
record: rec,
idempotency_key: None,
}
.with_idempotency_key([0xCD; 16]);
assert_eq!(submit.idempotency_key, Some([0xCD; 16]));
}
#[test]
fn activity_types_expose_trait_consts() {
assert_eq!(ActivityRecord::TYPE_CODE, 0x0003_0401);
assert_eq!(EntityShellId::TYPE_CODE, 0x0003_0402);
assert_eq!(SubmitActivity::TYPE_CODE, 0x0001_0401);
assert_eq!(SubmitActivity::BAND, 1);
assert_eq!(RetractActivity::TYPE_CODE, 0x0001_0402);
const { assert!(SubmitActivity::IDEMPOTENT) };
const { assert!(!RetractActivity::IDEMPOTENT) };
}
#[test]
fn target_kind_key_with_shell_stamps_discriminant_and_shell() {
let entry = TargetKind::Entry(EntryId::new(ent(7)));
let shell = ShellId([0x33u8; 16]);
let k = entry.key_with_shell(shell);
assert_eq!(k.kind_code, 1);
assert_eq!(k.id.get(), 7);
assert_eq!(k.target_shell_id, shell);
let ext = TargetKind::Extension {
type_code: TypeCode(0x0100_0001),
id: ent(9),
};
let k2 = ext.key_with_shell(shell);
assert_eq!(k2.kind_code, 5);
assert_eq!(k2.type_code, TypeCode(0x0100_0001));
}
#[test]
fn appeal_cap_is_eight() {
assert_eq!(APPEAL_MAX_DEPTH_CAP, 8);
}
}