From 3af8cc38c9e01acae4485cb6e345b8389f8d5b42 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 2 Jun 2026 19:54:56 +0200 Subject: [PATCH] feat(v1.1.2-docs): migrations + shared DocsService trait + TriggerEvent::Docs Migrations 0013_docs.sql + 0014_docs_triggers.sql land the docs table (JSONB body + GIN-on-jsonb_path_ops index, PK keyed on (app_id, collection, id) for cross-app isolation) and widen the triggers.kind and outbox.source_kind CHECK constraints to include 'docs', plus the docs_trigger_details detail table mirroring kv_trigger_details. picloud-shared grows the DocsService trait + DocRow/DocsListPage/ DocsError + NoopDocsService, the TriggerEvent::Docs variant with the prev_data change-data-capture surface, the DocsEventOp enum, the docs field on the Services bundle, and the SDK_VERSION bump 1.2 -> 1.3. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/migrations/0013_docs.sql | 39 +++ .../migrations/0014_docs_triggers.sql | 36 +++ crates/shared/src/docs.rs | 259 ++++++++++++++++++ crates/shared/src/lib.rs | 4 +- crates/shared/src/services.rs | 20 +- crates/shared/src/trigger_event.rs | 51 ++++ crates/shared/src/version.rs | 7 +- 7 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 crates/manager-core/migrations/0013_docs.sql create mode 100644 crates/manager-core/migrations/0014_docs_triggers.sql create mode 100644 crates/shared/src/docs.rs diff --git a/crates/manager-core/migrations/0013_docs.sql b/crates/manager-core/migrations/0013_docs.sql new file mode 100644 index 0000000..5294557 --- /dev/null +++ b/crates/manager-core/migrations/0013_docs.sql @@ -0,0 +1,39 @@ +-- v1.1.2: Documents — schemaless JSONB store with basic query semantics. +-- +-- Identity tuple `(app_id, collection, id)`. `id` is a server-generated +-- UUID; scripts never supply it on create. `app_id` is first in the +-- primary key so the implicit index is always per-app — cross-app reads +-- are impossible even under a buggy query. +-- +-- `data` is JSONB so scripts can store nested structures without a +-- separate serialization step. The GIN-on-`jsonb_path_ops` index +-- accelerates the v1.1.2 query DSL's equality and containment operators +-- (`docs::find` with `$eq` / `$in`); range/comparison operators rely on +-- the per-collection seq scan within the small `app_id` partition. +-- +-- `created_at` / `updated_at` are server-managed: created on insert, +-- bumped on every successful update. The returned doc envelope surfaces +-- both fields to scripts for read-only access (no script-side override). + +CREATE TABLE docs ( + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + collection TEXT NOT NULL, + id UUID NOT NULL, + data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (app_id, collection, id) +); + +-- The dispatcher/find hot path: "all docs in app X / collection Y." +-- The PK already covers (app_id, collection) as a prefix but spelling +-- out the explicit index makes intent clear for the planner. Mirrors +-- 0007_kv.sql's idx_kv_entries_app_collection. +CREATE INDEX idx_docs_app_collection ON docs (app_id, collection); + +-- GIN on JSONB with the `jsonb_path_ops` opclass: smaller index than +-- the default `jsonb_ops`, supports `@>` (containment) which is what +-- equality filters compile to under the GIN-friendly path. Range +-- operators ($gt/$gte/$lt/$lte/$ne) fall back to per-collection scans; +-- those are still bounded by the (app_id, collection) selectivity. +CREATE INDEX idx_docs_data_gin ON docs USING GIN (data jsonb_path_ops); diff --git a/crates/manager-core/migrations/0014_docs_triggers.sql b/crates/manager-core/migrations/0014_docs_triggers.sql new file mode 100644 index 0000000..7753fb0 --- /dev/null +++ b/crates/manager-core/migrations/0014_docs_triggers.sql @@ -0,0 +1,36 @@ +-- v1.1.2: Extend the triggers framework to recognise `docs` as the +-- second concrete kind (after `kv` in v1.1.1). +-- +-- Two CHECK constraints widen (no narrowing — both lists strictly +-- gain `'docs'`); one new detail table mirrors `kv_trigger_details`'s +-- shape with `DocsEventOp` ops instead of `KvEventOp`. Dispatcher +-- routing is generic across kinds — the same code path that handles +-- `Kv | DeadLetter` outbox rows now also handles `Docs` (single match +-- arm extension on the Rust side; no migration needed). + +-- Extend triggers.kind to include 'docs'. Constraint is in-line on the +-- column so Postgres auto-named it `triggers_kind_check`. Dropping the +-- old and adding the widened constraint is safe — no existing rows +-- carry a value outside the new set. +ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check; +ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check + CHECK (kind IN ('kv', 'dead_letter', 'docs')); + +-- Extend outbox.source_kind to include 'docs'. Same shape as above; +-- v1.1.1's existing source_kinds ('http', 'kv', 'dead_letter') stay. +ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check; +ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check + CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs')); + +-- One row per docs trigger. Same shape as `kv_trigger_details`: +-- collection_glob — "*" matches all, "foo*" prefix-matches, "foo" +-- exact-matches (Rust-side via collection_matches). +-- ops — subset of {create, update, delete}. Empty array +-- means "any op" (matches every docs mutation in +-- the collection). The admin endpoint rejects +-- empty collection_glob; ops can be empty. +CREATE TABLE docs_trigger_details ( + trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE, + collection_glob TEXT NOT NULL, + ops TEXT[] NOT NULL +); diff --git a/crates/shared/src/docs.rs b/crates/shared/src/docs.rs new file mode 100644 index 0000000..b6372ce --- /dev/null +++ b/crates/shared/src/docs.rs @@ -0,0 +1,259 @@ +//! `DocsService` — the v1.1.2 schemaless document store contract. +//! +//! Lives in `picloud-shared` (not `executor-core`) for the same reason +//! `KvService` does: the Rhai bridge, the manager-core Postgres impl, +//! and any future in-memory test impl all depend on the same trait +//! without dragging `executor-core` into `manager-core`'s dep graph. +//! +//! Implementations MUST derive every storage `app_id` from `cx.app_id` +//! — never from a script-passed argument. That is the cross-app +//! isolation boundary; see `docs/sdk-shape.md`. +//! +//! Filter shape (per `docs::find` / `find_one`) is an opaque +//! `serde_json::Value` at this layer; the manager-core implementation +//! parses it into a structured DSL with explicit operator allowlist +//! before touching SQL. Parser errors surface as +//! `DocsError::InvalidFilter` / `DocsError::UnsupportedOperator` so +//! scripts get a clear message naming the offending key. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use thiserror::Error; +use uuid::Uuid; + +use crate::SdkCallCx; + +/// Server-generated document identifier. Scripts see the `to_string()` +/// form as a Rhai string; the trait surface keeps the typed `Uuid` so +/// no implementation accidentally accepts a string-shaped path +/// parameter from a script. +pub type DocId = Uuid; + +/// One document as returned by `get` / `find` / `find_one`. The +/// envelope shape (decision D from the v1.1.2 plan): explicit +/// `id`+`data`+timestamps so user fields and platform metadata can't +/// alias. Scripts read user fields via `doc.data.`; timestamps +/// + id are direct children. +#[derive(Debug, Clone, PartialEq)] +pub struct DocRow { + pub id: DocId, + pub data: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// One page of `list`. `next_cursor` is `Some` when more pages exist, +/// `None` when exhausted. Mirrors `KvListPage`'s shape; the cursor +/// encoding is implementation-defined (the Postgres impl base64-encodes +/// the last id). +#[derive(Debug, Clone)] +pub struct DocsListPage { + pub docs: Vec, + pub next_cursor: Option, +} + +/// Collection-scoped CRUD + cursor list + filter-based find. +/// +/// Method shapes mirror `KvService`'s signature style (each takes +/// `&SdkCallCx` first non-self). The collection name is passed by +/// reference; the implementation rejects empty/whitespace-only +/// collections at the SDK boundary per `docs/sdk-shape.md`. +/// +/// `find` and `find_one` take the filter as `serde_json::Value` — the +/// service implementation parses it into a structured AST. Keeping the +/// trait signature untyped here lets the bridge convert +/// `Rhai Map → serde_json::Value` and hand it off without dragging the +/// parser into the shared crate. +#[async_trait] +pub trait DocsService: Send + Sync { + /// Create a new document with a server-generated UUID. Returns the + /// new id so the script can read/update/delete it later. The + /// document `data` must be a JSON object. + async fn create( + &self, + cx: &SdkCallCx, + collection: &str, + data: serde_json::Value, + ) -> Result; + + /// Fetch one document by id. Returns `None` for missing — the + /// bridge maps that to Rhai's `()`. + async fn get( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + ) -> Result, DocsError>; + + /// Filter-based query. Returns every matching document as a + /// `Vec` (empty when no matches). The filter is the + /// v1.1.2 query DSL shape — see `manager-core::docs_filter` for + /// the parser. Throws `InvalidFilter` / `UnsupportedOperator` on + /// parse errors. + async fn find( + &self, + cx: &SdkCallCx, + collection: &str, + filter: serde_json::Value, + ) -> Result, DocsError>; + + /// Single-result variant — equivalent to `find` with `$limit: 1` + /// then take-first. Returns `None` when no document matches. + async fn find_one( + &self, + cx: &SdkCallCx, + collection: &str, + filter: serde_json::Value, + ) -> Result, DocsError>; + + /// Full document replace. v1.1.2 has no partial-update DSL — + /// scripts that want partial update do `get + modify + update`. + /// Returns `DocsError::NotFound` if no such doc; otherwise emits + /// an `update` ServiceEvent with `prev_data` and `data`. + async fn update( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + data: serde_json::Value, + ) -> Result<(), DocsError>; + + /// Delete by id. Returns `bool was-present` (matches the `delete` + /// shape of every v1.1.x service). Emits a `delete` ServiceEvent + /// with `prev_data: Some(deleted_doc.data)` when the doc existed. + async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result; + + /// Cursor-paginated listing of every doc in the collection, + /// ordered by `id ASC` for stable cursor encoding. `None` cursor + /// starts from the beginning. Implementations cap `limit` at a + /// reasonable ceiling internally. + async fn list( + &self, + cx: &SdkCallCx, + collection: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result; +} + +/// Stub for tests that build a `Services` bundle without spinning up +/// Postgres. Every call returns `DocsError::Backend("...")` so +/// accidental docs use surfaces clearly. Mirrors `NoopKvService`. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopDocsService; + +#[async_trait] +impl DocsService for NoopDocsService { + async fn create( + &self, + _cx: &SdkCallCx, + _collection: &str, + _data: serde_json::Value, + ) -> Result { + Err(DocsError::Backend("docs is not wired in".into())) + } + + async fn get( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: DocId, + ) -> Result, DocsError> { + Err(DocsError::Backend("docs is not wired in".into())) + } + + async fn find( + &self, + _cx: &SdkCallCx, + _collection: &str, + _filter: serde_json::Value, + ) -> Result, DocsError> { + Err(DocsError::Backend("docs is not wired in".into())) + } + + async fn find_one( + &self, + _cx: &SdkCallCx, + _collection: &str, + _filter: serde_json::Value, + ) -> Result, DocsError> { + Err(DocsError::Backend("docs is not wired in".into())) + } + + async fn update( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: DocId, + _data: serde_json::Value, + ) -> Result<(), DocsError> { + Err(DocsError::Backend("docs is not wired in".into())) + } + + async fn delete( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: DocId, + ) -> Result { + Err(DocsError::Backend("docs is not wired in".into())) + } + + async fn list( + &self, + _cx: &SdkCallCx, + _collection: &str, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + Err(DocsError::Backend("docs is not wired in".into())) + } +} + +/// Failure modes surfaced to the Rhai bridge. The bridge converts each +/// to a Rhai runtime error string; the discriminants exist so internal +/// callers (admin endpoints, tests) can react more precisely. +#[derive(Debug, Error)] +pub enum DocsError { + /// Empty collection name; rejected at the SDK boundary per + /// `docs/sdk-shape.md`. + #[error("collection name must not be empty")] + InvalidCollection, + + /// `create`/`update` was handed a non-object JSON value (data must + /// be a JSON object so it can be navigated by field paths in + /// queries). + #[error("document data must be a JSON object")] + InvalidData, + + /// Parser rejected the filter — bad path syntax, malformed + /// operator value, multi-field `$sort`, etc. The string is the + /// script-visible message; it becomes part of the SDK contract + /// once a script depends on it. + #[error("invalid filter: {0}")] + InvalidFilter(String), + + /// Filter used an operator that's not in the v1.1.2 allowlist + /// (`$or`, `$regex`, `$exists`, …). String includes the offending + /// operator name + v1.2 pointer. + #[error("unsupported operator: {0}")] + UnsupportedOperator(String), + + /// `update` / `delete` target id does not exist. (`delete` returns + /// `Ok(false)` for "missing"; this variant is for `update` and any + /// future delete-must-exist callers.) + #[error("document not found")] + NotFound, + + /// Caller principal lacked the required capability. Only raised + /// when `cx.principal.is_some()` — scripts running with + /// `principal: None` (public HTTP) operate under script-as-gate + /// semantics and skip the capability check. + #[error("forbidden")] + Forbidden, + + /// Anything else — Postgres unavailable, serialization failure, + /// etc. The string is safe to surface to a script. + #[error("docs backend error: {0}")] + Backend(String), +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index e18fa80..ef8b937 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod auth; pub mod dead_letters; +pub mod docs; pub mod error; pub mod events; pub mod exec_summary; @@ -28,6 +29,7 @@ pub mod version; pub use app::{App, AppDomain, DomainShape}; pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService}; +pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService}; pub use error::Error; pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter}; pub use exec_summary::ExecResponseSummary; @@ -44,6 +46,6 @@ pub use sandbox::ScriptSandbox; pub use script::Script; pub use sdk_cx::SdkCallCx; pub use services::Services; -pub use trigger_event::{DeadLetterEventDetail, KvEventOp, TriggerEvent}; +pub use trigger_event::{DeadLetterEventDetail, DocsEventOp, KvEventOp, TriggerEvent}; pub use validator::{ScriptValidator, ValidationError}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 29e7c9d..98636b9 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -18,8 +18,8 @@ use std::sync::Arc; use crate::{ - DeadLetterService, KvService, NoopDeadLetterService, NoopEventEmitter, NoopKvService, - ServiceEventEmitter, + DeadLetterService, DocsService, KvService, NoopDeadLetterService, NoopDocsService, + NoopEventEmitter, NoopKvService, ServiceEventEmitter, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -30,15 +30,20 @@ pub struct Services { /// in-memory in tests. pub kv: Arc, + /// Document store (v1.1.2). Backed by Postgres in the picloud + /// binary; in-memory in tests. + pub docs: Arc, + /// Dead-letter management (v1.1.1). Scripts get /// `dead_letters::replay(id)` and `dead_letters::resolve(id, reason)`. pub dead_letters: Arc, /// 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`. + /// (`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, } @@ -49,11 +54,13 @@ impl Services { #[must_use] pub fn new( kv: Arc, + docs: Arc, dead_letters: Arc, events: Arc, ) -> Self { Self { kv, + docs, dead_letters, events, } @@ -68,6 +75,7 @@ impl Services { pub fn with_noop_services() -> Self { Self::new( Arc::new(NoopKvService), + Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), ) diff --git a/crates/shared/src/trigger_event.rs b/crates/shared/src/trigger_event.rs index 86c9e73..3095c36 100644 --- a/crates/shared/src/trigger_event.rs +++ b/crates/shared/src/trigger_event.rs @@ -45,6 +45,39 @@ impl KvEventOp { } } +/// Operations a docs trigger can fire on. v1.1.2. Stored as a +/// lowercase string in `docs_trigger_details.ops` (Postgres `text[]`). +/// Distinct from `KvEventOp` because docs has CRUD verbs (`create`) +/// instead of KV's set/upsert flavour (`insert`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DocsEventOp { + Create, + Update, + Delete, +} + +impl DocsEventOp { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + } + } + + #[must_use] + pub fn from_wire(s: &str) -> Option { + match s { + "create" => Some(Self::Create), + "update" => Some(Self::Update), + "delete" => Some(Self::Delete), + _ => None, + } + } +} + /// Discriminated description of a triggering event. Lifted from the /// outbox row's payload at dispatch time. Each variant carries the /// fields the corresponding `ctx.event` shape exposes to the script. @@ -61,6 +94,23 @@ pub enum TriggerEvent { value: Option, }, + /// A docs create / update / delete fired this handler. v1.1.2. + /// `data` is the current document state (absent on delete); + /// `prev_data` is the prior state (absent on create). For update + /// and delete handlers, `prev_data` is the load-bearing + /// change-data-capture surface (the repo reads the old row in the + /// same statement as the write). + Docs { + op: DocsEventOp, + collection: String, + /// UUID as string — Rhai sees it as a string. + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + prev_data: Option, + }, + /// A dead-letter row fired this handler. The original event is /// nested verbatim plus the dead-letter metadata the design notes /// §4 require. @@ -84,6 +134,7 @@ impl TriggerEvent { pub const fn source(&self) -> &'static str { match self { Self::Kv { .. } => "kv", + Self::Docs { .. } => "docs", Self::DeadLetter { .. } => "dead_letter", } } diff --git a/crates/shared/src/version.rs b/crates/shared/src/version.rs index e412ac4..50d31cc 100644 --- a/crates/shared/src/version.rs +++ b/crates/shared/src/version.rs @@ -22,7 +22,12 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// /// 1.2 additions (v1.1.1): `kv::collection(name).{get,set,has,delete,list}`, /// `dead_letters::{replay,resolve}`, `ctx.event` for triggered handlers. -pub const SDK_VERSION: &str = "1.2"; +/// +/// 1.3 additions (v1.1.2): +/// `docs::collection(name).{create,get,find,find_one,update,delete,list}` +/// with the v1.1.2 query DSL subset; `ctx.event.docs` for docs-trigger +/// handlers (carries `prev_data` change-data-capture for update/delete). +pub const SDK_VERSION: &str = "1.3"; /// HTTP API major version. Appears in URL paths as `/api/v{N}/...`. /// Bump (new integer + new URL prefix) when the request/response