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>
120 lines
4.8 KiB
Rust
120 lines
4.8 KiB
Rust
//! `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,
|
|
};
|
|
}
|
|
}
|