Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).
Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
`0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
(service, op) → script), `interceptor_repo` (insert/delete/list + the
nearest-owner-wins `resolve_before` chain walk), reconciled through the
declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
(shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
resolves that name and runs it via `run_resolved_blocking` (extracted from
`invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.
Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).
Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).
Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
12 KiB
Rust
296 lines
12 KiB
Rust
//! `Services` — bundle of stateful SDK service handles plumbed from the
|
|
//! host binary into every Rhai execution.
|
|
//!
|
|
//! Constructed once at startup in the picloud binary; cloned (cheap —
|
|
//! every field is an `Arc`) into the per-call sdk bridge so script
|
|
//! invocations don't need to re-resolve dependencies. The bundle is
|
|
//! handed to `executor-core::sdk::register_all` alongside an
|
|
//! `SdkCallCx` to wire each `::` namespace.
|
|
//!
|
|
//! v1.1.0 shipped this empty; v1.1.1 added the first two service fields
|
|
//! (`kv`, `dead_letters`) plus the `events` emitter that bound services
|
|
//! use to publish events into the triggers outbox. v1.1.3 adds the
|
|
//! `modules` field — the `ModuleSource` consulted by the per-call
|
|
//! `PicloudModuleResolver` to load `import`ed module scripts.
|
|
//!
|
|
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
|
|
//! consumers that only *pattern-match* a `&Services`; only crates that
|
|
//! *construct* a `Services` (the picloud binary and tests) update.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::{
|
|
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
|
|
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
|
|
InterceptorService, InvokeService, KvService, ModuleSource, NoopDeadLetterService,
|
|
NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
|
|
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
|
|
NoopHttpService, NoopInterceptorService, NoopInvokeService, NoopKvService, NoopModuleSource,
|
|
NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService,
|
|
NoopWorkflowService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
|
UsersService, VarsService, WorkflowService,
|
|
};
|
|
|
|
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
|
/// expansion plan.
|
|
#[non_exhaustive]
|
|
pub struct Services {
|
|
/// KV store (v1.1.1). Backed by Postgres in the picloud binary;
|
|
/// in-memory in tests.
|
|
pub kv: Arc<dyn KvService>,
|
|
|
|
/// Document store (v1.1.2). Backed by Postgres in the picloud
|
|
/// binary; in-memory in tests.
|
|
pub docs: Arc<dyn DocsService>,
|
|
|
|
/// Dead-letter management (v1.1.1). Scripts get
|
|
/// `dead_letters::replay(id)` and `dead_letters::resolve(id, reason)`.
|
|
pub dead_letters: Arc<dyn DeadLetterService>,
|
|
|
|
/// Event emitter for the triggers outbox. Mutating service methods
|
|
/// (`KvService::set/delete`, `DocsService::create/update/delete`,
|
|
/// future `files::*`, etc.) call `events.emit(cx, event)` after
|
|
/// the write succeeds. The outbox-backed impl in
|
|
/// `manager-core::outbox_event_emitter` replaces v1.1.0's
|
|
/// `NoopEventEmitter`.
|
|
pub events: Arc<dyn ServiceEventEmitter>,
|
|
|
|
/// Module source (v1.1.3). The `PicloudModuleResolver` consults
|
|
/// this to load `kind = 'module'` scripts that other scripts
|
|
/// `import`. Backed by Postgres in the picloud binary; in-memory
|
|
/// fakes in resolver tests.
|
|
pub modules: Arc<dyn ModuleSource>,
|
|
|
|
/// Outbound HTTP (v1.1.4). Scripts get `http::{get,post,…}`.
|
|
/// Backed by a reqwest client with the SSRF deny-list resolver in
|
|
/// the picloud binary; `NoopHttpService` in tests that don't make
|
|
/// network calls.
|
|
pub http: Arc<dyn HttpService>,
|
|
|
|
/// Filesystem-backed blob storage (v1.1.5). Scripts get
|
|
/// `files::collection(name).{create,head,get,update,delete,list}`.
|
|
/// Backed by a Postgres-metadata + on-disk-bytes repo in the
|
|
/// picloud binary; `NoopFilesService` in tests that don't touch
|
|
/// files.
|
|
pub files: Arc<dyn FilesService>,
|
|
|
|
/// Durable pub/sub (v1.1.5). Scripts get
|
|
/// `pubsub::publish_durable(topic, message)`. Backed by a
|
|
/// publish-time outbox fan-out in the picloud binary;
|
|
/// `NoopPubsubService` in tests that don't publish.
|
|
pub pubsub: Arc<dyn PubsubService>,
|
|
|
|
/// Encrypted per-app secrets (v1.1.7). Scripts get
|
|
/// `secrets::{get,set,delete,list}(name)`. Backed by an
|
|
/// AES-256-GCM-at-rest Postgres repo in the picloud binary;
|
|
/// `NoopSecretsService` in tests that don't touch secrets.
|
|
pub secrets: Arc<dyn SecretsService>,
|
|
|
|
/// Outbound email (v1.1.7). Scripts get `email::{send,send_html}`.
|
|
/// Backed by an SMTP relay (lettre) in the picloud binary;
|
|
/// `NoopEmailService` (always `NotConfigured`) in tests that don't
|
|
/// send mail.
|
|
pub email: Arc<dyn EmailService>,
|
|
|
|
/// Per-app data-plane user management (v1.1.8). Scripts get the
|
|
/// full `users::*` namespace (CRUD, login/verify/logout, email
|
|
/// verification, password reset, invitations, string-tagged
|
|
/// roles). Backed by Postgres-stored Argon2id hashes + SHA-256
|
|
/// session tokens in the picloud binary; `NoopUsersService` in
|
|
/// tests that don't exercise users.
|
|
pub users: Arc<dyn UsersService>,
|
|
|
|
/// Durable per-app named queues (v1.1.9). Scripts get
|
|
/// `queue::{enqueue,depth,depth_pending}`. Backed by Postgres in
|
|
/// the picloud binary; `NoopQueueService` in tests that don't
|
|
/// touch queues. Consumers register via `queue:receive` triggers
|
|
/// (one per `(app_id, queue_name)`).
|
|
pub queue: Arc<dyn QueueService>,
|
|
|
|
/// Same-app function composition (v1.1.9). Backs `invoke()` (sync)
|
|
/// and `invoke_async()` (fire-and-forget through the outbox).
|
|
/// Cross-app invokes are rejected at the service entry point.
|
|
pub invoke: Arc<dyn InvokeService>,
|
|
|
|
/// Group-inherited, env-scoped config (Phase 3). Scripts get
|
|
/// read-only `vars::{get,all}`; values resolve down the group tree
|
|
/// (§3). Backed by `config_resolver` over Postgres in the picloud
|
|
/// binary; `NoopVarsService` in tests that don't read config.
|
|
pub vars: Arc<dyn VarsService>,
|
|
|
|
/// Shared cross-app group collections (§11.6). Scripts get
|
|
/// `kv::shared_collection(name)` — read/write KV scoped to the nearest ancestor
|
|
/// group that declares the collection shared. Backed by Postgres in the
|
|
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
|
|
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
|
|
/// unchanged.
|
|
pub group_kv: Arc<dyn GroupKvService>,
|
|
|
|
/// Shared cross-app group DOCS collections (§11.6). Scripts get
|
|
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
|
|
/// defaults to `NoopGroupDocsService`.
|
|
pub group_docs: Arc<dyn GroupDocsService>,
|
|
|
|
/// Shared cross-app group FILES collections (§11.6). Scripts get
|
|
/// `files::shared_collection(name)`. Wired via [`Services::with_group_files`];
|
|
/// defaults to `NoopGroupFilesService`.
|
|
pub group_files: Arc<dyn GroupFilesService>,
|
|
|
|
/// Shared cross-app group TOPICS (§11.6 D2). Scripts get
|
|
/// `pubsub::shared_topic(name)` — publish to a group-scoped topic watched by
|
|
/// `shared = true` group pubsub triggers. Wired via
|
|
/// [`Services::with_group_pubsub`]; defaults to `NoopGroupPubsubService`.
|
|
pub group_pubsub: Arc<dyn GroupPubsubService>,
|
|
|
|
/// Shared cross-app group QUEUES (§11.6 D3). Scripts get
|
|
/// `queue::shared_collection(name)` — enqueue into a group-keyed store
|
|
/// drained by competing per-descendant consumers. Wired via
|
|
/// [`Services::with_group_queue`]; defaults to `NoopGroupQueueService`.
|
|
pub group_queue: Arc<dyn GroupQueueService>,
|
|
|
|
/// v1.2 Workflows (M5). Scripts get `workflow::start(name, input)` — start a
|
|
/// run of a named workflow in the caller's app. Wired via
|
|
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
|
|
pub workflow: Arc<dyn WorkflowService>,
|
|
|
|
/// §9.4 Service Interceptors — resolves which (if any) interceptor script
|
|
/// guards a `(service, op)` before it runs. Wired via
|
|
/// [`Services::with_interceptors`]; defaults to `NoopInterceptorService`
|
|
/// (nothing intercepted).
|
|
pub interceptors: Arc<dyn InterceptorService>,
|
|
}
|
|
|
|
impl Services {
|
|
/// Construct a bundle from already-constructed `Arc<dyn …>` handles.
|
|
/// The picloud binary's `main` wires this up after the DB pool is
|
|
/// open; tests build it from in-memory fakes.
|
|
#[must_use]
|
|
#[allow(clippy::too_many_arguments)] // one Arc per stateful service; a builder would just move the noise
|
|
pub fn new(
|
|
kv: Arc<dyn KvService>,
|
|
docs: Arc<dyn DocsService>,
|
|
dead_letters: Arc<dyn DeadLetterService>,
|
|
events: Arc<dyn ServiceEventEmitter>,
|
|
modules: Arc<dyn ModuleSource>,
|
|
http: Arc<dyn HttpService>,
|
|
files: Arc<dyn FilesService>,
|
|
pubsub: Arc<dyn PubsubService>,
|
|
secrets: Arc<dyn SecretsService>,
|
|
email: Arc<dyn EmailService>,
|
|
users: Arc<dyn UsersService>,
|
|
queue: Arc<dyn QueueService>,
|
|
invoke: Arc<dyn InvokeService>,
|
|
vars: Arc<dyn VarsService>,
|
|
) -> Self {
|
|
Self {
|
|
kv,
|
|
docs,
|
|
dead_letters,
|
|
events,
|
|
modules,
|
|
http,
|
|
files,
|
|
pubsub,
|
|
secrets,
|
|
email,
|
|
users,
|
|
queue,
|
|
invoke,
|
|
vars,
|
|
// §11.6 group collections default to noop; the picloud binary opts
|
|
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
|
|
// (mostly tests) unchanged — a field addition, not a param.
|
|
group_kv: Arc::new(NoopGroupKvService),
|
|
group_docs: Arc::new(NoopGroupDocsService),
|
|
group_files: Arc::new(NoopGroupFilesService),
|
|
group_pubsub: Arc::new(NoopGroupPubsubService),
|
|
group_queue: Arc::new(NoopGroupQueueService),
|
|
workflow: Arc::new(NoopWorkflowService),
|
|
interceptors: Arc::new(NoopInterceptorService),
|
|
}
|
|
}
|
|
|
|
/// Set the §9.4 interceptor resolver (picloud binary wires the
|
|
/// Postgres-backed impl; tests leave the noop default = nothing hooked).
|
|
#[must_use]
|
|
pub fn with_interceptors(mut self, interceptors: Arc<dyn InterceptorService>) -> Self {
|
|
self.interceptors = interceptors;
|
|
self
|
|
}
|
|
|
|
/// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed
|
|
/// impl; tests leave the noop default).
|
|
#[must_use]
|
|
pub fn with_workflow(mut self, workflow: Arc<dyn WorkflowService>) -> Self {
|
|
self.workflow = workflow;
|
|
self
|
|
}
|
|
|
|
/// Set the §11.6 shared group-KV service (the picloud binary wires the
|
|
/// Postgres-backed impl; tests leave the noop default).
|
|
#[must_use]
|
|
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
|
|
self.group_kv = group_kv;
|
|
self
|
|
}
|
|
|
|
/// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop).
|
|
#[must_use]
|
|
pub fn with_group_docs(mut self, group_docs: Arc<dyn GroupDocsService>) -> Self {
|
|
self.group_docs = group_docs;
|
|
self
|
|
}
|
|
|
|
/// Set the §11.6 shared group-FILES service (picloud binary; tests leave noop).
|
|
#[must_use]
|
|
pub fn with_group_files(mut self, group_files: Arc<dyn GroupFilesService>) -> Self {
|
|
self.group_files = group_files;
|
|
self
|
|
}
|
|
|
|
/// Set the §11.6 D2 shared group-TOPIC service (picloud binary; tests leave noop).
|
|
#[must_use]
|
|
pub fn with_group_pubsub(mut self, group_pubsub: Arc<dyn GroupPubsubService>) -> Self {
|
|
self.group_pubsub = group_pubsub;
|
|
self
|
|
}
|
|
|
|
/// Set the §11.6 D3 shared group-QUEUE service (picloud binary; tests leave noop).
|
|
#[must_use]
|
|
pub fn with_group_queue(mut self, group_queue: Arc<dyn GroupQueueService>) -> Self {
|
|
self.group_queue = group_queue;
|
|
self
|
|
}
|
|
|
|
/// All-noop bundle for tests that build an `Engine` but don't
|
|
/// exercise the stateful services. Returns the same shape as
|
|
/// `Services::new` so callers can't accidentally rely on a stub
|
|
/// silently doing the right thing — every call into a noop
|
|
/// service surfaces an explicit error.
|
|
#[must_use]
|
|
pub fn with_noop_services() -> Self {
|
|
Self::new(
|
|
Arc::new(NoopKvService),
|
|
Arc::new(NoopDocsService),
|
|
Arc::new(NoopDeadLetterService),
|
|
Arc::new(NoopEventEmitter),
|
|
Arc::new(NoopModuleSource),
|
|
Arc::new(NoopHttpService),
|
|
Arc::new(NoopFilesService),
|
|
Arc::new(NoopPubsubService),
|
|
Arc::new(NoopSecretsService),
|
|
Arc::new(NoopEmailService),
|
|
Arc::new(NoopUsersService),
|
|
Arc::new(NoopQueueService),
|
|
Arc::new(NoopInvokeService),
|
|
Arc::new(NoopVarsService),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Default for Services {
|
|
fn default() -> Self {
|
|
Self::with_noop_services()
|
|
}
|
|
}
|