feat(shared): SdkCallCx, Services bundle, ServiceEventEmitter trait shape
Foundation for the v1.1.x stateful SDK services. Lands the shape only:
- SdkCallCx — per-call context plumbed into every future service
trait method (app_id, principal, execution/request ids, trigger
depth slots).
- Services — empty non_exhaustive bundle; v1.1.1 (KV) adds the first
field, subsequent PRs follow.
- ServiceEventEmitter — async trait future services emit through;
real outbox-backed impl lands with triggers in v1.1.1. NoopEventEmitter
is the v1.1.0 default.
No behaviour change. Subsequent commits in this PR plumb these types
through executor-core and the orchestrator dispatch path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
119
crates/shared/src/events.rs
Normal file
119
crates/shared/src/events.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! `ServiceEventEmitter` — the contract every stateful SDK service uses
|
||||
//! to publish events into the (future) triggers framework.
|
||||
//!
|
||||
//! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that
|
||||
//! drops every event. The real outbox-backed implementation lands with
|
||||
//! the triggers PR in v1.1.1; locking the trait now means services
|
||||
//! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to
|
||||
//! re-thread their plumbing when the dispatcher arrives.
|
||||
//!
|
||||
//! Design rationale (full discussion: `docs/sdk-shape.md`):
|
||||
//! * Async — outbox writes hit Postgres.
|
||||
//! * Cx is passed in so the emitter can attribute the event to the
|
||||
//! `app_id` / `principal` / `execution_id` that produced it.
|
||||
//! * Events carry their semantic identity (`source` + `op`) plus
|
||||
//! optional locator (`collection` + `key`) and optional payloads
|
||||
//! (`payload` for the new value, `old_payload` for the previous on
|
||||
//! updates). The dispatcher matches on (source, op, collection)
|
||||
//! filters to decide which scripts to fan out to.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::SdkCallCx;
|
||||
|
||||
/// Trait every stateful service depends on to emit events. The host
|
||||
/// binary constructs one instance and clones the Arc into each service.
|
||||
#[async_trait]
|
||||
pub trait ServiceEventEmitter: Send + Sync {
|
||||
/// Publish a single event. Implementations are expected to be
|
||||
/// fire-and-forget from the caller's perspective: the outbox impl
|
||||
/// will return `Ok(())` once the event is durably persisted, the
|
||||
/// dispatcher reads it out-of-band.
|
||||
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>;
|
||||
}
|
||||
|
||||
/// One service event. `source` and `op` are `&'static str` because they
|
||||
/// come from a fixed enumeration baked into each service (`"kv"` +
|
||||
/// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data.
|
||||
/// `collection`/`key`/payloads come from user data and are owned.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceEvent {
|
||||
/// Service namespace. Matches the Rhai module name: `"kv"`,
|
||||
/// `"docs"`, `"files"`, etc.
|
||||
pub source: &'static str,
|
||||
|
||||
/// Operation verb. Each service defines its own vocabulary;
|
||||
/// dispatcher filters match on the literal string.
|
||||
pub op: &'static str,
|
||||
|
||||
/// Affected collection, when the service is collection-scoped
|
||||
/// (`kv`, `docs`, `files`). `None` for collection-less events.
|
||||
pub collection: Option<String>,
|
||||
|
||||
/// Affected key/id within the collection, when applicable.
|
||||
pub key: Option<String>,
|
||||
|
||||
/// New value after the operation, when carrying it is cheap and
|
||||
/// useful. `None` for deletes.
|
||||
pub payload: Option<serde_json::Value>,
|
||||
|
||||
/// Previous value before the operation, populated on `update` /
|
||||
/// `delete` so triggers can diff. `None` on `insert`.
|
||||
pub old_payload: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Errors an emitter can surface upward. The noop impl never returns
|
||||
/// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection
|
||||
/// failures and `Rejected` for malformed payloads (e.g. event JSON too
|
||||
/// large for the outbox row).
|
||||
#[derive(Debug, Error)]
|
||||
pub enum EmitError {
|
||||
#[error("event sink unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
#[error("event sink rejected event: {0}")]
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
/// Default emitter for v1.1.0. Accepts every event, persists nothing,
|
||||
/// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1
|
||||
/// triggers PR swaps this for a Postgres outbox writer.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopEventEmitter;
|
||||
|
||||
#[async_trait]
|
||||
impl ServiceEventEmitter for NoopEventEmitter {
|
||||
async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Compile-time check that ServiceEventEmitter is dyn-safe — every
|
||||
// service holds it as `Arc<dyn ServiceEventEmitter>` and would
|
||||
// silently break the workspace if a non-object-safe method snuck
|
||||
// in. Behavioural tests for the noop impl come for free once a
|
||||
// service exercises it (v1.1.1+); avoid pulling tokio into
|
||||
// `picloud-shared` just for a one-line `emit().await` check.
|
||||
#[allow(dead_code)]
|
||||
fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {}
|
||||
|
||||
#[test]
|
||||
fn service_event_construction_is_explicit() {
|
||||
// Pin the field layout so a re-ordering in a future PR causes a
|
||||
// compile failure here rather than silently misattributing
|
||||
// events. Hash-derive isn't appropriate (serde_json::Value isn't
|
||||
// Hash), so structural construction is the assertion.
|
||||
let _ = ServiceEvent {
|
||||
source: "kv",
|
||||
op: "insert",
|
||||
collection: Some("widgets".into()),
|
||||
key: Some("k1".into()),
|
||||
payload: Some(serde_json::json!({"v": 1})),
|
||||
old_payload: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user