Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions distributed_cli/skills/distributed-graphql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ Pass `change_stream(repo.read_model_changes())` for live subscriptions.
Command mutations: register `GraphqlCommands` on the builder and use
`graphql_router_with_service` / `with_graphql` so `Request::data` carries `Service`.

### Command return modes (ack / fact / projection)

**Command success does not imply projection visibility.**

| Handler topology | Return payload | Client `result.kind` |
|------------------|----------------|----------------------|
| Events/outbox only; projector separate (**default Seeker / e2e-ui todos**) | ack or domain-fact fields only | `ack` / `fact` + reconcile (subscription/refetch) |
| Handler commits **read model in the same request** | view-shaped JSON matching GraphQL output / RM columns | `projection` — client may apply payload now |

Rules:

1. If you **write** the read model in the command, **return it** (view-shaped). Prefer
`stage_projection_and_payload(&mut plan, &view)` so upsert + JSON cannot drift, or
`projection_return_value(&view)` after you commit the same row.
2. If projection is a **separate** event handler, return **ack/fact only** — do **not**
invent a full list row the RM has not stored.
3. e2e-ui `todos.create` returns fact-shaped `TodoCreatePayload` — treat as **`fact`**,
not projection-from-store.

Surface IR: SDL is built via `build_surface` → `graphql_sdl_from_surface` (shared inventory
for dialect-honest comparison ops and role grants). Runtime schema still partially dual
until full IR wire-up.

## SDL artifact (CI gate)

```bash
Expand Down
30 changes: 30 additions & 0 deletions src/graphql/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,36 @@ impl GraphqlEngine {
self.inner.schemas.get(role).map(|s| s.sdl())
}

/// Dep-free **surface IR** role SDL (A11 production path).
///
/// Preferred for codegen / `export_sdl`. Uses the same catalog grants as the
/// engine build (A12 mapper). Runtime dump is still available via [`Self::sdl_for_role`].
pub fn ir_sdl_for_role(&self, role: &str) -> Result<String, String> {
use super::permissions::role_grants_from_model_role_perms;
use super::sdl::{graphql_sdl_for_role, SdlOptions};
use super::compile::SqlDialect;

let tables: Vec<_> = self
.inner
.catalog
.values()
.filter(|e| e.exposed)
.map(|e| e.schema.clone())
.collect();
let opts = match self.inner.dialect {
SqlDialect::Sqlite => SdlOptions::sqlite(),
SqlDialect::Postgres => SdlOptions::postgres(),
};
let grants = role_grants_from_model_role_perms(
role,
self.inner
.permissions
.iter()
.map(|(k, v)| (k, &v.permission)),
);
graphql_sdl_for_role(&tables, &opts, role, &grants)
}

pub fn graphiql_enabled(&self) -> bool {
self.inner.graphiql
}
Expand Down
19 changes: 17 additions & 2 deletions src/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,26 @@
//! `feature = "graphql"`.

pub mod naming;
pub mod projection_return;
pub mod sdl;
pub mod surface;

pub use naming::{
aggregate_field, by_pk_field, comparison_op_fields, include_postgres_json_comparison_ops,
is_valid_graphql_name, object_type_name, root_list_field, scalar_type_name,
POSTGRES_JSON_COMPARISON_OPS, PORTABLE_COMPARISON_OPS, STRING_COMPARISON_OPS,
};
pub use sdl::{graphql_sdl_for_tables, graphql_sdl_for_tables_with_options, SdlOptions};
pub use sdl::{
graphql_sdl_for_role, graphql_sdl_for_tables, graphql_sdl_for_tables_with_options,
graphql_sdl_from_surface, SdlOptions,
};
pub use projection_return::{
projection_return_value, stage_projection_and_payload, ProjectionPayload,
};
pub use surface::{
build_surface, role_grants_for_role, surface_for_role, RoleGrant, RootField, RootKind, Surface,
SurfaceDialect, SurfaceModel, SurfaceOptions,
};

#[cfg(feature = "graphql")]
mod commands;
Expand Down Expand Up @@ -63,7 +75,10 @@ pub use identity::{
UNSET_OIDC_AUDIENCE, UNSET_OIDC_ISSUER,
};
#[cfg(feature = "graphql")]
pub use permissions::{read, ModelPermissions, ReadPermission};
pub use permissions::{
read, role_grant_from_read_permission, role_grants_from_model_role_perms, ModelPermissions,
ReadPermission,
};
#[cfg(feature = "graphql")]
pub use subscribe::ChangeHub;
#[cfg(feature = "graphql")]
Expand Down
78 changes: 77 additions & 1 deletion src/graphql/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
//! Prefer this vocabulary over “filter” / bare “allow”: grants are roles,
//! columns are field allowlists, rows are row scope.

use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::marker::PhantomData;

use super::filter::FilterExpr;
use super::surface::RoleGrant;

/// Per-role read access for one model.
#[derive(Clone, Debug)]
Expand Down Expand Up @@ -106,6 +107,39 @@ impl ReadPermission {
self.columns.clone().unwrap_or_default()
}
}

/// Map to feature-free surface IR grant (A12). Row filters / limits stay on
/// [`ReadPermission`] for execute; IR only needs column + aggregation surface.
pub fn to_role_grant(&self) -> RoleGrant {
role_grant_from_read_permission(self)
}
}

/// Pure A12 mapper: engine [`ReadPermission`] → surface [`RoleGrant`].
pub fn role_grant_from_read_permission(perm: &ReadPermission) -> RoleGrant {
let mut g = if perm.all_columns {
RoleGrant::all_columns()
} else {
RoleGrant::columns(perm.columns.clone().unwrap_or_default())
};
if perm.aggregations {
g = g.with_aggregations();
}
g
}

/// Collect IR grants for one role from `(model_name, role) → ReadPermission` pairs.
pub fn role_grants_from_model_role_perms<'a>(
role: &str,
entries: impl IntoIterator<Item = (&'a (String, String), &'a ReadPermission)>,
) -> BTreeMap<String, RoleGrant> {
let mut out = BTreeMap::new();
for ((model, r), perm) in entries {
if r == role {
out.insert(model.clone(), role_grant_from_read_permission(perm));
}
}
out
}

/// Typed bag of `(role, ReadPermission)` pairs for one model.
Expand Down Expand Up @@ -134,3 +168,45 @@ impl<M> ModelPermissions<M> {
self
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a12_all_columns_maps() {
let g = role_grant_from_read_permission(&read().all_columns());
assert!(g.all_columns);
assert!(g.columns.is_empty());
assert!(!g.aggregations);
}

#[test]
fn a12_column_list_and_aggregations() {
let g = role_grant_from_read_permission(
&read().columns(["order_id", "status"]).aggregations(),
);
assert!(!g.all_columns);
assert!(g.columns.contains("order_id"));
assert!(g.columns.contains("status"));
assert!(!g.columns.contains("meta"));
assert!(g.aggregations);
assert!(g.allows_column("order_id"));
assert!(!g.allows_column("meta"));
}

#[test]
fn a12_role_grants_from_model_role_perms_filters_role() {
let user = read().columns(["a"]);
let admin = read().all_columns().aggregations();
let key_u = ("M".to_string(), "user".to_string());
let key_a = ("M".to_string(), "admin".to_string());
let map = role_grants_from_model_role_perms(
"user",
[(&key_u, &user), (&key_a, &admin)],
);
assert_eq!(map.len(), 1);
assert!(map["M"].columns.contains("a"));
assert!(!map["M"].all_columns);
}
}
92 changes: 92 additions & 0 deletions src/graphql/projection_return.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Helpers for **same-tx projection** command returns (**library / optional topology**).
//!
//! # Product rule
//!
//! | Topology | Return | Client `result.kind` |
//! |----------|--------|----------------------|
//! | Async projector (default Seeker / e2e-ui todos) | ack or domain-fact fields | `ack` / `fact` + reconcile |
//! | Handler commits read model in the same request | view-shaped JSON | `projection` |
//!
//! If you write the read model in the command handler, **return it**.
//! If you do not, do **not** invent a full list row — return ack/fact only.
//!
//! # When to use these helpers
//!
//! - **Use** [`stage_projection_and_payload`] / [`projection_return_value`] only when the
//! command handler **intentionally** commits the read model in the same request
//! (e.g. game / same-tx demos) and the client policy is `result.kind = projection`.
//! - **Do not use** for default async projector commands (e2e-ui todos, chat posts).
//! Those return fact/ack and reconcile via subscription or delayed refetch.
//!
//! e2e-ui todos create returns fact-shaped fields from the domain fact — treat as
//! **`fact`**, not projection-from-store (projectors are separate event handlers).

use serde::Serialize;

use crate::read_model::{ReadModelWritePlanBuilder, RelationalReadModel};
use crate::table::TableStoreError;

/// JSON value returned from a GraphQL command mutation when the payload is
/// view-shaped (same fields the read model / GraphQL object exposes).
pub type ProjectionPayload = serde_json::Value;

/// Serialize a row as the GraphQL mutation payload after (or as part of)
/// committing the same row to the read model in this request.
///
/// Prefer [`stage_projection_and_payload`] so the upsert and the returned JSON
/// cannot drift.
pub fn projection_return_value<V: Serialize>(row: &V) -> Result<ProjectionPayload, String> {
serde_json::to_value(row).map_err(|e| e.to_string())
}

/// Stage a full-row upsert on `plan` and return the same value as GraphQL JSON.
///
/// Call only when the handler **intentionally** commits the projection in the
/// same request. Do **not** use for default async-projector commands (todos).
///
/// ```ignore
/// let mut plan = ReadModelWritePlanBuilder::new();
/// let payload = stage_projection_and_payload(&mut plan, &view)?;
/// // commit plan with the aggregate/outbox in the same unit of work
/// Ok(payload)
/// ```
pub fn stage_projection_and_payload<M>(
plan: &mut ReadModelWritePlanBuilder,
row: &M,
) -> Result<ProjectionPayload, TableStoreError>
where
M: RelationalReadModel + Serialize,
{
plan.upsert(row)?;
serde_json::to_value(row).map_err(|e| {
TableStoreError::Metadata(format!("projection payload serialize failed: {e}"))
})
}

#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;

#[derive(Serialize)]
struct ViewRow {
todo_id: String,
title: String,
status: String,
}

#[test]
fn projection_return_value_is_view_shaped_json() {
let row = ViewRow {
todo_id: "t1".into(),
title: "hi".into(),
status: "open".into(),
};
let v = projection_return_value(&row).expect("json");
assert_eq!(v["todo_id"], "t1");
assert_eq!(v["title"], "hi");
assert_eq!(v["status"], "open");
// Only fields present on the row — no invented admin/secret keys
assert!(v.get("secret").is_none());
}
}
Loading
Loading