feat(v1.1.1-kv): migrations + KvService trait + Postgres impl

First v1.1.1 commit. Adds the KV store the design notes commit to:
`(app_id, collection, key)` identity with JSONB value and a per-app
index. Trait lives in `picloud-shared` so the executor-core Rhai
bridge (next commit), the Postgres impl, and tests all depend on the
same surface without coupling crates.

The `Services` bundle grows from empty to three fields: `kv`,
`dead_letters` (NoopDeadLetterService stub — replaced by the
Postgres impl in commit 8), and `events` (NoopEventEmitter until the
outbox emitter lands with the dispatcher). Tests use
`Services::default()` for an all-noop bundle.

New capabilities `AppKvRead` / `AppKvWrite` join the Capability
enum. They map onto the existing seven-value `Scope` (script:read /
script:write) — the scope vocabulary stays locked per the
`docs/versioning.md` commitment.

Script-as-gate semantics in `KvServiceImpl`: capability check runs
when `cx.principal.is_some()`, skipped when None (public HTTP).
Cross-app isolation is enforced independently by deriving every
row's `app_id` from `cx.app_id` rather than a script-passed argument.

In-memory `KvRepo` impl + unit tests cover the round-trips, the
cross-app isolation property, empty-collection rejection,
script-as-gate behaviour for both anonymous and authed contexts,
and cursor-style pagination. Postgres impl exists; integration
testing waits for a real DB harness (see HANDBACK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 21:29:59 +02:00
parent 1efb350b54
commit 434fb63cd2
14 changed files with 1143 additions and 42 deletions

View File

@@ -1,38 +1,81 @@
//! `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:
//! 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.
//!
//! ```ignore
//! pub kv: Arc<dyn KvService>, // v1.1.1
//! pub docs: Arc<dyn DocsService>, // v1.1.2
//! pub http: Arc<dyn HttpService>, // 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.
//! v1.1.0 shipped this empty; v1.1.1 adds the first two service fields
//! (`kv`, `dead_letters`) plus the `events` emitter that bound services
//! use to publish events into the triggers outbox.
//!
//! `#[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.
//! *construct* a `Services` (the picloud binary and tests) update.
use std::sync::Arc;
use crate::{
DeadLetterService, KvService, NoopDeadLetterService, NoopEventEmitter, NoopKvService,
ServiceEventEmitter,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
/// expansion plan.
#[non_exhaustive]
#[derive(Default)]
pub struct Services {}
pub struct Services {
/// KV store (v1.1.1). Backed by Postgres in the picloud binary;
/// in-memory in tests.
pub kv: Arc<dyn KvService>,
/// 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`, future `docs::*`, `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>,
}
impl Services {
/// Construct an empty bundle. Replaced by a fielded `::new(...)`
/// once the first service (KV, v1.1.1) lands.
/// 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]
pub fn new() -> Self {
Self {}
pub fn new(
kv: Arc<dyn KvService>,
dead_letters: Arc<dyn DeadLetterService>,
events: Arc<dyn ServiceEventEmitter>,
) -> Self {
Self {
kv,
dead_letters,
events,
}
}
/// 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(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
)
}
}
impl Default for Services {
fn default() -> Self {
Self::with_noop_services()
}
}