Skip to content

feat: Add lifecycle hook system for CRUD operations #116

@ds1sqe

Description

@ds1sqe

Problem

type-bridge has no way to react to CRUD lifecycle events. Common cross-cutting concerns — audit logging, validation, cache invalidation, async notifications — require manually wrapping every manager call today:

# Without hooks: every call site must remember to wrap
log.info("inserting person")
manager.insert(alice)
log.info("inserted person")
cache.invalidate("person")

This is error-prone, repetitive, and impossible to enforce consistently across a codebase.

Use Cases

Use case When Example
Audit logging After any write Log every insert/update/delete with type, IID, and timestamp
Input validation Before insert/update Reject entities with invalid email domains
Auto-populate fields Before insert Set created_at timestamp automatically
Cache invalidation After any write Evict stale entries on insert/update/delete
Rate limiting Before any CRUD request Throttle per-client write operations at the server level
Tenant-scoped filtering Before any CRUD request Inject tenant filters into queries at the server level
Async notifications After insert Send Slack/email notifications without blocking the write path

Proposed Design

Three independent but design-aligned hook layers. Each can be shipped separately.

Layer Scope Pattern
Python ORM Local — per-manager instance manager.add_hook(hook)
Rust ORM Local — per-manager instance manager.add_hook(hook)
Rust Server Network — per-request CrudInfo on RequestContext + CrudInterceptor trait

No global mutable state. ORM hooks live on the manager instance; server hooks use the existing interceptor chain.


1. Python ORM

Hooks are classes that implement the methods they care about. Register them on a manager instance.

Core types:

class CrudEvent(Enum):
    PRE_INSERT = "pre_insert"
    POST_INSERT = "post_insert"
    PRE_UPDATE = "pre_update"
    POST_UPDATE = "post_update"
    PRE_DELETE = "pre_delete"
    POST_DELETE = "post_delete"
    PRE_PUT = "pre_put"
    POST_PUT = "post_put"

class HookCancelled(Exception):
    """Raise in a pre-hook to abort the operation."""

Manager API:

manager = Person.manager(db)
manager.add_hook(hook)       # returns Self for chaining
manager.remove_hook(hook)

Writing a hook — implement only the methods you need:

class AuditHook:
    def post_insert(self, sender, instance):
        log.info(f"[insert] {sender.__name__} iid={instance.iid}")

    def post_update(self, sender, instance):
        log.info(f"[update] {sender.__name__} iid={instance.iid}")

    def post_delete(self, sender, instance):
        log.info(f"[delete] {sender.__name__} iid={instance.iid}")

Pre-hook validation — raise HookCancelled to abort:

class EmailDomainValidator:
    def __init__(self, domain: str):
        self.domain = domain

    def should_run(self, event: CrudEvent, sender: type) -> bool:
        return event in (CrudEvent.PRE_INSERT, CrudEvent.PRE_UPDATE)

    def pre_insert(self, sender, instance):
        if not instance.email.value.endswith(f"@{self.domain}"):
            raise HookCancelled(f"Email must end with @{self.domain}")

    def pre_update(self, sender, instance):
        self.pre_insert(sender, instance)  # same logic

Pre-hook mutation — modify the instance before it hits the database:

class TimestampHook:
    def pre_insert(self, sender, instance):
        if hasattr(instance, "created_at") and instance.created_at is None:
            instance.created_at = CreatedAt(datetime.now(timezone.utc))

Async hooks — define async_* variants; used automatically in async context:

class SlackNotifyHook:
    def __init__(self, slack_client):
        self.slack = slack_client

    async def async_post_insert(self, sender, instance):
        await self.slack.post(f"New {sender.__name__}: {instance}")

Composing hooks:

manager = (
    Person.manager(db)
    .add_hook(TimestampHook())
    .add_hook(EmailDomainValidator("company.com"))
    .add_hook(AuditHook())
    .add_hook(CacheInvalidationHook(cache))
)

Semantics:

  • Pre-hooks run in registration order; post-hooks run in reverse order (middleware unwinding)
  • Pre-hooks can cancel via HookCancelled; post-hooks log errors but don't propagate
  • should_run(event, sender) allows filtering by event type or model class
  • Zero overhead when no hooks are registered

2. Rust ORM

Same per-manager pattern. Hooks implement the LifecycleHook trait.

Core types:

pub enum CrudOperation { Insert, Update, Delete, Put }

pub struct HookContext {
    pub type_name: &'static str,
    pub type_kind: TypeKind,                             // Entity | Relation
    pub operation: CrudOperation,
    pub attributes: Vec<(&'static str, AttributeValue)>,
    pub iid: Option<String>,
    pub metadata: HashMap<String, serde_json::Value>,
    pub timestamp: DateTime<Utc>,
}

pub enum PreHookResult {
    /// Continue with optionally modified attributes.
    Continue { attributes: Option<Vec<(&'static str, AttributeValue)>> },
    /// Abort the operation.
    Reject { reason: String },
}

pub trait LifecycleHook: Send + Sync {
    fn name(&self) -> &str;
    fn before_operation(&self, ctx: &mut HookContext)
        -> BoxFuture<'_, Result<PreHookResult, HookError>>;
    fn after_operation(&self, ctx: &HookContext, result: &serde_json::Value)
        -> BoxFuture<'_, Result<(), HookError>>;
    fn should_run(&self, ctx: &HookContext) -> bool { true }
}

Manager API:

let mut manager = EntityManager::<Person>::new(&db);
manager.add_hook(hook);  // returns &mut Self for chaining

Audit hook:

struct AuditHook;

impl LifecycleHook for AuditHook {
    fn name(&self) -> &str { "audit" }

    fn before_operation(&self, _ctx: &mut HookContext)
        -> BoxFuture<'_, Result<PreHookResult, HookError>>
    {
        Box::pin(async { Ok(PreHookResult::Continue { attributes: None }) })
    }

    fn after_operation(&self, ctx: &HookContext, _result: &serde_json::Value)
        -> BoxFuture<'_, Result<(), HookError>>
    {
        Box::pin(async move {
            tracing::info!(
                operation = ?ctx.operation,
                type_name = ctx.type_name,
                iid = ?ctx.iid,
                "CRUD operation completed"
            );
            Ok(())
        })
    }
}

Validation hook (rejects bad data):

struct EmailDomainValidator { allowed_domain: String }

impl LifecycleHook for EmailDomainValidator {
    fn name(&self) -> &str { "email-domain-validator" }

    fn before_operation(&self, ctx: &mut HookContext)
        -> BoxFuture<'_, Result<PreHookResult, HookError>>
    {
        Box::pin(async move {
            for (name, value) in &ctx.attributes {
                if *name == "email" {
                    if let AttributeValue::String(email) = value {
                        if !email.ends_with(&format!("@{}", self.allowed_domain)) {
                            return Ok(PreHookResult::Reject {
                                reason: format!("Email must end with @{}", self.allowed_domain),
                            });
                        }
                    }
                }
            }
            Ok(PreHookResult::Continue { attributes: None })
        })
    }

    fn after_operation(&self, _ctx: &HookContext, _result: &serde_json::Value)
        -> BoxFuture<'_, Result<(), HookError>>
    {
        Box::pin(async { Ok(()) })
    }

    fn should_run(&self, ctx: &HookContext) -> bool {
        matches!(ctx.operation, CrudOperation::Insert | CrudOperation::Update)
    }
}

Composing hooks:

let mut manager = EntityManager::<Person>::new(&db);
manager
    .add_hook(TimestampHook)
    .add_hook(EmailDomainValidator { allowed_domain: "company.com".into() })
    .add_hook(AuditHook);

Semantics: same as Python — registration-order pre-hooks, reverse-order post-hooks, short-circuit on Reject, post-hook errors are logged but don't propagate, zero overhead when empty.


3. Rust Server — CRUD-Aware Interceptors

The server already has an Interceptor trait and InterceptorChain. This proposal enriches them with CRUD context so interceptors can distinguish CRUD operations from raw queries.

New CrudInfo on RequestContext:

#[derive(Debug, Clone, Default)]
pub struct CrudInfo {
    pub operation: Option<String>,    // "insert", "fetch", "update", "delete"
    pub type_name: Option<String>,    // "person"
    pub type_kind: Option<String>,    // "entity" | "relation"
    pub attribute_names: Vec<String>,
    pub iid: Option<String>,
}

impl CrudInfo {
    pub fn is_crud(&self) -> bool { self.operation.is_some() }
}

CRUD endpoints populate this; raw query endpoints leave it as Default.

New CrudInterceptor convenience trait:

pub trait CrudInterceptor: Send + Sync {
    fn name(&self) -> &str;
    fn on_crud_request<'a>(&'a self, clauses: Vec<Clause>, crud_info: &'a CrudInfo, ctx: &'a mut RequestContext)
        -> BoxFuture<'a, Result<Vec<Clause>, InterceptError>>;
    fn on_crud_response<'a>(&'a self, result: &'a serde_json::Value, crud_info: &'a CrudInfo, ctx: &'a RequestContext)
        -> BoxFuture<'a, Result<(), InterceptError>> { /* default no-op */ }
    fn should_intercept(&self, crud_info: &CrudInfo) -> bool { true }
}

CrudInterceptorAdapter<T> bridges CrudInterceptor into the existing Interceptor trait, passing non-CRUD requests through unchanged.

Rate limiting example:

struct RateLimiter { limiter: Arc<RateLimiterBackend> }

impl CrudInterceptor for RateLimiter {
    fn name(&self) -> &str { "rate-limiter" }

    fn on_crud_request<'a>(&'a self, clauses: Vec<Clause>, crud_info: &'a CrudInfo, ctx: &'a mut RequestContext)
        -> BoxFuture<'a, Result<Vec<Clause>, InterceptError>>
    {
        Box::pin(async move {
            let key = format!("{}:{}", ctx.client_id, crud_info.type_name.as_deref().unwrap_or("?"));
            if !self.limiter.check(&key).await {
                return Err(InterceptError::RateLimited {
                    reason: format!("Too many requests for {key}"),
                });
            }
            Ok(clauses)
        })
    }

    fn should_intercept(&self, crud_info: &CrudInfo) -> bool {
        matches!(crud_info.operation.as_deref(), Some("insert" | "update" | "delete"))
    }
}

Tenant-scoped filtering example:

struct TenantFilter { tenant_attr: String }

impl CrudInterceptor for TenantFilter {
    fn name(&self) -> &str { "tenant-filter" }

    fn on_crud_request<'a>(&'a self, mut clauses: Vec<Clause>, _crud_info: &'a CrudInfo, ctx: &'a mut RequestContext)
        -> BoxFuture<'a, Result<Vec<Clause>, InterceptError>>
    {
        Box::pin(async move {
            let tenant_id = ctx.metadata.get("tenant_id")
                .and_then(|v| v.as_str())
                .ok_or_else(|| InterceptError::AccessDenied {
                    reason: "Missing tenant_id".into(),
                })?;
            inject_attribute_filter(&mut clauses, &self.tenant_attr, tenant_id);
            Ok(clauses)
        })
    }

    fn should_intercept(&self, crud_info: &CrudInfo) -> bool {
        crud_info.is_crud()
    }
}

Registering on the chain:

let chain = InterceptorChain::new(vec![
    Arc::new(CrudInterceptorAdapter(RateLimiter { limiter })),
    Arc::new(CrudInterceptorAdapter(TenantFilter { tenant_attr: "tenant_id".into() })),
    Arc::new(AuditLogInterceptor::new(&config)?),
]);

Non-goals

  • Transactional hooks (participate in the DB transaction and roll back with it)
  • Schema-change hooks
  • Read-operation hooks (could be added later)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions