From 2669714a518373409ed4a5c0249e5bb0c7d2a949 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 30 May 2026 18:40:09 +0200 Subject: [PATCH] feat(shared): SdkCallCx, Services bundle, ServiceEventEmitter trait shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/shared/src/events.rs | 119 ++++++++++++++++++++++++++++++++++ crates/shared/src/lib.rs | 6 ++ crates/shared/src/sdk_cx.rs | 54 +++++++++++++++ crates/shared/src/services.rs | 38 +++++++++++ 4 files changed, 217 insertions(+) create mode 100644 crates/shared/src/events.rs create mode 100644 crates/shared/src/sdk_cx.rs create mode 100644 crates/shared/src/services.rs diff --git a/crates/shared/src/events.rs b/crates/shared/src/events.rs new file mode 100644 index 0000000..1720925 --- /dev/null +++ b/crates/shared/src/events.rs @@ -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, + + /// Affected key/id within the collection, when applicable. + pub key: Option, + + /// New value after the operation, when carrying it is cheap and + /// useful. `None` for deletes. + pub payload: Option, + + /// Previous value before the operation, populated on `update` / + /// `delete` so triggers can diff. `None` on `insert`. + pub old_payload: Option, +} + +/// 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` 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, + }; + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 53b864a..5714861 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -7,23 +7,29 @@ pub mod app; pub mod auth; pub mod error; +pub mod events; pub mod execution_log; pub mod ids; pub mod log_sink; pub mod route; pub mod sandbox; pub mod script; +pub mod sdk_cx; +pub mod services; pub mod validator; pub mod version; pub use app::{App, AppDomain, DomainShape}; pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; pub use error::Error; +pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}; pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId}; pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use route::{HostKind, PathKind, Route}; pub use sandbox::ScriptSandbox; pub use script::Script; +pub use sdk_cx::SdkCallCx; +pub use services::Services; pub use validator::{ScriptValidator, ValidationError}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; diff --git a/crates/shared/src/sdk_cx.rs b/crates/shared/src/sdk_cx.rs new file mode 100644 index 0000000..3fa4b35 --- /dev/null +++ b/crates/shared/src/sdk_cx.rs @@ -0,0 +1,54 @@ +//! `SdkCallCx` — per-call context every stateful SDK service receives. +//! +//! Service trait methods (added by subsequent v1.1.x PRs starting with +//! KV) all take `&SdkCallCx` so they can: +//! * scope by `app_id` for cross-app isolation, +//! * audit `principal` when authenticated, +//! * carry `execution_id` / `request_id` into emitted events, +//! * bound trigger chains via `trigger_depth` / `root_execution_id`. +//! +//! The struct lives in `picloud-shared` (not `executor-core`) because +//! future service impls live in `manager-core` and the trait that hands +//! the cx in is shared by both sides. Pure value type — no handles, no +//! DB pool references, no allocations beyond what's in `Principal`. + +use crate::{AppId, ExecutionId, Principal, RequestId}; + +/// Per-invocation context for every stateful SDK service call. +/// +/// Constructed once at the start of an invocation by `executor-core` +/// from the incoming `ExecRequest`, then handed (by reference) to every +/// service trait method the script triggers during execution. Services +/// MUST derive `app_id` from this struct — never from script-passed +/// arguments — to preserve cross-app isolation. +#[derive(Debug, Clone)] +pub struct SdkCallCx { + /// Owning application for this invocation. Source of truth for + /// every `(app_id, …)` storage lookup the script makes. + pub app_id: AppId, + + /// Caller identity, when authenticated. `None` for unauthenticated + /// data-plane HTTP requests (the common case for public endpoints); + /// `Some` when the call came in via the dashboard, an API key, or a + /// future authed surface. + pub principal: Option, + + /// Unique id for THIS execution. Matches `ExecRequest.execution_id`. + pub execution_id: ExecutionId, + + /// Unique id for the ingress request that started the chain. The + /// same `request_id` is shared across every execution triggered by + /// the same request (direct + trigger fan-out). + pub request_id: RequestId, + + /// `0` for direct invocations (HTTP request, manual run). Each + /// indirect invocation through the triggers framework (v1.1.1) + /// increments this; the dispatcher rejects beyond a configured + /// ceiling to prevent runaway feedback loops. + pub trigger_depth: u32, + + /// `== execution_id` when `trigger_depth == 0`; otherwise the + /// `execution_id` of the original ingress execution. Lets the audit + /// log group every fan-out execution under the originating event. + pub root_execution_id: ExecutionId, +} diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs new file mode 100644 index 0000000..6900c63 --- /dev/null +++ b/crates/shared/src/services.rs @@ -0,0 +1,38 @@ +//! `Services` — bundle of stateful SDK service handles plumbed from the +//! host binary into every Rhai execution. +//! +//! v1.1.0 ships this struct empty. Subsequent PRs in the v1.1.x series +//! add one field per service: +//! +//! ```ignore +//! pub kv: Arc, // v1.1.1 +//! pub docs: Arc, // v1.1.2 +//! pub http: Arc, // v1.1.4 +//! // … +//! ``` +//! +//! The bundle is cheap to clone (`Arc` per service) and is constructed +//! once at startup in the picloud binary. The executor takes it by +//! reference per invocation, hands it (alongside an `SdkCallCx`) to +//! `executor-core::sdk::register_all`, which wires the corresponding +//! Rhai `::` namespace per service. +//! +//! `#[non_exhaustive]` so adding fields is a non-breaking change for +//! consumers that only *pattern-match* a `&Services`; only crates that +//! *construct* a `Services` (in practice, just the picloud binary) need +//! to update their constructor when new services land. + +/// SDK service bundle. See module docs for the lifecycle and the v1.1.x +/// expansion plan. +#[non_exhaustive] +#[derive(Default)] +pub struct Services {} + +impl Services { + /// Construct an empty bundle. Replaced by a fielded `::new(...)` + /// once the first service (KV, v1.1.1) lands. + #[must_use] + pub fn new() -> Self { + Self {} + } +}