From 795b0fa1db3f196cf10cf9540caee97a3d3b3f2f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 16 Jul 2026 16:26:07 -0500 Subject: [PATCH 01/20] feat(graphql): surface IR, command pipeline cache, projection return helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement pre-release tracks for graphql-qs-prerelease-1: - Surface IR (build_surface / surface_for_role); SDL emits via IR - Browser QueryCache + command pipeline (ack/fact/projection, rollback) - projection_return helpers + skill docs for same-tx vs async projectors Product rule: command success ≠ projection visibility; default ack/fact + reconcile. Residuals (full schema-from-IR, e2e-ui route migrate, extract packages, client_reconcile manifest) remain in GitKB v1/state. --- .../skills/distributed-graphql/SKILL.md | 23 + src/graphql/mod.rs | 14 +- src/graphql/projection_return.rs | 84 ++ src/graphql/sdl.rs | 36 + src/graphql/surface.rs | 721 ++++++++++++++++++ tests/e2e-ui/ui/src/lib/gql/cache/index.ts | 15 + tests/e2e-ui/ui/src/lib/gql/cache/ops.ts | 230 ++++++ tests/e2e-ui/ui/src/lib/gql/cache/pipeline.ts | 197 +++++ .../ui/src/lib/gql/cache/query-cache.ts | 99 +++ tests/e2e-ui/ui/src/lib/gql/index.ts | 19 + .../e2e-ui/ui/tests/command-pipeline.test.mjs | 44 ++ tests/e2e-ui/ui/tests/run-pipeline-unit.mjs | 218 ++++++ 12 files changed, 1699 insertions(+), 1 deletion(-) create mode 100644 src/graphql/projection_return.rs create mode 100644 src/graphql/surface.rs create mode 100644 tests/e2e-ui/ui/src/lib/gql/cache/index.ts create mode 100644 tests/e2e-ui/ui/src/lib/gql/cache/ops.ts create mode 100644 tests/e2e-ui/ui/src/lib/gql/cache/pipeline.ts create mode 100644 tests/e2e-ui/ui/src/lib/gql/cache/query-cache.ts create mode 100644 tests/e2e-ui/ui/tests/command-pipeline.test.mjs create mode 100644 tests/e2e-ui/ui/tests/run-pipeline-unit.mjs diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index 45a7c157..57261cce 100644 --- a/distributed_cli/skills/distributed-graphql/SKILL.md +++ b/distributed_cli/skills/distributed-graphql/SKILL.md @@ -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 diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 9ca20436..750b8185 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -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_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, surface_for_role, RoleGrant, RootField, RootKind, Surface, SurfaceDialect, + SurfaceModel, SurfaceOptions, +}; #[cfg(feature = "graphql")] mod commands; diff --git a/src/graphql/projection_return.rs b/src/graphql/projection_return.rs new file mode 100644 index 00000000..94005e0e --- /dev/null +++ b/src/graphql/projection_return.rs @@ -0,0 +1,84 @@ +//! Helpers for **same-tx projection** command returns. +//! +//! # 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. +//! +//! 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(row: &V) -> Result { + 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( + plan: &mut ReadModelWritePlanBuilder, + row: &M, +) -> Result +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()); + } +} diff --git a/src/graphql/sdl.rs b/src/graphql/sdl.rs index a5dce89b..97d108f5 100644 --- a/src/graphql/sdl.rs +++ b/src/graphql/sdl.rs @@ -60,6 +60,9 @@ impl SdlOptions { } /// Render GraphQL SDL for the given tables (ReadModel only; operational filtered). +/// +/// Builds the shared [[surface]] IR first, then emits SDL only from that IR so +/// dialect ops and model set cannot diverge from `build_surface`. pub fn graphql_sdl_for_tables(tables: &[TableSchema]) -> Result { graphql_sdl_for_tables_with_options(tables, &SdlOptions::default()) } @@ -67,6 +70,39 @@ pub fn graphql_sdl_for_tables(tables: &[TableSchema]) -> Result pub fn graphql_sdl_for_tables_with_options( tables: &[TableSchema], options: &SdlOptions, +) -> Result { + let surface_opts = super::surface::SurfaceOptions { + dialect: if options.jsonb_operators { + super::surface::SurfaceDialect::Postgres + } else { + super::surface::SurfaceDialect::Sqlite + }, + aggregates: options.aggregates, + subscriptions: options.subscriptions, + }; + let surface = super::surface::build_surface(tables, &surface_opts)?; + graphql_sdl_from_surface(&surface) +} + +/// Emit GraphQL SDL from a pre-built surface IR (role-filtered or full catalog). +pub fn graphql_sdl_from_surface(surface: &super::surface::Surface) -> Result { + let tables = surface.schemas(); + let options = surface_options_to_sdl(surface); + graphql_sdl_from_read_models(&tables, &options) +} + +fn surface_options_to_sdl(surface: &super::surface::Surface) -> SdlOptions { + SdlOptions { + aggregates: surface.aggregates, + jsonb_operators: include_postgres_json_comparison_ops(surface.dialect.is_postgres()), + subscriptions: surface.subscriptions, + } +} + +/// Internal renderer over an already IR-filtered set of read models. +fn graphql_sdl_from_read_models( + tables: &[TableSchema], + options: &SdlOptions, ) -> Result { let read_models: Vec<&TableSchema> = tables .iter() diff --git a/src/graphql/surface.rs b/src/graphql/surface.rs new file mode 100644 index 00000000..617f2fca --- /dev/null +++ b/src/graphql/surface.rs @@ -0,0 +1,721 @@ +//! Shared GraphQL **surface IR** — single source of truth for the query/subscription +//! type system a catalog (and optionally a role) can see. +//! +//! SDL emission and (over time) runtime schema construction consume this IR so +//! dialect-honest comparison ops, roots, and column grants cannot diverge. +//! +//! Core types compile without the `graphql` feature so `dctl schema --format graphql` +//! can share the same IR path. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::table::{ + resolve_m2m_target_foreign_key, RelationshipKind, TableColumn, TableSchema, +}; + +use super::naming::{ + by_pk_field, comparison_exp_name, comparison_op_fields, include_postgres_json_comparison_ops, + is_valid_graphql_name, object_type_name, root_list_field, scalar_type_name, CUSTOM_SCALARS, +}; + +/// Dialect gate for comparison operators (JSON ops only on Postgres). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SurfaceDialect { + Sqlite, + Postgres, +} + +impl SurfaceDialect { + pub fn is_postgres(self) -> bool { + matches!(self, Self::Postgres) + } +} + +/// Options for building a surface from a table catalog. +#[derive(Clone, Debug)] +pub struct SurfaceOptions { + pub dialect: SurfaceDialect, + pub aggregates: bool, + pub subscriptions: bool, +} + +impl SurfaceOptions { + pub fn sqlite() -> Self { + Self { + dialect: SurfaceDialect::Sqlite, + aggregates: true, + subscriptions: true, + } + } + + pub fn postgres() -> Self { + Self { + dialect: SurfaceDialect::Postgres, + aggregates: true, + subscriptions: true, + } + } +} + +/// Kind of a GraphQL root field on Query / Subscription. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RootKind { + List, + ByPk, + Aggregate, +} + +/// One Query or Subscription root field inventory entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RootField { + pub name: String, + pub kind: RootKind, + /// GraphQL object type name (`model_name`). + pub object: String, + /// Model name in the catalog (`schema.model_name`). + pub model_name: String, +} + +/// Column field on an object type (after skips / role filter). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColumnField { + pub name: String, + pub scalar: String, + pub nullable: bool, +} + +/// Relationship field inventory (target must be on the surface). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RelField { + pub name: String, + pub target_model: String, + pub target_object: String, + pub kind: RelationshipKind, + pub list: bool, +} + +/// One exposed read-model on the surface. +#[derive(Clone, Debug)] +pub struct SurfaceModel { + pub model_name: String, + pub table_name: String, + pub object_name: String, + pub columns: Vec, + pub relationships: Vec, + pub primary_key: Vec, + /// Filtered schema clone (columns limited for role surfaces). + pub(crate) schema: TableSchema, +} + +/// Intermediate surface IR. +#[derive(Clone, Debug)] +pub struct Surface { + pub dialect: SurfaceDialect, + pub aggregates: bool, + pub subscriptions: bool, + /// Keyed by `model_name`. + pub models: BTreeMap, + pub query_fields: Vec, + pub subscription_fields: Vec, + /// GraphQL comparison input name → operator field names (from `naming` only). + pub comparison_ops: BTreeMap>, +} + +impl Surface { + /// Read-model schemas in stable model_name order (for SDL / adapters). + pub fn schemas(&self) -> Vec { + self.models + .values() + .map(|m| m.schema.clone()) + .collect() + } + + /// Inventory of query root field names (sorted). + pub fn query_root_names(&self) -> Vec<&str> { + let mut names: Vec<&str> = self.query_fields.iter().map(|f| f.name.as_str()).collect(); + names.sort(); + names + } + + /// Comparison operator fields for a scalar, empty if scalar unused. + pub fn comparison_ops_for_scalar(&self, scalar: &str) -> Vec<&str> { + let name = comparison_exp_name(scalar); + self.comparison_ops + .get(&name) + .map(|ops| ops.iter().map(String::as_str).collect()) + .unwrap_or_default() + } +} + +/// Build the full (unscoped) surface from a table catalog. +pub fn build_surface( + tables: &[TableSchema], + options: &SurfaceOptions, +) -> Result { + let read_models: Vec<&TableSchema> = tables + .iter() + .filter(|t| t.kind.is_read_model()) + .collect(); + + for schema in &read_models { + schema + .validate() + .map_err(|e| format!("schema `{}` invalid: {e}", schema.model_name))?; + } + + let by_model: BTreeMap<&str, &TableSchema> = read_models + .iter() + .map(|t| (t.model_name.as_str(), *t)) + .collect(); + let by_table: BTreeMap<&str, &TableSchema> = read_models + .iter() + .map(|t| (t.table_name.as_str(), *t)) + .collect(); + + let postgres_json = include_postgres_json_comparison_ops(options.dialect.is_postgres()); + let mut used_scalars: BTreeSet = BTreeSet::new(); + let mut models: BTreeMap = BTreeMap::new(); + + for schema in &read_models { + let object_name = object_type_name(schema).to_string(); + if !is_valid_graphql_name(&object_name) { + return Err(format!("object type `{object_name}` is not a valid GraphQL name")); + } + if !is_valid_graphql_name(root_list_field(schema)) { + return Err(format!( + "root field `{}` is not a valid GraphQL name", + root_list_field(schema) + )); + } + + let mut columns = Vec::new(); + for col in visible_columns(schema) { + if !is_valid_graphql_name(&col.column_name) { + return Err(format!( + "model `{}` column `{}` is not a valid GraphQL name", + schema.model_name, col.column_name + )); + } + let Some(scalar) = scalar_type_name(&col.column_type) else { + return Err(format!( + "model `{}` column `{}` has unsupported type", + schema.model_name, col.column_name + )); + }; + used_scalars.insert(scalar.to_string()); + columns.push(ColumnField { + name: col.column_name.clone(), + scalar: scalar.to_string(), + nullable: col.nullable, + }); + } + + let mut relationships = Vec::new(); + for rel in &schema.relationships { + if !is_valid_graphql_name(&rel.field_name) { + return Err(format!( + "model `{}` relationship `{}` is not a valid GraphQL name", + schema.model_name, rel.field_name + )); + } + if !relationship_emitted(schema, rel, &by_model, &by_table) { + continue; + } + let target = by_model + .get(rel.target_model.as_str()) + .expect("relationship_emitted"); + let list = matches!( + rel.kind, + RelationshipKind::HasMany | RelationshipKind::ManyToMany + ); + relationships.push(RelField { + name: rel.field_name.clone(), + target_model: rel.target_model.clone(), + target_object: object_type_name(target).to_string(), + kind: rel.kind.clone(), + list, + }); + } + + models.insert( + schema.model_name.clone(), + SurfaceModel { + model_name: schema.model_name.clone(), + table_name: schema.table_name.clone(), + object_name, + columns, + relationships, + primary_key: schema.primary_key.columns.clone(), + schema: (*schema).clone(), + }, + ); + } + + // Drop relationships whose target was not included (defensive). + let model_keys: BTreeSet = models.keys().cloned().collect(); + for model in models.values_mut() { + model + .relationships + .retain(|r| model_keys.contains(&r.target_model)); + } + + let mut comparison_ops = BTreeMap::new(); + for scalar in &used_scalars { + let ops = comparison_op_fields(scalar, postgres_json); + comparison_ops.insert( + comparison_exp_name(scalar), + ops.into_iter().map(str::to_string).collect(), + ); + } + // Always reserve custom scalar names for naming collisions checks downstream. + let _ = CUSTOM_SCALARS; + + let (query_fields, subscription_fields) = + root_fields_for_models(&models, options.aggregates, options.subscriptions); + + Ok(Surface { + dialect: options.dialect, + aggregates: options.aggregates, + subscriptions: options.subscriptions, + models, + query_fields, + subscription_fields, + comparison_ops, + }) +} + +/// Role grant used by [`surface_for_role`] (feature-free; maps from `ReadPermission` +/// when the `graphql` feature is enabled). +#[derive(Clone, Debug)] +pub struct RoleGrant { + pub all_columns: bool, + pub columns: BTreeSet, + pub aggregations: bool, +} + +impl RoleGrant { + pub fn all_columns() -> Self { + Self { + all_columns: true, + columns: BTreeSet::new(), + aggregations: false, + } + } + + pub fn columns>>(cols: I) -> Self { + Self { + all_columns: false, + columns: cols.into_iter().map(Into::into).collect(), + aggregations: false, + } + } + + pub fn with_aggregations(mut self) -> Self { + self.aggregations = true; + self + } + + pub fn allows_column(&self, name: &str) -> bool { + self.all_columns || self.columns.contains(name) + } +} + +/// Apply role grants: drop ungranted models and columns (and relationships to +/// dropped models). Aggregate roots omitted when `aggregations` is false. +/// +/// `grants`: map of model_name → grant for this role. Missing model = not granted. +pub fn surface_for_role( + surface: &Surface, + role: &str, + grants: &BTreeMap, +) -> Surface { + let _ = role; + let mut models: BTreeMap = BTreeMap::new(); + + for (model_name, model) in &surface.models { + let Some(grant) = grants.get(model_name) else { + continue; + }; + + let allowed_cols: BTreeSet = model + .columns + .iter() + .filter(|c| grant.allows_column(&c.name)) + .map(|c| c.name.clone()) + .collect(); + + let columns: Vec = model + .columns + .iter() + .filter(|c| allowed_cols.contains(&c.name)) + .cloned() + .collect(); + + let mut schema = model.schema.clone(); + for col in &mut schema.columns { + if !col.skipped && !allowed_cols.contains(&col.column_name) { + col.skipped = true; + } + } + + models.insert( + model_name.clone(), + SurfaceModel { + model_name: model.model_name.clone(), + table_name: model.table_name.clone(), + object_name: model.object_name.clone(), + columns, + relationships: model.relationships.clone(), + primary_key: model.primary_key.clone(), + schema, + }, + ); + } + + // Relationships only if target model remains granted (collect keys first). + let model_keys: BTreeSet = models.keys().cloned().collect(); + for model in models.values_mut() { + model + .relationships + .retain(|r| model_keys.contains(&r.target_model)); + let rel_names: BTreeSet = model.relationships.iter().map(|r| r.name.clone()).collect(); + model.schema.relationships.retain(|r| { + model_keys.contains(&r.target_model) && rel_names.contains(&r.field_name) + }); + } + + let mut query_fields = Vec::new(); + let mut subscription_fields = Vec::new(); + for model in models.values() { + let grant = grants.get(&model.model_name); + let allow_agg = surface.aggregates && grant.is_some_and(|g| g.aggregations); + let list = root_list_field(&model.schema).to_string(); + let by_pk = by_pk_field(&model.schema); + query_fields.push(RootField { + name: list.clone(), + kind: RootKind::List, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + query_fields.push(RootField { + name: by_pk.clone(), + kind: RootKind::ByPk, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + if allow_agg { + query_fields.push(RootField { + name: format!("{}_aggregate", model.table_name), + kind: RootKind::Aggregate, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + } + if surface.subscriptions { + subscription_fields.push(RootField { + name: list, + kind: RootKind::List, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + subscription_fields.push(RootField { + name: by_pk, + kind: RootKind::ByPk, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + } + } + query_fields.sort_by(|a, b| a.name.cmp(&b.name)); + subscription_fields.sort_by(|a, b| a.name.cmp(&b.name)); + + let postgres_json = include_postgres_json_comparison_ops(surface.dialect.is_postgres()); + let mut used_scalars: BTreeSet = BTreeSet::new(); + for m in models.values() { + for c in &m.columns { + used_scalars.insert(c.scalar.clone()); + } + } + let mut comparison_ops = BTreeMap::new(); + for scalar in &used_scalars { + let ops = comparison_op_fields(scalar, postgres_json); + comparison_ops.insert( + comparison_exp_name(scalar), + ops.into_iter().map(str::to_string).collect(), + ); + } + + let aggregates = query_fields.iter().any(|f| f.kind == RootKind::Aggregate); + + Surface { + dialect: surface.dialect, + aggregates, + subscriptions: surface.subscriptions, + models, + query_fields, + subscription_fields, + comparison_ops, + } +} + +fn root_fields_for_models( + models: &BTreeMap, + aggregates: bool, + subscriptions: bool, +) -> (Vec, Vec) { + let mut query_fields = Vec::new(); + let mut subscription_fields = Vec::new(); + for model in models.values() { + let list = root_list_field(&model.schema).to_string(); + let by_pk = by_pk_field(&model.schema); + query_fields.push(RootField { + name: list.clone(), + kind: RootKind::List, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + query_fields.push(RootField { + name: by_pk.clone(), + kind: RootKind::ByPk, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + if aggregates { + query_fields.push(RootField { + name: format!("{}_aggregate", model.table_name), + kind: RootKind::Aggregate, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + } + if subscriptions { + subscription_fields.push(RootField { + name: list, + kind: RootKind::List, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + subscription_fields.push(RootField { + name: by_pk, + kind: RootKind::ByPk, + object: model.object_name.clone(), + model_name: model.model_name.clone(), + }); + } + } + query_fields.sort_by(|a, b| a.name.cmp(&b.name)); + subscription_fields.sort_by(|a, b| a.name.cmp(&b.name)); + (query_fields, subscription_fields) +} + +fn visible_columns(schema: &TableSchema) -> impl Iterator { + schema.columns.iter().filter(|c| !c.skipped) +} + +fn relationship_emitted( + schema: &TableSchema, + rel: &crate::table::RelationshipDef, + by_model: &BTreeMap<&str, &TableSchema>, + by_table: &BTreeMap<&str, &TableSchema>, +) -> bool { + let Some(target) = by_model.get(rel.target_model.as_str()) else { + return false; + }; + match rel.kind { + RelationshipKind::HasMany | RelationshipKind::BelongsTo => true, + RelationshipKind::ManyToMany => { + let Some(through_name) = rel.through.as_deref() else { + return false; + }; + if let Some(through) = by_table.get(through_name) { + resolve_m2m_target_foreign_key(schema, rel, through, target).is_ok() + } else { + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::table::{ColumnType, PrimaryKey, RelationshipDef, TableColumn, TableKind}; + + fn orders() -> TableSchema { + TableSchema { + model_name: "OrderView".into(), + table_name: "orders".into(), + columns: vec![ + TableColumn { + primary_key: true, + ..TableColumn::new("order_id", "order_id", ColumnType::Text) + }, + TableColumn::new("customer_id", "customer_id", ColumnType::Text), + TableColumn::new("status", "status", ColumnType::Text), + TableColumn { + jsonb: true, + ..TableColumn::new("meta", "meta", ColumnType::Json) + }, + ], + primary_key: PrimaryKey::new(["order_id"]), + version_column: Some("_sourced_version".into()), + foreign_keys: Vec::new(), + indexes: Vec::new(), + relationships: Vec::new(), + kind: TableKind::ReadModel, + } + } + + fn operational() -> TableSchema { + TableSchema { + model_name: "Outbox".into(), + table_name: "outbox".into(), + columns: vec![TableColumn { + primary_key: true, + ..TableColumn::new("id", "id", ColumnType::Text) + }], + primary_key: PrimaryKey::new(["id"]), + version_column: None, + foreign_keys: Vec::new(), + indexes: Vec::new(), + relationships: Vec::new(), + kind: TableKind::Operational, + } + } + + #[test] + fn build_surface_skips_operational_and_lists_roots() { + let surface = build_surface( + &[orders(), operational()], + &SurfaceOptions::sqlite(), + ) + .expect("surface"); + assert!(surface.models.contains_key("OrderView")); + assert!(!surface.models.contains_key("Outbox")); + let roots = surface.query_root_names(); + assert!(roots.contains(&"orders")); + assert!(roots.contains(&"orders_by_pk")); + assert!(roots.contains(&"orders_aggregate")); + } + + #[test] + fn sqlite_surface_omits_pg_json_comparison_ops() { + let surface = build_surface(&[orders()], &SurfaceOptions::sqlite()).unwrap(); + let ops = surface.comparison_ops_for_scalar("JSON"); + assert!(ops.contains(&"_eq")); + for forbidden in ["_contains", "_contained_in", "_has_key"] { + assert!( + !ops.contains(&forbidden), + "SQLite must not expose {forbidden}" + ); + } + } + + #[test] + fn postgres_surface_includes_pg_json_comparison_ops() { + let surface = build_surface(&[orders()], &SurfaceOptions::postgres()).unwrap(); + let ops = surface.comparison_ops_for_scalar("JSON"); + for required in ["_contains", "_contained_in", "_has_key"] { + assert!(ops.contains(&required), "Postgres missing {required}"); + } + } + + #[test] + fn surface_for_role_drops_ungranted_columns_and_models() { + let full = build_surface(&[orders()], &SurfaceOptions::sqlite()).unwrap(); + let mut grants = BTreeMap::new(); + grants.insert( + "OrderView".to_string(), + RoleGrant::columns(["order_id", "status"]), + ); + let role_surface = surface_for_role(&full, "user", &grants); + let model = role_surface.models.get("OrderView").expect("granted"); + let col_names: Vec<_> = model.columns.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(col_names, vec!["order_id", "status"]); + assert!(!col_names.contains(&"customer_id")); + assert!(!col_names.contains(&"meta")); + + let empty = surface_for_role(&full, "anon", &BTreeMap::new()); + assert!(empty.models.is_empty()); + assert!(empty.query_fields.is_empty()); + } + + #[test] + fn surface_for_role_omits_aggregate_without_grant() { + let full = build_surface(&[orders()], &SurfaceOptions::sqlite()).unwrap(); + let mut grants = BTreeMap::new(); + grants.insert("OrderView".to_string(), RoleGrant::all_columns()); + let role_surface = surface_for_role(&full, "user", &grants); + let names = role_surface.query_root_names(); + assert!(names.contains(&"orders")); + assert!(!names.contains(&"orders_aggregate")); + + let mut admin = BTreeMap::new(); + admin.insert( + "OrderView".to_string(), + RoleGrant::all_columns().with_aggregations(), + ); + let admin_surface = surface_for_role(&full, "admin", &admin); + assert!(admin_surface + .query_root_names() + .contains(&"orders_aggregate")); + } + + #[test] + fn relationship_only_when_target_on_surface() { + let parent = TableSchema { + model_name: "ParentView".into(), + table_name: "parents".into(), + columns: vec![TableColumn { + primary_key: true, + ..TableColumn::new("id", "id", ColumnType::Text) + }], + primary_key: PrimaryKey::new(["id"]), + version_column: None, + foreign_keys: Vec::new(), + indexes: Vec::new(), + relationships: vec![RelationshipDef { + field_name: "children".into(), + kind: RelationshipKind::HasMany, + target_model: "ChildView".into(), + foreign_key: Some("parent_id".into()), + through: None, + target_foreign_key: None, + }], + kind: TableKind::ReadModel, + }; + let child = TableSchema { + model_name: "ChildView".into(), + table_name: "children".into(), + columns: vec![ + TableColumn { + primary_key: true, + ..TableColumn::new("id", "id", ColumnType::Text) + }, + TableColumn::new("parent_id", "parent_id", ColumnType::Text), + ], + primary_key: PrimaryKey::new(["id"]), + version_column: None, + foreign_keys: Vec::new(), + indexes: Vec::new(), + relationships: Vec::new(), + kind: TableKind::ReadModel, + }; + let both = build_surface(&[parent.clone(), child], &SurfaceOptions::sqlite()).unwrap(); + assert!(both + .models + .get("ParentView") + .unwrap() + .relationships + .iter() + .any(|r| r.name == "children")); + + let parent_only = build_surface(&[parent], &SurfaceOptions::sqlite()).unwrap(); + assert!(parent_only + .models + .get("ParentView") + .unwrap() + .relationships + .is_empty()); + } +} diff --git a/tests/e2e-ui/ui/src/lib/gql/cache/index.ts b/tests/e2e-ui/ui/src/lib/gql/cache/index.ts new file mode 100644 index 00000000..67ab4455 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/cache/index.ts @@ -0,0 +1,15 @@ +export { QueryCache, cacheKey, type CacheEntry, type CacheKey } from './query-cache.ts'; +export { + applyCacheOp, + applyCacheOps, + applyProjectionPayload, + effect, + rollback, + type CacheOp, + type CacheTarget, + type CommandPolicy, + type Effect, + type ResultKind, + type ReconcileKind +} from './ops.ts'; +export { runCommandPipeline, type CommandPipelineOptions, type PipelineDeps } from './pipeline.ts'; diff --git a/tests/e2e-ui/ui/src/lib/gql/cache/ops.ts b/tests/e2e-ui/ui/src/lib/gql/cache/ops.ts new file mode 100644 index 00000000..03378471 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/cache/ops.ts @@ -0,0 +1,230 @@ +/** + * Closed sets: CacheOp (client cache only) vs Effect (UI side effects). + */ + +import type { QueryCache } from './query-cache.ts'; +import { cacheKey } from './query-cache.ts'; + +export type CacheTarget = { + /** Query/subscription document string. */ + document: string; + variables?: Record; + /** Dot path to list/object on data, e.g. "todos". Empty = whole document. */ + at?: string; + /** Primary key field for list upsert/remove. */ + by?: string; +}; + +export type CacheOp = + | { + op: 'upsert'; + target: CacheTarget; + row: Record; + } + | { + op: 'patch'; + target: CacheTarget; + row: Record; + } + | { + op: 'remove'; + target: CacheTarget; + id: unknown; + } + | { + op: 'write'; + target: CacheTarget; + data: unknown; + } + | { + op: 'invalidate'; + prefix?: string; + }; + +export type Effect = + | { kind: 'toast'; message: string } + | { kind: 'alert'; message: string } + | { kind: 'goto'; path: string }; + +export const effect = { + toast: (message: string): Effect => ({ kind: 'toast', message }), + alert: (message: string): Effect => ({ kind: 'alert', message }), + goto: (path: string): Effect => ({ kind: 'goto', path }) +}; + +export type ResultKind = 'ack' | 'fact' | 'projection' | 'none'; +export type ReconcileKind = 'subscription' | 'refetch' | 'invalidate' | 'none'; + +export type CommandPolicy = { + result?: { kind: ResultKind; apply?: { targets: CacheTarget[] } }; + reconcile?: { kind: ReconcileKind; document?: string; variables?: Record }; + optimistic?: { + targets: CacheTarget[]; + row?: Record; + }; +}; + +export type Snapshot = { + key: string; + entry: ReturnType | undefined; + had: boolean; +}; + +function getAt(data: unknown, path?: string): unknown { + if (!path) return data; + let cur: unknown = data; + for (const part of path.split('.').filter(Boolean)) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[part]; + } + return cur; +} + +function setAt(data: unknown, path: string | undefined, value: unknown): unknown { + if (!path) return value; + const parts = path.split('.').filter(Boolean); + const root = + data !== null && typeof data === 'object' ? { ...(data as Record) } : {}; + let cur: Record = root; + for (let i = 0; i < parts.length - 1; i++) { + const p = parts[i]!; + const next = cur[p]; + cur[p] = + next !== null && typeof next === 'object' && !Array.isArray(next) + ? { ...(next as Record) } + : {}; + cur = cur[p] as Record; + } + cur[parts[parts.length - 1]!] = value; + return root; +} + +function cloneEntry(entry: ReturnType): ReturnType { + if (!entry) return undefined; + return { + ...entry, + data: structuredClone(entry.data) + }; +} + +export function applyCacheOp(cache: QueryCache, op: CacheOp, now = Date.now()): Snapshot | null { + if (op.op === 'invalidate') { + cache.invalidate(op.prefix); + return null; + } + + const key = cacheKey(op.target.document, op.target.variables); + const prev = cache.get(key); + const snap: Snapshot = { key, entry: cloneEntry(prev), had: !!prev }; + + if (op.op === 'write') { + cache.set(key, { + data: op.data, + updatedAt: now, + optimistic: false, + pending: false + }); + return snap; + } + + const baseData = prev?.data ?? {}; + const at = op.target.at; + const by = op.target.by ?? 'id'; + + if (op.op === 'upsert' || op.op === 'patch') { + const list = getAt(baseData, at); + if (Array.isArray(list)) { + const id = op.row[by]; + const idx = list.findIndex( + (row) => row && typeof row === 'object' && (row as Record)[by] === id + ); + const next = [...list]; + if (idx >= 0) { + const existing = next[idx] as Record; + next[idx] = op.op === 'patch' ? { ...existing, ...op.row } : { ...existing, ...op.row }; + } else if (op.op === 'upsert') { + next.push({ ...op.row }); + } + const data = setAt(baseData, at, next); + cache.set(key, { + data, + updatedAt: now, + optimistic: true, + pending: prev?.pending + }); + } else if (!prev) { + // No cached list yet — only write full document if `at` empty. + if (!at) { + cache.set(key, { + data: op.row, + updatedAt: now, + optimistic: true + }); + } + // else: do not invent a list document from incomplete row + } else if (!at && op.op === 'patch' && prev.data && typeof prev.data === 'object') { + cache.set(key, { + data: { ...(prev.data as object), ...op.row }, + updatedAt: now, + optimistic: true + }); + } + return snap; + } + + if (op.op === 'remove') { + const list = getAt(baseData, at); + if (Array.isArray(list)) { + const next = list.filter( + (row) => !(row && typeof row === 'object' && (row as Record)[by] === op.id) + ); + cache.set(key, { + data: setAt(baseData, at, next), + updatedAt: now, + optimistic: false + }); + } + return snap; + } + + return snap; +} + +/** Apply ops; return snapshots for rollback (in reverse order). */ +export function applyCacheOps(cache: QueryCache, ops: CacheOp[]): Snapshot[] { + const snaps: Snapshot[] = []; + for (const op of ops) { + const snap = applyCacheOp(cache, op); + if (snap) snaps.push(snap); + } + return snaps; +} + +export function rollback(cache: QueryCache, snaps: Snapshot[]): void { + // Restore in reverse so nested writes unwind correctly. + for (let i = snaps.length - 1; i >= 0; i--) { + const s = snaps[i]!; + if (s.had && s.entry) { + cache.set(s.key, s.entry); + } else { + cache.delete(s.key); + } + } +} + +/** + * Projection apply: merge payload fields only onto cache targets. + * Never invents fields absent from `payload`. + */ +export function applyProjectionPayload( + cache: QueryCache, + targets: CacheTarget[], + payload: Record +): Snapshot[] { + const ops: CacheOp[] = targets.map((target) => ({ + op: 'upsert' as const, + target, + row: { ...payload } + })); + return applyCacheOps(cache, ops); +} diff --git a/tests/e2e-ui/ui/src/lib/gql/cache/pipeline.ts b/tests/e2e-ui/ui/src/lib/gql/cache/pipeline.ts new file mode 100644 index 00000000..ef8a598d --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/cache/pipeline.ts @@ -0,0 +1,197 @@ +/** + * Command result pipeline: + * optimistic → network → result.kind → effects → reconcile + * + * Product rule: command success ≠ projection visibility. + * Default todos path: ack/fact + subscription/refetch — do not invent full RM rows from ack. + */ + +import type { QueryCache } from './query-cache.ts'; +import { cacheKey } from './query-cache.ts'; +import { + applyCacheOps, + applyProjectionPayload, + effect, + rollback, + type CacheOp, + type CacheTarget, + type CommandPolicy, + type Effect, + type ResultKind, + type ReconcileKind +} from './ops.ts'; + +export type GqlError = { message?: string; extensions?: { code?: string } }; + +export type NetworkResult> = { + data?: T | null; + errors?: GqlError[] | null; +}; + +export type CommandPipelineOptions = { + policy?: CommandPolicy; + optimistic?: CommandPolicy['optimistic']; + result?: CommandPolicy['result']; + reconcile?: CommandPolicy['reconcile']; + onSuccess?: (ctx: { + data: unknown; + }) => Array; + onError?: (ctx: { errors: GqlError[] }) => Array; + onSettled?: () => void; + /** When true (SSR), skip optimistic + cache mutations. */ + browser?: boolean; +}; + +export type PipelineDeps = { + cache: QueryCache; + request: (document: string, variables?: Record) => Promise; + /** Optional: run refetch reconcile. */ + refetch?: (document: string, variables?: Record) => Promise; + /** Schedule effects (toast/alert). Never called on the network path itself. */ + runEffects?: (effects: Effect[]) => void; +}; + +function mergePolicy( + defaults: CommandPolicy | undefined, + opts: CommandPipelineOptions +): { + resultKind: ResultKind; + applyTargets?: CacheTarget[]; + reconcileKind: ReconcileKind; + reconcileDoc?: string; + reconcileVars?: Record; + optimistic?: CommandPolicy['optimistic']; +} { + const result = opts.result ?? defaults?.result; + const reconcile = opts.reconcile ?? defaults?.reconcile; + return { + resultKind: result?.kind ?? 'ack', + applyTargets: result?.apply?.targets, + reconcileKind: reconcile?.kind ?? 'none', + reconcileDoc: reconcile?.document, + reconcileVars: reconcile?.variables, + optimistic: opts.optimistic ?? defaults?.optimistic + }; +} + +function splitOps(items: Array): { ops: CacheOp[]; effects: Effect[] } { + const ops: CacheOp[] = []; + const effects: Effect[] = []; + for (const item of items) { + if ('op' in item) ops.push(item); + else effects.push(item); + } + return { ops, effects }; +} + +/** + * Run one generated command mutation through the cache pipeline. + * Exactly one network `request` for the command document. + */ +export async function runCommandPipeline>( + deps: PipelineDeps, + commandDocument: string, + input: Record, + opts: CommandPipelineOptions = {} +): Promise> { + const browser = opts.browser !== false; + const merged = mergePolicy(opts.policy, opts); + let snaps = [] as ReturnType; + + if (browser && merged.optimistic?.targets?.length && merged.optimistic.row) { + const optOps: CacheOp[] = merged.optimistic.targets.map((target) => ({ + op: 'upsert' as const, + target, + row: { ...merged.optimistic!.row! } + })); + snaps = applyCacheOps(deps.cache, optOps); + } + + let result: NetworkResult; + try { + result = (await deps.request(commandDocument, { input })) as NetworkResult; + } catch (e) { + if (browser && snaps.length) rollback(deps.cache, snaps); + const errors = [{ message: e instanceof Error ? e.message : String(e) }]; + const errItems = opts.onError?.({ errors }) ?? []; + const { ops, effects } = splitOps(errItems); + if (browser && ops.length) applyCacheOps(deps.cache, ops); + deps.runEffects?.(effects); + opts.onSettled?.(); + return { data: null, errors }; + } + + if (result.errors?.length) { + if (browser && snaps.length) rollback(deps.cache, snaps); + const errItems = opts.onError?.({ errors: result.errors }) ?? [ + effect.alert(result.errors[0]?.message ?? 'Failed') + ]; + const { ops, effects } = splitOps(errItems); + if (browser && ops.length) applyCacheOps(deps.cache, ops); + deps.runEffects?.(effects); + opts.onSettled?.(); + return result; + } + + // Success path + if (browser) { + if (merged.resultKind === 'projection') { + const data = result.data; + let payload: Record | null = null; + if (data && typeof data === 'object' && !Array.isArray(data)) { + const values = Object.values(data as Record); + const first = values[0]; + if (first && typeof first === 'object' && !Array.isArray(first)) { + payload = first as Record; + } else { + payload = data as Record; + } + } + if (payload && merged.applyTargets?.length) { + // Payload fields only — applyProjectionPayload copies payload keys only. + applyProjectionPayload(deps.cache, merged.applyTargets, payload); + } + } else if (merged.resultKind === 'ack' || merged.resultKind === 'fact') { + // Do NOT invent a full list row from incomplete mutation payload. + // Keep optimistic (if any) and mark pending when we have a cache key. + if (merged.optimistic?.targets?.length) { + for (const t of merged.optimistic.targets) { + const key = cacheKey(t.document, t.variables); + const entry = deps.cache.get(key); + if (entry) { + deps.cache.set(key, { ...entry, pending: true, optimistic: true }); + } + } + } + } + // resultKind === 'none' → no cache apply from response + } + + const successItems = opts.onSuccess?.({ data: result.data }) ?? []; + const { ops, effects } = splitOps(successItems); + if (browser && ops.length) applyCacheOps(deps.cache, ops); + deps.runEffects?.(effects); + + // Reconcile (subscription is usually already running; refetch optional) + if (browser && merged.reconcileKind === 'refetch' && merged.reconcileDoc && deps.refetch) { + const refetched = await deps.refetch(merged.reconcileDoc, merged.reconcileVars); + if (refetched.data && !refetched.errors?.length) { + const key = cacheKey(merged.reconcileDoc, merged.reconcileVars); + deps.cache.set(key, { + data: refetched.data, + updatedAt: Date.now(), + pending: false, + optimistic: false + }); + } + } else if (browser && merged.reconcileKind === 'invalidate' && merged.reconcileDoc) { + deps.cache.invalidate(cacheKey(merged.reconcileDoc, merged.reconcileVars)); + } + // subscription: caller owns live query → cache write-through separately + + opts.onSettled?.(); + return result; +} + +export { effect }; +export type { CacheOp, Effect, CommandPolicy, ResultKind, ReconcileKind }; diff --git a/tests/e2e-ui/ui/src/lib/gql/cache/query-cache.ts b/tests/e2e-ui/ui/src/lib/gql/cache/query-cache.ts new file mode 100644 index 00000000..0ac3102d --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/cache/query-cache.ts @@ -0,0 +1,99 @@ +/** + * Document-keyed browser query cache (pure). + * Not a system of truth — projectors + live queries own freshness. + */ + +export type CacheKey = string; + +export type CacheEntry = { + data: T; + updatedAt: number; + optimistic?: boolean; + /** Command succeeded; projection not yet reconciled. */ + pending?: boolean; +}; + +export type CacheListener = () => void; + +export class QueryCache { + #entries = new Map(); + #listeners = new Map>(); + + get(key: CacheKey): CacheEntry | undefined { + return this.#entries.get(key) as CacheEntry | undefined; + } + + set(key: CacheKey, entry: CacheEntry): void { + this.#entries.set(key, entry as CacheEntry); + this.#notify(key); + } + + update(key: CacheKey, fn: (data: T) => T): void { + const prev = this.get(key); + if (!prev) return; + this.set(key, { + ...prev, + data: fn(prev.data), + updatedAt: Date.now() + }); + } + + /** Delete a single key (exact). */ + delete(key: CacheKey): void { + if (this.#entries.delete(key)) this.#notify(key); + } + + invalidate(prefix?: string): void { + if (prefix === undefined) { + const keys = [...this.#entries.keys()]; + this.#entries.clear(); + for (const k of keys) this.#notify(k); + return; + } + for (const key of [...this.#entries.keys()]) { + if (key.startsWith(prefix)) { + this.#entries.delete(key); + this.#notify(key); + } + } + } + + subscribe(key: CacheKey, cb: CacheListener): () => void { + let set = this.#listeners.get(key); + if (!set) { + set = new Set(); + this.#listeners.set(key, set); + } + set.add(cb); + return () => { + set!.delete(cb); + if (set!.size === 0) this.#listeners.delete(key); + }; + } + + #notify(key: CacheKey): void { + const set = this.#listeners.get(key); + if (!set) return; + for (const cb of set) cb(); + } +} + +/** Stable key for a document string + variables. */ +export function cacheKey(document: string, variables?: Record): CacheKey { + // Treat missing and empty variables as the same key. + const vars = + variables && Object.keys(variables).length > 0 ? stableStringify(variables) : ''; + return `${document.trim()}::${vars}`; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`; +} diff --git a/tests/e2e-ui/ui/src/lib/gql/index.ts b/tests/e2e-ui/ui/src/lib/gql/index.ts index d38adef1..c0b941da 100644 --- a/tests/e2e-ui/ui/src/lib/gql/index.ts +++ b/tests/e2e-ui/ui/src/lib/gql/index.ts @@ -21,3 +21,22 @@ export { browserGraphql } from './client.ts'; // Re-export command binders for non-useGraphql call sites (tests, SSR factories). export { bindCommands } from '$lib/api/commands.generated'; export type { BoundCommands, CommandClient } from '$lib/api/commands.generated'; + +/** Browser query cache + command result pipeline (ack/fact/projection). */ +export { + QueryCache, + cacheKey, + runCommandPipeline, + effect, + applyCacheOps, + rollback +} from './cache/index.ts'; +export type { + CacheOp, + CacheTarget, + CommandPolicy, + Effect, + ResultKind, + ReconcileKind, + CommandPipelineOptions +} from './cache/index.ts'; diff --git a/tests/e2e-ui/ui/tests/command-pipeline.test.mjs b/tests/e2e-ui/ui/tests/command-pipeline.test.mjs new file mode 100644 index 00000000..a1a9da3e --- /dev/null +++ b/tests/e2e-ui/ui/tests/command-pipeline.test.mjs @@ -0,0 +1,44 @@ +/** + * Unit tests for browser command pipeline + QueryCache. + * Drives the shipped modules under src/lib/gql/cache/. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const uiRoot = path.dirname(fileURLToPath(new URL('.', import.meta.url))); +const cacheDir = path.join(uiRoot, '../src/lib/gql/cache'); + +// Node cannot import .ts directly — load compiled-equivalent via dynamic import +// of the TypeScript sources through a small ESM bridge using tsx if present, +// otherwise re-implement is forbidden: use node --experimental-strip-types when available. + +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const runner = path.join(uiRoot, 'tests/run-pipeline-unit.mjs'); +const r = spawnSync(process.execPath, ['--experimental-strip-types', runner], { + encoding: 'utf8', + cwd: uiRoot +}); + +if (r.status !== 0) { + // Fallback: inline minimal loader that imports .ts with strip-types from this file + console.error(r.stdout); + console.error(r.stderr); +} + +test('pipeline unit suite (strip-types)', () => { + if (r.status === 0) { + assert.match(r.stdout + r.stderr, /# pass|# tests|ok |passed/i); + return; + } + // If strip-types unsupported, fail with diagnostics + assert.equal( + r.status, + 0, + `pipeline unit runner failed:\n${r.stdout}\n${r.stderr}` + ); +}); diff --git a/tests/e2e-ui/ui/tests/run-pipeline-unit.mjs b/tests/e2e-ui/ui/tests/run-pipeline-unit.mjs new file mode 100644 index 00000000..98b8c7bf --- /dev/null +++ b/tests/e2e-ui/ui/tests/run-pipeline-unit.mjs @@ -0,0 +1,218 @@ +/** + * Runnable via: node --experimental-strip-types tests/run-pipeline-unit.mjs + * Imports shipped TypeScript modules under src/lib/gql/cache/. + */ +import assert from 'node:assert/strict'; +import { + QueryCache, + cacheKey, + applyCacheOps, + rollback, + runCommandPipeline, + effect +} from '../src/lib/gql/cache/index.ts'; + +const TODOS_DOC = 'query Todos { todos { todo_id title status } }'; +const CREATE_DOC = 'mutation TodosCreate($input: TodosCreateInput!) { todos_create(input: $input) { todo_id } }'; + +let passed = 0; +function check(name, fn) { + try { + fn(); + passed += 1; + console.log(`ok - ${name}`); + } catch (e) { + console.error(`not ok - ${name}`); + console.error(e); + process.exitCode = 1; + } +} + +async function checkAsync(name, fn) { + try { + await fn(); + passed += 1; + console.log(`ok - ${name}`); + } catch (e) { + console.error(`not ok - ${name}`); + console.error(e); + process.exitCode = 1; + } +} + +check('QueryCache set/get/invalidate', () => { + const cache = new QueryCache(); + const key = cacheKey(TODOS_DOC, {}); + cache.set(key, { data: { todos: [] }, updatedAt: 1 }); + assert.deepEqual(cache.get(key)?.data, { todos: [] }); + cache.invalidate(key); + assert.equal(cache.get(key), undefined); +}); + +check('optimistic upsert into list by PK', () => { + const cache = new QueryCache(); + const key = cacheKey(TODOS_DOC, {}); + cache.set(key, { + data: { todos: [{ todo_id: 'a', title: 'old', status: 'open' }] }, + updatedAt: 1 + }); + applyCacheOps(cache, [ + { + op: 'upsert', + target: { document: TODOS_DOC, at: 'todos', by: 'todo_id' }, + row: { todo_id: 'b', title: 'new', status: 'open' } + } + ]); + const todos = cache.get(key).data.todos; + assert.equal(todos.length, 2); + assert.equal(todos[1].todo_id, 'b'); +}); + +check('rollback restores previous cache', () => { + const cache = new QueryCache(); + const key = cacheKey(TODOS_DOC, {}); + cache.set(key, { + data: { todos: [{ todo_id: 'a', title: 'old', status: 'open' }] }, + updatedAt: 1 + }); + const snaps = applyCacheOps(cache, [ + { + op: 'upsert', + target: { document: TODOS_DOC, at: 'todos', by: 'todo_id' }, + row: { todo_id: 'b', title: 'ghost', status: 'open' } + } + ]); + assert.equal(cache.get(key).data.todos.length, 2); + rollback(cache, snaps); + assert.equal(cache.get(key).data.todos.length, 1); + assert.equal(cache.get(key).data.todos[0].todo_id, 'a'); +}); + +await checkAsync('error path rolls back optimistic and runs onError effect', async () => { + const cache = new QueryCache(); + const key = cacheKey(TODOS_DOC, {}); + cache.set(key, { data: { todos: [] }, updatedAt: 1 }); + const effects = []; + const result = await runCommandPipeline( + { + cache, + request: async () => ({ data: null, errors: [{ message: 'boom' }] }), + runEffects: (e) => effects.push(...e) + }, + CREATE_DOC, + { todo_id: 'x', title: 't' }, + { + optimistic: { + targets: [{ document: TODOS_DOC, at: 'todos', by: 'todo_id' }], + row: { todo_id: 'x', title: 't', status: 'open' } + }, + result: { kind: 'ack' }, + onError: ({ errors }) => [effect.alert(errors[0].message)] + } + ); + assert.ok(result.errors?.length); + assert.equal(cache.get(key).data.todos.length, 0); + assert.equal(effects[0]?.kind, 'alert'); + assert.equal(effects[0]?.message, 'boom'); +}); + +await checkAsync('ack success does not invent full list row from incomplete payload', async () => { + const cache = new QueryCache(); + const key = cacheKey(TODOS_DOC, {}); + cache.set(key, { data: { todos: [] }, updatedAt: 1 }); + // No optimistic — only incomplete server payload + const result = await runCommandPipeline( + { + cache, + request: async () => ({ + data: { todos_create: { todo_id: 'only-id' } }, + errors: null + }) + }, + CREATE_DOC, + { todo_id: 'only-id', title: 't' }, + { + result: { kind: 'ack' }, + reconcile: { kind: 'none' } + } + ); + assert.ok(result.data); + // Must not invent { todo_id, title, status } list row from ack payload alone + assert.deepEqual(cache.get(key).data.todos, []); +}); + +await checkAsync('projection applies payload fields only to targets', async () => { + const cache = new QueryCache(); + const key = cacheKey(TODOS_DOC, {}); + cache.set(key, { + data: { todos: [{ todo_id: 'g', title: 'old', status: 'open', secret: 'keep' }] }, + updatedAt: 1 + }); + await runCommandPipeline( + { + cache, + request: async () => ({ + data: { + game_move: { todo_id: 'g', title: 'from-server', status: 'done' } + }, + errors: null + }) + }, + 'mutation M($input: In!) { game_move(input: $input) { todo_id title status } }', + { todo_id: 'g' }, + { + result: { + kind: 'projection', + apply: { + targets: [{ document: TODOS_DOC, at: 'todos', by: 'todo_id' }] + } + } + } + ); + const row = cache.get(key).data.todos.find((t) => t.todo_id === 'g'); + assert.equal(row.title, 'from-server'); + assert.equal(row.status, 'done'); + // secret was not in payload — merge keeps existing on upsert merge + assert.equal(row.secret, 'keep'); +}); + +await checkAsync('onSuccess toast is Effect and not run on error', async () => { + const effects = []; + await runCommandPipeline( + { + cache: new QueryCache(), + request: async () => ({ data: null, errors: [{ message: 'no' }] }), + runEffects: (e) => effects.push(...e) + }, + CREATE_DOC, + {}, + { + result: { kind: 'ack' }, + onSuccess: () => [effect.toast('Created')], + onError: () => [effect.alert('Failed')] + } + ); + assert.equal(effects.length, 1); + assert.equal(effects[0].kind, 'alert'); +}); + +await checkAsync('exactly one network request for command', async () => { + let calls = 0; + await runCommandPipeline( + { + cache: new QueryCache(), + request: async () => { + calls += 1; + return { data: { todos_create: { todo_id: '1' } }, errors: null }; + } + }, + CREATE_DOC, + { todo_id: '1' }, + { result: { kind: 'ack' } } + ); + assert.equal(calls, 1); +}); + +console.log(`# tests ${passed}`); +console.log(`# pass ${passed}`); +if (process.exitCode) process.exit(process.exitCode); From 30e5042042792e9563142c5785dfce447032afa4 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 16 Jul 2026 17:34:44 -0500 Subject: [PATCH 02/20] feat(e2e-ui): migrate todos/chat/admin to command pipeline + QueryCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages use gql.commands.* with optimistic → fact/ack → reconcile (refetch/sub). List UI is cache-driven; hand-rolled mergeFromServer/pending maps removed. bindCommandsPipeline wires generated COMMAND_DOCS through runCommandPipeline. Tests assert page structure and pipeline behavior (rollback, refetch). --- .../ui/src/lib/gql/bind-commands-pipeline.ts | 181 +++++++++++++++++ tests/e2e-ui/ui/src/lib/gql/cache-helpers.ts | 53 +++++ tests/e2e-ui/ui/src/lib/gql/create-client.ts | 71 ++++++- tests/e2e-ui/ui/src/lib/gql/index.ts | 14 +- tests/e2e-ui/ui/src/lib/gql/use-graphql.ts | 47 ++++- tests/e2e-ui/ui/src/routes/admin/+page.svelte | 73 ++++--- tests/e2e-ui/ui/src/routes/chat/+page.svelte | 86 +++++--- tests/e2e-ui/ui/src/routes/todos/+page.svelte | 187 +++++++++--------- tests/e2e-ui/ui/tests/api-contract.test.mjs | 24 ++- tests/e2e-ui/ui/tests/command-client.test.mjs | 14 +- tests/e2e-ui/ui/tests/page-pipeline.test.mjs | 133 +++++++++++++ 11 files changed, 721 insertions(+), 162 deletions(-) create mode 100644 tests/e2e-ui/ui/src/lib/gql/bind-commands-pipeline.ts create mode 100644 tests/e2e-ui/ui/src/lib/gql/cache-helpers.ts create mode 100644 tests/e2e-ui/ui/tests/page-pipeline.test.mjs diff --git a/tests/e2e-ui/ui/src/lib/gql/bind-commands-pipeline.ts b/tests/e2e-ui/ui/src/lib/gql/bind-commands-pipeline.ts new file mode 100644 index 00000000..3bc80053 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/bind-commands-pipeline.ts @@ -0,0 +1,181 @@ +/** + * Bind generated commands through the command result pipeline when a QueryCache + * is present. Call sites: + * + * await gql.commands.todosCreate(input, { + * optimistic: { targets: [...], row }, + * result: { kind: 'fact' }, + * reconcile: { kind: 'refetch', document: todosDoc }, + * onError: ({ errors }) => [effect.alert(errors[0]?.message ?? 'Failed')], + * }); + * + * Without a second arg, defaults from `commandPolicies` apply when provided. + */ +import { + COMMAND_DOCS, + bindCommands, + type BoundCommands, + type CommandClient, + type TodoCreateInput, + type TodoCreatePayload, + type TodoCompleteInput, + type TodoStatusPayload, + type TodoArchiveInput, + type TodoForceArchiveInput, + type TodoForceArchivePayload, + type TodoRenameInput, + type TodoRenamePayload, + type TodoReopenInput, + type ChatPostInput, + type ChatPostPayload +} from '../api/commands.generated.ts'; +import type { GqlResult } from './types.ts'; +import type { QueryCache } from './cache/query-cache.ts'; +import { + runCommandPipeline, + type CommandPipelineOptions, + type CommandPolicy +} from './cache/index.ts'; + +export type CommandCallOptions = CommandPipelineOptions; + +/** Default policies keyed by bound command function name. */ +export type CommandPolicyMap = Partial>; + +export type PipelinedBoundCommands = { + todosCreate: ( + input: TodoCreateInput, + opts?: CommandCallOptions + ) => Promise>; + todosComplete: ( + input: TodoCompleteInput, + opts?: CommandCallOptions + ) => Promise>; + todosArchive: ( + input: TodoArchiveInput, + opts?: CommandCallOptions + ) => Promise>; + todosForceArchive: ( + input: TodoForceArchiveInput, + opts?: CommandCallOptions + ) => Promise>; + todosRename: ( + input: TodoRenameInput, + opts?: CommandCallOptions + ) => Promise>; + todosReopen: ( + input: TodoReopenInput, + opts?: CommandCallOptions + ) => Promise>; + chatMessagesPost: ( + input: ChatPostInput, + opts?: CommandCallOptions + ) => Promise>; +}; + +type FieldName = keyof typeof COMMAND_DOCS; + +const FN_TO_FIELD: Record = { + todosCreate: 'todos_create', + todosComplete: 'todos_complete', + todosArchive: 'todos_archive', + todosForceArchive: 'todos_force_archive', + todosRename: 'todos_rename', + todosReopen: 'todos_reopen', + chatMessagesPost: 'chat_messages_post' +}; + +function isBrowser(): boolean { + return typeof window !== 'undefined'; +} + +function unwrapField( + data: Record | null | undefined, + field: string +): T | undefined { + if (!data || typeof data !== 'object') return undefined; + return data[field] as T | undefined; +} + +/** + * Bind commands. With `cache`, each call runs optimistic → network → result → + * effects → reconcile. Without cache (SSR), falls back to plain `bindCommands`. + */ +export function bindCommandsPipeline( + client: CommandClient, + options: { + cache?: QueryCache; + policies?: CommandPolicyMap; + /** Collect UI effects (toast/alert) from onSuccess/onError. */ + runEffects?: (effects: import('./cache/ops.ts').Effect[]) => void; + } = {} +): PipelinedBoundCommands { + const plain = bindCommands(client); + const cache = options.cache; + const policies = options.policies ?? {}; + + if (!cache) { + // Still accept optional second arg for API compatibility; ignore it. + return { + todosCreate: (input, _opts?) => plain.todosCreate(input), + todosComplete: (input, _opts?) => plain.todosComplete(input), + todosArchive: (input, _opts?) => plain.todosArchive(input), + todosForceArchive: (input, _opts?) => plain.todosForceArchive(input), + todosRename: (input, _opts?) => plain.todosRename(input), + todosReopen: (input, _opts?) => plain.todosReopen(input), + chatMessagesPost: (input, _opts?) => plain.chatMessagesPost(input) + }; + } + + const wrap = + , O>(fnName: keyof BoundCommands) => + async (input: I, callOpts: CommandCallOptions = {}): Promise> => { + const field = FN_TO_FIELD[fnName]; + const document = COMMAND_DOCS[field]; + const policy = policies[fnName]; + // Always use the pipeline when a cache is bound. Optimistic/cache mutations + // run when `browser` is true (real window, or explicit opt-in for unit tests). + const browser = callOpts.browser ?? isBrowser(); + + const result = await runCommandPipeline( + { + cache, + request: async (doc, variables) => { + const r = await client.request(doc, variables); + return { data: r.data as Record | null, errors: r.errors }; + }, + refetch: async (doc, variables) => { + const r = await client.request(doc, variables); + return { data: r.data as Record | null, errors: r.errors }; + }, + runEffects: options.runEffects + }, + document, + input, + { + ...callOpts, + policy: callOpts.policy ?? policy, + browser + } + ); + + return { + data: unwrapField( + result.data as Record | null | undefined, + field + ), + errors: result.errors ?? undefined, + status: 200 + }; + }; + + return { + todosCreate: wrap('todosCreate'), + todosComplete: wrap('todosComplete'), + todosArchive: wrap('todosArchive'), + todosForceArchive: wrap('todosForceArchive'), + todosRename: wrap('todosRename'), + todosReopen: wrap('todosReopen'), + chatMessagesPost: wrap('chatMessagesPost') + }; +} diff --git a/tests/e2e-ui/ui/src/lib/gql/cache-helpers.ts b/tests/e2e-ui/ui/src/lib/gql/cache-helpers.ts new file mode 100644 index 00000000..c80c7812 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/cache-helpers.ts @@ -0,0 +1,53 @@ +/** + * Small helpers for pages that seed / read a document-keyed QueryCache. + */ +import type { QueryCache } from './cache/query-cache.ts'; +import { cacheKey } from './cache/query-cache.ts'; +import { documentToString, type GqlDocument } from './document.ts'; + +export function seedQueryCache( + cache: QueryCache, + document: GqlDocument, + data: T, + variables?: Record +): string { + const key = cacheKey(documentToString(document), variables); + cache.set(key, { + data, + updatedAt: Date.now(), + optimistic: false, + pending: false + }); + return key; +} + +export function readQueryList( + cache: QueryCache, + document: GqlDocument, + at: string, + variables?: Record +): T[] { + const key = cacheKey(documentToString(document), variables); + const entry = cache.get>(key); + const list = entry?.data?.[at]; + return Array.isArray(list) ? (list as T[]) : []; +} + +export function queryDocString(document: GqlDocument): string { + return documentToString(document); +} + +/** List target for optimistic upserts / projection apply. */ +export function listTarget( + document: GqlDocument, + at: string, + by: string, + variables?: Record +) { + return { + document: documentToString(document), + variables, + at, + by + }; +} diff --git a/tests/e2e-ui/ui/src/lib/gql/create-client.ts b/tests/e2e-ui/ui/src/lib/gql/create-client.ts index fce2c11a..c01e2ddd 100644 --- a/tests/e2e-ui/ui/src/lib/gql/create-client.ts +++ b/tests/e2e-ui/ui/src/lib/gql/create-client.ts @@ -5,6 +5,9 @@ * * Browser clients also expose `subscribe` so WebSocket auth/URL match HTTP — * no separate `authFromPageData` at call sites. + * + * Optional `cache`: query/subscription write-through (never treats mutations as + * projected list truth — command pipeline owns command result apply). */ import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { @@ -12,12 +15,19 @@ import { type GqlWsHandlers } from '$lib/graphql-ws'; import { requestGraphql, type GqlDocument } from './request.ts'; +import { documentToString } from './document.ts'; import type { GqlAuth, GqlResult } from './types.ts'; +import type { QueryCache } from './cache/query-cache.ts'; +import { cacheKey } from './cache/query-cache.ts'; export type GraphqlClientOptions = { /** Absolute API URL or same-origin path, e.g. `/graphql` or `http://127.0.0.1:8791/graphql` */ getUrl: () => string; getAuth: () => GqlAuth | Promise; + /** Browser query/subscription cache (optional). */ + cache?: QueryCache; + /** When true (default if cache set), write query results into the cache. */ + writeThrough?: boolean; }; export type GraphqlClient = { @@ -31,12 +41,23 @@ export type GraphqlClient = { /** * Live subscription over `/graphql/ws` using the same auth as `request`. * Returns an unsubscribe function (safe to call before the socket opens). + * When a cache is configured, successful payloads write-through by document key. */ subscribe: (document: GqlDocument, handlers: GqlWsHandlers) => () => void; + /** Shared query cache when configured on the client. */ + cache?: QueryCache; }; +function looksLikeMutation(document: GqlDocument | TypedDocumentNode): boolean { + const src = documentToString(document as GqlDocument).trimStart(); + return /^mutation[\s({]/i.test(src); +} + export function createGraphqlClient(opts: GraphqlClientOptions): GraphqlClient { - return { + const writeThrough = opts.writeThrough ?? !!opts.cache; + + const client: GraphqlClient = { + cache: opts.cache, async request< TResult = Record, TVariables extends Record = Record @@ -45,20 +66,61 @@ export function createGraphqlClient(opts: GraphqlClientOptions): GraphqlClient { variables?: TVariables ): Promise> { const auth = await opts.getAuth(); - return requestGraphql( + const result = await requestGraphql( opts.getUrl(), - document, + document as GqlDocument, auth, (variables ?? {}) as TVariables ); + // Query write-through only — mutations go through the command pipeline. + if ( + opts.cache && + writeThrough && + result.data && + !result.errors?.length && + !looksLikeMutation(document) + ) { + const key = cacheKey( + documentToString(document as GqlDocument), + (variables ?? {}) as Record + ); + opts.cache.set(key, { + data: result.data, + updatedAt: Date.now(), + optimistic: false, + pending: false + }); + } + return result; }, subscribe(document, handlers) { let unsub = () => {}; let cancelled = false; + const wrapped: GqlWsHandlers = { + ...handlers, + onNext: (payload) => { + if (opts.cache && writeThrough && payload && typeof payload === 'object') { + const p = payload as { + data?: unknown; + errors?: unknown[]; + }; + if (p.data && !p.errors?.length) { + const key = cacheKey(documentToString(document), {}); + opts.cache.set(key, { + data: p.data, + updatedAt: Date.now(), + optimistic: false, + pending: false + }); + } + } + handlers.onNext?.(payload); + } + }; void (async () => { const auth = await opts.getAuth(); if (cancelled) return; - unsub = subscribeWs(document, auth, handlers, { + unsub = subscribeWs(document, auth, wrapped, { httpUrl: opts.getUrl() }); })(); @@ -68,4 +130,5 @@ export function createGraphqlClient(opts: GraphqlClientOptions): GraphqlClient { }; } }; + return client; } diff --git a/tests/e2e-ui/ui/src/lib/gql/index.ts b/tests/e2e-ui/ui/src/lib/gql/index.ts index c0b941da..7f7032f6 100644 --- a/tests/e2e-ui/ui/src/lib/gql/index.ts +++ b/tests/e2e-ui/ui/src/lib/gql/index.ts @@ -16,11 +16,17 @@ export type { GraphqlClient, GraphqlClientOptions } from './create-client.ts'; export { defineResource } from './define-resource.ts'; export type { DefineResourceInput, GraphqlResource } from './define-resource.ts'; export { useGraphql, authFromPageData } from './use-graphql.ts'; -export type { AppGraphqlClient, PageGraphqlData } from './use-graphql.ts'; +export type { AppGraphqlClient, PageGraphqlData, UseGraphqlOptions } from './use-graphql.ts'; export { browserGraphql } from './client.ts'; // Re-export command binders for non-useGraphql call sites (tests, SSR factories). export { bindCommands } from '$lib/api/commands.generated'; export type { BoundCommands, CommandClient } from '$lib/api/commands.generated'; +export { bindCommandsPipeline } from './bind-commands-pipeline.ts'; +export type { + PipelinedBoundCommands, + CommandCallOptions, + CommandPolicyMap +} from './bind-commands-pipeline.ts'; /** Browser query cache + command result pipeline (ack/fact/projection). */ export { @@ -40,3 +46,9 @@ export type { ReconcileKind, CommandPipelineOptions } from './cache/index.ts'; +export { + seedQueryCache, + readQueryList, + queryDocString, + listTarget +} from './cache-helpers.ts'; diff --git a/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts b/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts index 7a60a3a1..3ba441a6 100644 --- a/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts +++ b/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts @@ -1,17 +1,34 @@ /** * Browser binder: page data → GqlAuth + createGraphqlClient (POST /graphql). - * Attaches generated command helpers as `client.commands.*`. + * Attaches generated command helpers as `client.commands.*` through the + * command result pipeline when a browser QueryCache is available. */ -import { bindCommands, type BoundCommands } from '$lib/api/commands.generated'; import { createGraphqlClient, type GraphqlClient } from './create-client.ts'; import { authFromPageData, type PageGraphqlData } from './auth-from-page.ts'; +import { + bindCommandsPipeline, + type CommandPolicyMap, + type PipelinedBoundCommands +} from './bind-commands-pipeline.ts'; +import { QueryCache } from './cache/query-cache.ts'; +import type { Effect } from './cache/ops.ts'; export type { PageGraphqlData } from './auth-from-page.ts'; export { authFromPageData } from './auth-from-page.ts'; -/** Bound HTTP + WS client with pre-registered command functions. */ +/** Bound HTTP + WS client with pipelined command functions + shared cache. */ export type AppGraphqlClient = GraphqlClient & { - commands: BoundCommands; + commands: PipelinedBoundCommands; + cache: QueryCache; +}; + +export type UseGraphqlOptions = { + /** Override / seed the shared browser cache (default: new QueryCache). */ + cache?: QueryCache; + /** Per-command default result/reconcile policies. */ + policies?: CommandPolicyMap; + /** Optional UI effect handler (toast/alert). */ + runEffects?: (effects: Effect[]) => void; }; /** @@ -20,16 +37,30 @@ export type AppGraphqlClient = GraphqlClient & { * * @example * const gql = useGraphql(() => data); - * await gql.commands.todosCreate({ todo_id, title }); + * await gql.commands.todosCreate( + * { todo_id, title }, + * { optimistic: { targets: [...], row }, result: { kind: 'fact' }, reconcile: { kind: 'refetch', document } } + * ); * gql.subscribe(chat.subscription, { onNext }); */ -export function useGraphql(getData: () => PageGraphqlData): AppGraphqlClient { +export function useGraphql( + getData: () => PageGraphqlData, + options: UseGraphqlOptions = {} +): AppGraphqlClient { + const cache = options.cache ?? new QueryCache(); const client = createGraphqlClient({ getUrl: () => '/graphql', - getAuth: () => authFromPageData(getData()) + getAuth: () => authFromPageData(getData()), + cache, + writeThrough: true }); return { ...client, - commands: bindCommands(client) + cache, + commands: bindCommandsPipeline(client, { + cache, + policies: options.policies, + runEffects: options.runEffects + }) }; } diff --git a/tests/e2e-ui/ui/src/routes/admin/+page.svelte b/tests/e2e-ui/ui/src/routes/admin/+page.svelte index c059eac0..6e4de184 100644 --- a/tests/e2e-ui/ui/src/routes/admin/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/admin/+page.svelte @@ -1,9 +1,16 @@ diff --git a/tests/e2e-ui/ui/src/routes/chat/+page.svelte b/tests/e2e-ui/ui/src/routes/chat/+page.svelte index daa3d47e..1b2ee7e2 100644 --- a/tests/e2e-ui/ui/src/routes/chat/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/chat/+page.svelte @@ -1,11 +1,18 @@ @@ -144,8 +185,9 @@

Co-located chat.resource: SSR seed + live - graphql-transport-ws (Bearer in connection_init) + - POST /graphql posts. Signed in as {displayName}. + gql.subscribe (cache write-through) + + gql.commands.chatMessagesPost pipeline. Signed in as + {displayName}.

diff --git a/tests/e2e-ui/ui/src/routes/todos/+page.svelte b/tests/e2e-ui/ui/src/routes/todos/+page.svelte index adae9637..a4e01a83 100644 --- a/tests/e2e-ui/ui/src/routes/todos/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/todos/+page.svelte @@ -1,12 +1,13 @@ @@ -190,8 +186,9 @@

Field notes

Tasks for {who}. Co-located - todos.resource query + mutations on SSR and in the browser - (POST /graphql). + todos.resource query + gql.commands.* pipeline + (optimistic → fact/ack → refetch). Async projectors; list truth lives in the + browser QueryCache.

diff --git a/tests/e2e-ui/ui/tests/api-contract.test.mjs b/tests/e2e-ui/ui/tests/api-contract.test.mjs index f919a4c3..493df318 100644 --- a/tests/e2e-ui/ui/tests/api-contract.test.mjs +++ b/tests/e2e-ui/ui/tests/api-contract.test.mjs @@ -102,9 +102,10 @@ test('admin: role-gated all-owners todos view + force-archive mutation', () => { new URL('../../crates/service/src/service.rs', import.meta.url), 'utf8' ); - assert.match(service, /role\("admin"/); + // Admin grant uses grant/read API (not legacy role("admin")). + assert.match(service, /grant\("admin"|role\("admin"/); assert.match(service, /todos_force_archive|force_archive/); - assert.match(service, /\.roles\(\[\"admin\"\]\)|roles\(\[\"admin\"\]\)/); + assert.match(service, /\.roles\(\[\"admin\"\]\)|roles\(\[\"admin\"\]\)|roles\(\["admin"\]\)/); assert.match(service, /owner_id|claim\("x-user-id"\)/); assert.match(service, /graphiql_enabled|GRAPHIQL/); const force = fs.readFileSync( @@ -184,7 +185,7 @@ test('home is distributed template with 8-step unidirectional todos story', () = assert.match(home, /accessToken|Auth\.js|OIDC|Zitadel/i); assert.match(home, /Bearer|serverGraphql|authorization/i); assert.match(home, /ModelPermissions|owner_id|claim\("x-user-id"\)|OidcBearer/); - assert.match(home, /data\.todos|hydration|mergeFromServer/i); + assert.match(home, /data\.todos|hydration|QueryCache|optimistic/i); assert.match(home, /subscription|connection_init/); assert.match(home, /anti-pattern|todos_create|todo\.create/i); assert.match(home, /require_user|outbox|TodoFact|todo\.created/); @@ -259,17 +260,21 @@ test('todos: co-located .gql + resource — same query SSR + browser mutations', const page = fs.readFileSync(new URL('../src/routes/todos/+page.svelte', import.meta.url), 'utf8'); assert.match(page, /\$state\(\[\.\.\.\(data\.todos/); - assert.match(page, /mergeFromServer/); + // Command pipeline + QueryCache (not hand-rolled mergeFromServer) + assert.match(page, /seedQueryCache|gql\.cache|QueryCache/); + assert.match(page, /optimistic:/); + assert.match(page, /result:\s*\{\s*kind:\s*'fact'/); + assert.match(page, /reconcile:\s*\{\s*kind:\s*'refetch'/); + assert.doesNotMatch(page, /function mergeFromServer/); assert.match(page, /useGraphql/); assert.match(page, /todos\.resource|todosResource|from '\.\/todos\.resource'/); assert.match(page, /todosResource\.query|todos\.query/); - // Writes: generated command functions (GraphQL wire under the hood). + // Writes: generated command functions through pipeline. assert.match(page, /gql\.commands\.todosCreate/); assert.match(page, /gql\.commands\.todosComplete/); assert.match(page, /gql\.commands\.todosArchive/); assert.doesNotMatch(page, /mutations\.(create|complete|archive)/); assert.doesNotMatch(page, /use:enhance|\?\/create|export const actions/); - // Query refetch still uses GraphQL client; writes use command client assert.match(page, /useGraphql|gql\.request|todosCreate/); const server = fs.readFileSync( @@ -415,10 +420,11 @@ test('live GraphQL unauthenticated rejected when OIDC stack', { skip: !base }, a } }); -test('useGraphql attaches bindCommands as client.commands', () => { +test('useGraphql attaches bindCommandsPipeline as client.commands', () => { const use = fs.readFileSync(new URL('../src/lib/gql/use-graphql.ts', import.meta.url), 'utf8'); - assert.match(use, /bindCommands/); - assert.match(use, /commands:\s*bindCommands/); + assert.match(use, /bindCommandsPipeline/); + assert.match(use, /commands:\s*bindCommandsPipeline/); + assert.match(use, /QueryCache/); assert.match(use, /AppGraphqlClient/); }); diff --git a/tests/e2e-ui/ui/tests/command-client.test.mjs b/tests/e2e-ui/ui/tests/command-client.test.mjs index 7158d61a..99b284f5 100644 --- a/tests/e2e-ui/ui/tests/command-client.test.mjs +++ b/tests/e2e-ui/ui/tests/command-client.test.mjs @@ -91,20 +91,32 @@ test('generated todosCreate uses client.request + COMMAND_DOCS', async () => { assert.match(r.stdout, /command-client-ok/); }); -test('app pages use gql.commands.* (pre-bound on useGraphql client)', () => { +test('app pages use gql.commands.* pipeline (optimistic + fact/ack + reconcile)', () => { const todos = fs.readFileSync(pageSvelte, 'utf8'); assert.match(todos, /gql\.commands\.todosCreate/); assert.match(todos, /gql\.commands\.todosComplete/); assert.match(todos, /gql\.commands\.todosArchive/); + assert.match(todos, /result:\s*\{\s*kind:\s*'fact'/); + assert.match(todos, /reconcile:\s*\{\s*kind:\s*'refetch'/); + assert.match(todos, /optimistic:/); + assert.match(todos, /QueryCache|gql\.cache|seedQueryCache/); assert.doesNotMatch(todos, /from '\$lib\/api\/commands\.generated'/); assert.doesNotMatch(todos, /url:\s*['"]\/graphql['"]/); + // No hand-rolled pending map / mergeFromServer — pipeline owns latency compensation + assert.doesNotMatch(todos, /function mergeFromServer/); + assert.doesNotMatch(todos, /let pending = \$state/); const chat = fs.readFileSync(path.join(uiRoot, 'src/routes/chat/+page.svelte'), 'utf8'); assert.match(chat, /gql\.commands\.chatMessagesPost/); + assert.match(chat, /optimistic:/); + assert.match(chat, /result:\s*\{\s*kind:\s*'fact'/); + assert.match(chat, /reconcile:\s*\{\s*kind:\s*'subscription'/); assert.doesNotMatch(chat, /from '\$lib\/api\/commands\.generated'/); const admin = fs.readFileSync(path.join(uiRoot, 'src/routes/admin/+page.svelte'), 'utf8'); assert.match(admin, /gql\.commands\.todosForceArchive/); + assert.match(admin, /optimistic:/); + assert.match(admin, /reconcile:\s*\{\s*kind:\s*'refetch'/); assert.doesNotMatch(admin, /from '\$lib\/api\/commands\.generated'/); }); diff --git a/tests/e2e-ui/ui/tests/page-pipeline.test.mjs b/tests/e2e-ui/ui/tests/page-pipeline.test.mjs new file mode 100644 index 00000000..8d924e68 --- /dev/null +++ b/tests/e2e-ui/ui/tests/page-pipeline.test.mjs @@ -0,0 +1,133 @@ +/** + * Pages use the real command pipeline binder (cache + optimistic + fact/ack). + * Drives bindCommandsPipeline + QueryCache — not page-local merge helpers. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('bindCommandsPipeline: optimistic create + rollback on error', () => { + const script = ` + import { QueryCache, cacheKey } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/gql/cache/query-cache.ts')).href + )}; + import { bindCommandsPipeline } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/gql/bind-commands-pipeline.ts')).href + )}; + import { COMMAND_DOCS } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/api/commands.generated.ts')).href + )}; + + const TODOS = 'query Todos { todos { todo_id title status owner_id } }'; + const cache = new QueryCache(); + const key = cacheKey(TODOS, {}); + cache.set(key, { data: { todos: [] }, updatedAt: 1 }); + + let calls = 0; + const client = { + async request(document, variables) { + calls += 1; + if (String(document).includes('todos_create')) { + return { data: null, errors: [{ message: 'nope' }], status: 200 }; + } + return { data: { todos: [] }, status: 200 }; + } + }; + + const commands = bindCommandsPipeline(client, { cache }); + const r = await commands.todosCreate( + { todo_id: 't1', title: 'x' }, + { + // Node unit tests have no window — force browser pipeline path. + browser: true, + result: { kind: 'fact' }, + reconcile: { kind: 'none' }, + optimistic: { + targets: [{ document: TODOS, at: 'todos', by: 'todo_id' }], + row: { todo_id: 't1', title: 'x', status: 'open', owner_id: 'me' } + } + } + ); + if (!r.errors?.length) throw new Error('expected errors'); + const todos = cache.get(key)?.data?.todos ?? []; + if (todos.length !== 0) throw new Error('expected rollback, got ' + todos.length); + if (calls !== 1) throw new Error('expected 1 network call, got ' + calls); + if (!COMMAND_DOCS.todos_create) throw new Error('missing COMMAND_DOCS'); + console.log('page-pipeline-ok'); + `; + + const r = spawnSync( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '-e', script], + { encoding: 'utf8', cwd: uiRoot } + ); + assert.equal(r.status, 0, `stderr=${r.stderr}\nstdout=${r.stdout}`); + assert.match(r.stdout, /page-pipeline-ok/); +}); + +test('bindCommandsPipeline: fact success keeps optimistic without inventing second row from empty list invent', () => { + const script = ` + import { QueryCache, cacheKey } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/gql/cache/query-cache.ts')).href + )}; + import { bindCommandsPipeline } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/gql/bind-commands-pipeline.ts')).href + )}; + + const TODOS = 'query Todos { todos { todo_id title status owner_id } }'; + const cache = new QueryCache(); + const key = cacheKey(TODOS, {}); + cache.set(key, { data: { todos: [] }, updatedAt: 1 }); + + let calls = 0; + const client = { + async request(document, variables) { + calls += 1; + if (String(document).includes('mutation')) { + return { + data: { todos_create: { todo_id: 't1', owner_id: 'a', title: 'x', status: 'open' } }, + status: 200 + }; + } + // refetch reconcile + return { + data: { + todos: [{ todo_id: 't1', owner_id: 'a', title: 'x', status: 'open' }] + }, + status: 200 + }; + } + }; + + const commands = bindCommandsPipeline(client, { cache }); + const r = await commands.todosCreate( + { todo_id: 't1', title: 'x' }, + { + browser: true, + result: { kind: 'fact' }, + reconcile: { kind: 'refetch', document: TODOS }, + optimistic: { + targets: [{ document: TODOS, at: 'todos', by: 'todo_id' }], + row: { todo_id: 't1', title: 'x', status: 'open', owner_id: 'a' } + } + } + ); + if (!r.data || r.data.todo_id !== 't1') throw new Error('unwrap failed'); + const todos = cache.get(key)?.data?.todos ?? []; + if (todos.length !== 1) throw new Error('expected 1 row after reconcile, got ' + todos.length); + if (calls < 2) throw new Error('expected command + refetch, got ' + calls); + console.log('page-pipeline-refetch-ok'); + `; + + const r = spawnSync( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '-e', script], + { encoding: 'utf8', cwd: uiRoot } + ); + assert.equal(r.status, 0, `stderr=${r.stderr}\nstdout=${r.stdout}`); + assert.match(r.stdout, /page-pipeline-refetch-ok/); +}); From f8f1e6003d4fb29c34b6226959806594894cf59e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 16 Jul 2026 18:10:47 -0500 Subject: [PATCH 03/20] =?UTF-8?q?refactor(e2e-ui):=20Houdini-style=20gql.s?= =?UTF-8?q?tore/live=20=E2=80=94=20cache=20is=20transparent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages no longer seed/subscribe/sync QueryCache by hand. Document stores auto-seed from SSR, follow cache notifications, and (live) open subscriptions. UI reads \$store.data / \$store.status; commands still attach optimistic targets via store.target(). --- tests/e2e-ui/ui/src/lib/gql/document-store.ts | 258 ++++++++++++++++++ tests/e2e-ui/ui/src/lib/gql/index.ts | 11 + tests/e2e-ui/ui/src/lib/gql/use-graphql.ts | 72 ++++- tests/e2e-ui/ui/src/routes/admin/+page.svelte | 56 ++-- tests/e2e-ui/ui/src/routes/chat/+page.svelte | 132 +++------ tests/e2e-ui/ui/src/routes/todos/+page.svelte | 72 ++--- tests/e2e-ui/ui/tests/api-contract.test.mjs | 21 +- tests/e2e-ui/ui/tests/command-client.test.mjs | 28 +- tests/e2e-ui/ui/tests/document-store.test.mjs | 79 ++++++ 9 files changed, 524 insertions(+), 205 deletions(-) create mode 100644 tests/e2e-ui/ui/src/lib/gql/document-store.ts create mode 100644 tests/e2e-ui/ui/tests/document-store.test.mjs diff --git a/tests/e2e-ui/ui/src/lib/gql/document-store.ts b/tests/e2e-ui/ui/src/lib/gql/document-store.ts new file mode 100644 index 00000000..606e7389 --- /dev/null +++ b/tests/e2e-ui/ui/src/lib/gql/document-store.ts @@ -0,0 +1,258 @@ +/** + * Houdini-inspired document store: the cache is transparent. + * + * App code does **not** seed/subscribe/sync the QueryCache by hand. + * A store: + * 1. Seeds the shared cache from SSR / initial data + * 2. Subscribes to that cache key (optimistic + refetch + live writes update UI) + * 3. Optionally opens a GraphQL subscription (write-through already in createClient) + * + * Usage (Svelte — `$` auto-subscribes to the store contract): + * + * const lobby = gql.live({ + * document: chat.subscription ?? chat.query, + * initialData: { chat_messages: data.messages }, + * select: (d) => sortChatMessages(d.chat_messages ?? []), + * }); + * // {$lobby.data} {$lobby.status} + * + * await gql.commands.chatMessagesPost(input, { + * result: { kind: 'fact' }, + * reconcile: { kind: 'subscription', document: lobby.document }, + * optimistic: { targets: [lobby.target('chat_messages', 'message_id')], row }, + * }); + */ + +import type { GqlDocument } from './document.ts'; +import { documentToString } from './document.ts'; +import type { QueryCache } from './cache/query-cache.ts'; +import { cacheKey } from './cache/query-cache.ts'; +import type { CacheTarget } from './cache/ops.ts'; +import type { GraphqlClient } from './create-client.ts'; + +export type StoreStatus = 'idle' | 'connecting' | 'live' | 'error'; + +export type DocumentStoreSnapshot = { + /** Selected view of the cached document data. */ + data: TSelected; + /** Full document payload in cache (before select). */ + raw: unknown; + status: StoreStatus; + error: string | null; + pending: boolean; + optimistic: boolean; +}; + +export type DocumentStoreOptions, TSelected = TData> = { + /** Query or subscription document (same selection set preferred). */ + document: GqlDocument; + variables?: Record; + /** SSR / first-paint payload written into the cache. */ + initialData?: TData; + /** Map cached document → UI shape (e.g. list extract + sort). */ + select?: (data: TData) => TSelected; + /** + * When true, open a live GraphQL subscription for `document`. + * Cache write-through is automatic; UI follows the cache. + */ + live?: boolean; +}; + +export type DocumentStore = { + /** Svelte store contract — use as `{$store.data}`. */ + subscribe: (run: (value: DocumentStoreSnapshot) => void) => () => void; + /** Current snapshot (non-reactive outside Svelte). */ + get: () => DocumentStoreSnapshot; + /** GraphQL source string (for command reconcile / policies). */ + readonly document: string; + readonly variables: Record | undefined; + /** Cache target for optimistic list upserts. */ + target: (at: string, by: string) => CacheTarget; + /** Replace SSR seed / external server data without dropping optimistic if newer. */ + seed: (data: unknown) => void; + /** Force HTTP refetch of the same document (write-through updates cache → UI). */ + refetch: () => Promise; + /** (Re)connect live subscription. */ + connect: () => void; + /** Tear down cache listener + WS. Call from onDestroy. */ + destroy: () => void; +}; + +function identity(x: T): T { + return x; +} + +/** + * Create a document-keyed store bound to a client + its QueryCache. + * Prefer `gql.store` / `gql.live` from `useGraphql`. + */ +export function createDocumentStore, TSelected = TData>( + client: GraphqlClient, + options: DocumentStoreOptions +): DocumentStore { + const cache = client.cache; + if (!cache) { + throw new Error('createDocumentStore requires a GraphqlClient with cache'); + } + + const docStr = documentToString(options.document); + const variables = options.variables; + const key = cacheKey(docStr, variables); + const select = (options.select ?? identity) as (data: TData) => TSelected; + + const listeners = new Set<(v: DocumentStoreSnapshot) => void>(); + let status: StoreStatus = options.live ? 'connecting' : 'idle'; + let error: string | null = null; + let unsubCache: (() => void) | null = null; + let unsubWs: (() => void) | null = null; + let destroyed = false; + + function readRaw(): unknown { + return cache.get(key)?.data; + } + + function snapshot(): DocumentStoreSnapshot { + const entry = cache.get(key); + const raw = (entry?.data ?? options.initialData ?? null) as TData; + let data: TSelected; + try { + data = select(raw as TData); + } catch { + data = select((options.initialData ?? {}) as TData); + } + return { + data, + raw: entry?.data, + status, + error, + pending: !!entry?.pending, + optimistic: !!entry?.optimistic + }; + } + + function emit() { + const snap = snapshot(); + for (const run of listeners) run(snap); + } + + function seed(data: unknown) { + if (destroyed) return; + // Don't clobber a fresher optimistic/pending entry with older SSR. + const existing = cache.get(key); + if (existing?.optimistic || existing?.pending) { + emit(); + return; + } + cache.set(key, { + data, + updatedAt: Date.now(), + optimistic: false, + pending: false + }); + } + + // Initial seed + if (options.initialData !== undefined) { + seed(options.initialData); + } + + unsubCache = cache.subscribe(key, () => emit()); + + function connect() { + if (destroyed || !options.live) return; + unsubWs?.(); + status = 'connecting'; + error = null; + emit(); + unsubWs = client.subscribe(options.document, { + onNext: (payload) => { + const p = payload as { data?: unknown; errors?: Array<{ message?: string }> }; + if (p?.errors?.length) { + status = 'error'; + error = p.errors[0]?.message ?? 'subscription error'; + emit(); + return; + } + // createClient already write-throughs data into cache; just mark live. + if (p?.data !== undefined) { + status = 'live'; + error = null; + // Ensure write-through even if client was built without cache (defensive) + if (!cache.get(key)?.data && p.data) { + cache.set(key, { + data: p.data, + updatedAt: Date.now(), + optimistic: false, + pending: false + }); + } else { + emit(); + } + } + }, + onError: (e) => { + status = 'error'; + if (e instanceof Event) { + error = 'WebSocket error — is the API running?'; + } else if (Array.isArray(e)) { + error = JSON.stringify(e); + } else { + error = String(e); + } + emit(); + }, + onComplete: () => { + if (status === 'live') { + status = 'connecting'; + emit(); + } + } + }); + } + + if (options.live && typeof window !== 'undefined') { + // Defer to next microtask so callers can attach subscribe first. + queueMicrotask(() => { + if (!destroyed) connect(); + }); + } + + async function refetch() { + const result = await client.request(options.document, variables as never); + if (result.errors?.length) { + error = result.errors[0]?.message ?? 'refetch failed'; + emit(); + return; + } + // request write-through updates cache; emit if needed + error = null; + emit(); + } + + return { + subscribe(run) { + listeners.add(run); + run(snapshot()); + return () => { + listeners.delete(run); + }; + }, + get: snapshot, + document: docStr, + variables, + target(at: string, by: string): CacheTarget { + return { document: docStr, variables, at, by }; + }, + seed, + refetch, + connect, + destroy() { + destroyed = true; + unsubCache?.(); + unsubCache = null; + unsubWs?.(); + unsubWs = null; + listeners.clear(); + } + }; +} diff --git a/tests/e2e-ui/ui/src/lib/gql/index.ts b/tests/e2e-ui/ui/src/lib/gql/index.ts index 7f7032f6..1e12ec39 100644 --- a/tests/e2e-ui/ui/src/lib/gql/index.ts +++ b/tests/e2e-ui/ui/src/lib/gql/index.ts @@ -46,6 +46,17 @@ export type { ReconcileKind, CommandPipelineOptions } from './cache/index.ts'; + +/** Houdini-style document store (prefer over manual cache helpers). */ +export { createDocumentStore } from './document-store.ts'; +export type { + DocumentStore, + DocumentStoreOptions, + DocumentStoreSnapshot, + StoreStatus +} from './document-store.ts'; + +/** Escape-hatch cache helpers — prefer `gql.store` / `gql.live`. */ export { seedQueryCache, readQueryList, diff --git a/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts b/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts index 3ba441a6..d5db71ad 100644 --- a/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts +++ b/tests/e2e-ui/ui/src/lib/gql/use-graphql.ts @@ -1,7 +1,12 @@ /** * Browser binder: page data → GqlAuth + createGraphqlClient (POST /graphql). - * Attaches generated command helpers as `client.commands.*` through the - * command result pipeline when a browser QueryCache is available. + * + * Cache is **transparent** (Houdini-style): + * - `gql.store` / `gql.live` seed + follow the QueryCache automatically + * - `request` / `subscribe` write-through into the cache + * - `gql.commands.*` pipeline patches the same cache keys + * + * Pages should not call seedQueryCache / cache.subscribe by hand. */ import { createGraphqlClient, type GraphqlClient } from './create-client.ts'; import { authFromPageData, type PageGraphqlData } from './auth-from-page.ts'; @@ -12,18 +17,38 @@ import { } from './bind-commands-pipeline.ts'; import { QueryCache } from './cache/query-cache.ts'; import type { Effect } from './cache/ops.ts'; +import { + createDocumentStore, + type DocumentStore, + type DocumentStoreOptions +} from './document-store.ts'; export type { PageGraphqlData } from './auth-from-page.ts'; export { authFromPageData } from './auth-from-page.ts'; -/** Bound HTTP + WS client with pipelined command functions + shared cache. */ +/** Bound HTTP + WS client with pipelined commands + document stores. */ export type AppGraphqlClient = GraphqlClient & { commands: PipelinedBoundCommands; + /** Shared browser query cache (escape hatch; prefer store/live). */ cache: QueryCache; + /** + * Follow a query document in the cache (SSR seed + refetch/optimistic updates). + * Use `$store.data` in templates. + */ + store: , TSelected = TData>( + options: DocumentStoreOptions + ) => DocumentStore; + /** + * Like `store` + automatic GraphQL subscription for the same document. + * Connection status is on `$store.status`. + */ + live: , TSelected = TData>( + options: Omit, 'live'> + ) => DocumentStore; }; export type UseGraphqlOptions = { - /** Override / seed the shared browser cache (default: new QueryCache). */ + /** Override the shared browser cache (default: new QueryCache). */ cache?: QueryCache; /** Per-command default result/reconcile policies. */ policies?: CommandPolicyMap; @@ -32,16 +57,24 @@ export type UseGraphqlOptions = { }; /** - * Client bound to same-origin `/graphql` (Vite proxies to the API in dev). - * Pass a getter so reactive page data is read on each request / subscribe. + * Client bound to same-origin `/graphql`. * - * @example + * @example Chat (cache transparent) * const gql = useGraphql(() => data); - * await gql.commands.todosCreate( - * { todo_id, title }, - * { optimistic: { targets: [...], row }, result: { kind: 'fact' }, reconcile: { kind: 'refetch', document } } - * ); - * gql.subscribe(chat.subscription, { onNext }); + * const lobby = gql.live({ + * document: chat.subscription ?? chat.query, + * initialData: { chat_messages: data.messages }, + * select: (d) => d.chat_messages ?? [], + * }); + * // {$lobby.data} {$lobby.status} + * onDestroy(() => lobby.destroy()); + * + * @example Command with optimistic list patch + * await gql.commands.todosCreate(input, { + * result: { kind: 'fact' }, + * reconcile: { kind: 'refetch', document: list.document }, + * optimistic: { targets: [list.target('todos', 'todo_id')], row }, + * }); */ export function useGraphql( getData: () => PageGraphqlData, @@ -54,9 +87,24 @@ export function useGraphql( cache, writeThrough: true }); + + function store, TSelected = TData>( + storeOpts: DocumentStoreOptions + ): DocumentStore { + return createDocumentStore(client, storeOpts); + } + + function live, TSelected = TData>( + storeOpts: Omit, 'live'> + ): DocumentStore { + return createDocumentStore(client, { ...storeOpts, live: true }); + } + return { ...client, cache, + store, + live, commands: bindCommandsPipeline(client, { cache, policies: options.policies, diff --git a/tests/e2e-ui/ui/src/routes/admin/+page.svelte b/tests/e2e-ui/ui/src/routes/admin/+page.svelte index 6e4de184..97a65eca 100644 --- a/tests/e2e-ui/ui/src/routes/admin/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/admin/+page.svelte @@ -1,31 +1,20 @@ @@ -119,7 +105,7 @@
- {todos.length} + {$list.data.length} notes
@@ -139,7 +125,7 @@

{/if} - {#if todos.length === 0} + {#if $list.data.length === 0}

No notes in the read model yet. Create some as alice/bob on /todos.

{:else}
@@ -154,7 +140,7 @@ - {#each todos as t (t.todo_id)} + {#each $list.data as t (t.todo_id)} {t.owner_id} {t.title} diff --git a/tests/e2e-ui/ui/src/routes/chat/+page.svelte b/tests/e2e-ui/ui/src/routes/chat/+page.svelte index 1b2ee7e2..3d5d4485 100644 --- a/tests/e2e-ui/ui/src/routes/chat/+page.svelte +++ b/tests/e2e-ui/ui/src/routes/chat/+page.svelte @@ -1,29 +1,17 @@ @@ -185,10 +174,9 @@

Field notes

- Tasks for {who}. Co-located - todos.resource query + gql.commands.* pipeline - (optimistic → fact/ack → refetch). Async projectors; list truth lives in the - browser QueryCache. + Tasks for {who}. List via gql.store + ($list.data); writes via gql.commands.* pipeline + (optimistic → fact → refetch). Cache is transparent — no manual seed/sync.

diff --git a/tests/e2e-ui/ui/tests/api-contract.test.mjs b/tests/e2e-ui/ui/tests/api-contract.test.mjs index 493df318..d84d9bc7 100644 --- a/tests/e2e-ui/ui/tests/api-contract.test.mjs +++ b/tests/e2e-ui/ui/tests/api-contract.test.mjs @@ -259,23 +259,22 @@ test('todos: co-located .gql + resource — same query SSR + browser mutations', assert.match(useGql, /createGraphqlClient|\/graphql/); const page = fs.readFileSync(new URL('../src/routes/todos/+page.svelte', import.meta.url), 'utf8'); - assert.match(page, /\$state\(\[\.\.\.\(data\.todos/); - // Command pipeline + QueryCache (not hand-rolled mergeFromServer) - assert.match(page, /seedQueryCache|gql\.cache|QueryCache/); + // Document store (Houdini-style) — cache transparent + assert.match(page, /gql\.store\s*\(/); + assert.match(page, /\$list\.data/); assert.match(page, /optimistic:/); assert.match(page, /result:\s*\{\s*kind:\s*'fact'/); assert.match(page, /reconcile:\s*\{\s*kind:\s*'refetch'/); - assert.doesNotMatch(page, /function mergeFromServer/); + assert.doesNotMatch(page, /function mergeFromServer|seedQueryCache|readQueryList/); assert.match(page, /useGraphql/); assert.match(page, /todos\.resource|todosResource|from '\.\/todos\.resource'/); assert.match(page, /todosResource\.query|todos\.query/); - // Writes: generated command functions through pipeline. assert.match(page, /gql\.commands\.todosCreate/); assert.match(page, /gql\.commands\.todosComplete/); assert.match(page, /gql\.commands\.todosArchive/); assert.doesNotMatch(page, /mutations\.(create|complete|archive)/); assert.doesNotMatch(page, /use:enhance|\?\/create|export const actions/); - assert.match(page, /useGraphql|gql\.request|todosCreate/); + assert.match(page, /useGraphql|list\.refetch|todosCreate/); const server = fs.readFileSync( new URL('../src/routes/todos/+page.server.ts', import.meta.url), @@ -374,8 +373,10 @@ test('chat: co-located .gql + resource — SSR query, WS subscription, browser p assert.match(page, /useGraphql/); assert.doesNotMatch(page, /chat\.mutations\.post|mutations\.post/); assert.match(page, /chat\.subscription|subscription/); - // WS uses bound client — same auth as HTTP (no separate authFromPageData at call site). - assert.match(page, /gql\.subscribe\s*\(/); + // Live document store owns WS + cache (no raw gql.subscribe / authFromPageData). + assert.match(page, /gql\.live\s*\(/); + assert.match(page, /\$lobby\.(data|status)/); + assert.doesNotMatch(page, /gql\.subscribe\s*\(/); assert.doesNotMatch(page, /from '\$lib\/graphql-ws'/); assert.doesNotMatch(page, /authFromPageData/); assert.doesNotMatch(page, /browserGraphql|from '\$lib\/gql\/documents'/); @@ -420,11 +421,13 @@ test('live GraphQL unauthenticated rejected when OIDC stack', { skip: !base }, a } }); -test('useGraphql attaches bindCommandsPipeline as client.commands', () => { +test('useGraphql attaches pipeline commands + store/live helpers', () => { const use = fs.readFileSync(new URL('../src/lib/gql/use-graphql.ts', import.meta.url), 'utf8'); assert.match(use, /bindCommandsPipeline/); assert.match(use, /commands:\s*bindCommandsPipeline/); assert.match(use, /QueryCache/); + assert.match(use, /createDocumentStore|\.store\s*=|\bstore\b/); + assert.match(use, /\blive\b/); assert.match(use, /AppGraphqlClient/); }); diff --git a/tests/e2e-ui/ui/tests/command-client.test.mjs b/tests/e2e-ui/ui/tests/command-client.test.mjs index 99b284f5..efc6b502 100644 --- a/tests/e2e-ui/ui/tests/command-client.test.mjs +++ b/tests/e2e-ui/ui/tests/command-client.test.mjs @@ -91,33 +91,34 @@ test('generated todosCreate uses client.request + COMMAND_DOCS', async () => { assert.match(r.stdout, /command-client-ok/); }); -test('app pages use gql.commands.* pipeline (optimistic + fact/ack + reconcile)', () => { +test('app pages use gql.store/live + commands pipeline (cache transparent)', () => { const todos = fs.readFileSync(pageSvelte, 'utf8'); + assert.match(todos, /gql\.store\s*\(/); + assert.match(todos, /\$list\.data/); assert.match(todos, /gql\.commands\.todosCreate/); assert.match(todos, /gql\.commands\.todosComplete/); assert.match(todos, /gql\.commands\.todosArchive/); assert.match(todos, /result:\s*\{\s*kind:\s*'fact'/); assert.match(todos, /reconcile:\s*\{\s*kind:\s*'refetch'/); - assert.match(todos, /optimistic:/); - assert.match(todos, /QueryCache|gql\.cache|seedQueryCache/); + assert.match(todos, /list\.target\(/); + assert.doesNotMatch(todos, /seedQueryCache|readQueryList|syncFromCache/); assert.doesNotMatch(todos, /from '\$lib\/api\/commands\.generated'/); - assert.doesNotMatch(todos, /url:\s*['"]\/graphql['"]/); - // No hand-rolled pending map / mergeFromServer — pipeline owns latency compensation assert.doesNotMatch(todos, /function mergeFromServer/); - assert.doesNotMatch(todos, /let pending = \$state/); const chat = fs.readFileSync(path.join(uiRoot, 'src/routes/chat/+page.svelte'), 'utf8'); + assert.match(chat, /gql\.live\s*\(/); + assert.match(chat, /\$lobby\.(data|status)/); assert.match(chat, /gql\.commands\.chatMessagesPost/); - assert.match(chat, /optimistic:/); - assert.match(chat, /result:\s*\{\s*kind:\s*'fact'/); + assert.match(chat, /lobby\.target\(/); assert.match(chat, /reconcile:\s*\{\s*kind:\s*'subscription'/); + assert.doesNotMatch(chat, /seedQueryCache|gql\.subscribe\s*\(/); assert.doesNotMatch(chat, /from '\$lib\/api\/commands\.generated'/); const admin = fs.readFileSync(path.join(uiRoot, 'src/routes/admin/+page.svelte'), 'utf8'); + assert.match(admin, /gql\.store\s*\(/); assert.match(admin, /gql\.commands\.todosForceArchive/); - assert.match(admin, /optimistic:/); - assert.match(admin, /reconcile:\s*\{\s*kind:\s*'refetch'/); - assert.doesNotMatch(admin, /from '\$lib\/api\/commands\.generated'/); + assert.match(admin, /list\.target\(/); + assert.doesNotMatch(admin, /seedQueryCache|readQueryList/); }); test('generated mutations are multiline; operations.gql is copy-paste ready', () => { @@ -139,9 +140,10 @@ test('httpUrlToWsUrl maps HTTP GraphQL paths to /graphql/ws', async () => { assert.equal(httpUrlToWsUrl('https://api.example/graphql'), 'wss://api.example/graphql/ws'); }); -test('chat page uses gql.subscribe (bound client), not raw authFromPageData', () => { +test('chat page uses gql.live (not raw subscribe / authFromPageData)', () => { const chat = fs.readFileSync(path.join(uiRoot, 'src/routes/chat/+page.svelte'), 'utf8'); - assert.match(chat, /gql\.subscribe\s*\(/); + assert.match(chat, /gql\.live\s*\(/); + assert.doesNotMatch(chat, /gql\.subscribe\s*\(/); assert.doesNotMatch(chat, /authFromPageData/); assert.doesNotMatch(chat, /from '\$lib\/graphql-ws'/); }); diff --git a/tests/e2e-ui/ui/tests/document-store.test.mjs b/tests/e2e-ui/ui/tests/document-store.test.mjs new file mode 100644 index 00000000..050ac1a3 --- /dev/null +++ b/tests/e2e-ui/ui/tests/document-store.test.mjs @@ -0,0 +1,79 @@ +/** + * Document store: cache is transparent (seed + follow + live write-through). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('createDocumentStore seeds cache and updates when cache changes', () => { + const script = ` + import { QueryCache, cacheKey } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/gql/cache/query-cache.ts')).href + )}; + import { createDocumentStore } from ${JSON.stringify( + pathToFileURL(path.join(uiRoot, 'src/lib/gql/document-store.ts')).href + )}; + + const DOC = 'query Q { items { id } }'; + const cache = new QueryCache(); + const client = { + cache, + async request() { return { data: { items: [{ id: '2' }] } }; }, + subscribe() { return () => {}; } + }; + + const store = createDocumentStore(client, { + document: DOC, + initialData: { items: [{ id: '1' }] }, + select: (d) => d.items + }); + + let latest; + const unsub = store.subscribe((s) => { latest = s; }); + if (!latest || latest.data[0].id !== '1') throw new Error('initial seed failed'); + + cache.set(cacheKey(DOC, {}), { + data: { items: [{ id: '1' }, { id: 'x' }] }, + updatedAt: Date.now() + }); + if (latest.data.length !== 2) throw new Error('cache notify failed: ' + latest.data.length); + + await store.refetch(); + if (latest.data[0].id !== '2') throw new Error('refetch did not update store'); + + unsub(); + store.destroy(); + console.log('document-store-ok'); + `; + + const r = spawnSync( + process.execPath, + ['--experimental-strip-types', '--input-type=module', '-e', script], + { encoding: 'utf8', cwd: uiRoot } + ); + assert.equal(r.status, 0, `stderr=${r.stderr}\nstdout=${r.stdout}`); + assert.match(r.stdout, /document-store-ok/); +}); + +test('pages use gql.store / gql.live — no manual seedQueryCache', () => { + const todos = fs.readFileSync(path.join(uiRoot, 'src/routes/todos/+page.svelte'), 'utf8'); + assert.match(todos, /gql\.store\s*\(/); + assert.match(todos, /\$list\.data/); + assert.doesNotMatch(todos, /seedQueryCache|readQueryList|cache\.subscribe/); + + const chat = fs.readFileSync(path.join(uiRoot, 'src/routes/chat/+page.svelte'), 'utf8'); + assert.match(chat, /gql\.live\s*\(/); + assert.match(chat, /\$lobby\.(data|status)/); + assert.doesNotMatch(chat, /seedQueryCache|readQueryList|syncFromCache/); + assert.doesNotMatch(chat, /gql\.subscribe\s*\(/); + + const admin = fs.readFileSync(path.join(uiRoot, 'src/routes/admin/+page.svelte'), 'utf8'); + assert.match(admin, /gql\.store\s*\(/); + assert.match(admin, /\$list\.data/); + assert.doesNotMatch(admin, /seedQueryCache|readQueryList/); +}); From 7e3f1ef8ca8697faaeccf9840d65cf1a79625fd7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 16 Jul 2026 18:10:57 -0500 Subject: [PATCH 04/20] fix(e2e-ui): document store refetch writes cache without client write-through --- tests/e2e-ui/ui/src/lib/gql/document-store.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/e2e-ui/ui/src/lib/gql/document-store.ts b/tests/e2e-ui/ui/src/lib/gql/document-store.ts index 606e7389..78963f4b 100644 --- a/tests/e2e-ui/ui/src/lib/gql/document-store.ts +++ b/tests/e2e-ui/ui/src/lib/gql/document-store.ts @@ -224,7 +224,15 @@ export function createDocumentStore, TSelected = emit(); return; } - // request write-through updates cache; emit if needed + // Prefer client write-through; still write here so plain mock clients work. + if (result.data !== undefined && result.data !== null) { + cache.set(key, { + data: result.data, + updatedAt: Date.now(), + optimistic: false, + pending: false + }); + } error = null; emit(); } From c2c6db484b1e9ea88e958dec00a89fb0a9e08323 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 16 Jul 2026 18:14:21 -0500 Subject: [PATCH 05/20] fix(e2e-ui): Svelte 5 syntax hygiene across the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename command UI helpers to fx so they never shadow \$effect - Type layout/Auth children as Snippet; use \$app/state consistently - Convert +error onMount → \$effect (browser-guarded) - Fix chat PageData (drop invalid userId); allowImportingTsExtensions - Clear empty CSS ruleset; CommandClient cast for pipeline binder svelte-check: 0 errors / 0 warnings --- .../lib/components/shared/header/Auth.svelte | 11 +++++++-- .../src/lib/components/shared/ui/Card.svelte | 4 ---- .../ui/src/lib/gql/bind-commands-pipeline.ts | 6 ++++- tests/e2e-ui/ui/src/lib/gql/cache/index.ts | 2 +- tests/e2e-ui/ui/src/lib/gql/cache/ops.ts | 6 ++++- tests/e2e-ui/ui/src/lib/gql/cache/pipeline.ts | 6 ++--- tests/e2e-ui/ui/src/lib/gql/document-store.ts | 20 +++++++++------- tests/e2e-ui/ui/src/lib/gql/index.ts | 2 +- tests/e2e-ui/ui/src/lib/gql/use-graphql.ts | 3 ++- tests/e2e-ui/ui/src/routes/+error.svelte | 24 +++++++++++-------- tests/e2e-ui/ui/src/routes/+layout.svelte | 3 ++- tests/e2e-ui/ui/src/routes/admin/+page.svelte | 4 ++-- tests/e2e-ui/ui/src/routes/chat/+page.svelte | 6 ++--- tests/e2e-ui/ui/src/routes/todos/+page.svelte | 8 +++---- tests/e2e-ui/ui/tests/run-pipeline-unit.mjs | 8 +++---- tests/e2e-ui/ui/tsconfig.json | 5 +++- 16 files changed, 70 insertions(+), 48 deletions(-) diff --git a/tests/e2e-ui/ui/src/lib/components/shared/header/Auth.svelte b/tests/e2e-ui/ui/src/lib/components/shared/header/Auth.svelte index 324f4f6e..184c6def 100644 --- a/tests/e2e-ui/ui/src/lib/components/shared/header/Auth.svelte +++ b/tests/e2e-ui/ui/src/lib/components/shared/header/Auth.svelte @@ -1,8 +1,15 @@ {#if isAuthenticated} - {:else} - + Sign in {/if} {#if children} diff --git a/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte b/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte index 5fa81c11..30259d14 100644 --- a/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte +++ b/tests/e2e-ui/ui/src/lib/components/shared/header/Navbar.svelte @@ -94,11 +94,11 @@