diff --git a/ROADMAP.md b/ROADMAP.md index b0dae16..1b43fb8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,9 +69,11 @@ selection controls expose those fields for ordinary objects, show provenance for a single selection, and keep metadata through conversion, grouping, duplication, and SVG export where the format has a representation. -Bindings still need an optional relation type so people and agents can query -typed incoming and outgoing relationships without inferring meaning from -coordinates or visual style. +Bindings carry an optional relation type for semantic connections. Rust, the +CLI, and the editor projection preserve the type and shape references. Queries +can select typed bindings and filter their incoming or outgoing shape, while +visual routing validation remains separate from relationship-reference +validation. ### Layout operations diff --git a/TODO.md b/TODO.md index d651365..fb65e5a 100644 --- a/TODO.md +++ b/TODO.md @@ -82,13 +82,13 @@ the CLI, and agents. ### Semantic connections -- [ ] Add an optional relation type to Rust binding records and generated +- [x] Add an optional relation type to Rust binding records and generated bindings -- [ ] Query incoming, outgoing, and typed relationships in Rust without +- [x] Query incoming, outgoing, and typed relationships in Rust without inferring them from coordinates -- [ ] Validate dangling or invalid relationship references separately from +- [x] Validate dangling or invalid relationship references separately from visual routing -- [ ] Add model, CLI, and editor integration tests for typed relationships +- [x] Add model, CLI, and editor integration tests for typed relationships ## Layout operations diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index 24eae52..a273528 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -487,6 +487,9 @@ describe('Rust-backed desktop session repository', () => { name: null, role: null, tag: null, + relation_type: null, + incoming_to: null, + outgoing_from: null, shape_kind: null, page_id: null, layer_id: null, diff --git a/apps/web/src/content/docs/internals.md b/apps/web/src/content/docs/internals.md index b7f3599..029af10 100644 --- a/apps/web/src/content/docs/internals.md +++ b/apps/web/src/content/docs/internals.md @@ -76,7 +76,9 @@ editor model. `inkfinite-core` owns the native document session. Its shapes have a registry `kind`, a parent relation, a parent-relative transform, ordered container children, kind-specific properties, semantic metadata, common style, and a record version. Semantic metadata includes optional names, -roles, descriptions, sources, links, tags, custom JSON fields, and provenance. +roles, descriptions, sources, links, tags, custom JSON fields, and provenance. Binding records can +also carry an optional relation type with source and target shape IDs; the query API filters those +records by type and direction. Pages own ordered layers; layers own ordered root shapes. Containers can own nested shapes and optionally apply free, stack, or grid layout. diff --git a/apps/web/src/content/docs/reference/cli.md b/apps/web/src/content/docs/reference/cli.md index e93d5f3..28cd892 100644 --- a/apps/web/src/content/docs/reference/cli.md +++ b/apps/web/src/content/docs/reference/cli.md @@ -124,6 +124,18 @@ To hand the edit to another process without changing the document, add and refuses to overwrite an existing output file. Run `shape kinds` or `shape describe KIND` to discover shape contracts. +Use `connect --kind relation --relation-type TYPE` for a semantic connection that does not +participate in arrow routing. Query its direction or type with `--outgoing-from`, `--incoming-to`, +and `--relation-type`: + +```sh +inkfinite connect architecture.inkfinite \ + --source shape:service --target shape:database \ + --kind relation --relation-type depends_on --json +inkfinite query architecture.inkfinite \ + --outgoing-from shape:service --relation-type depends_on --detail --json +``` + ## Live mode With the desktop app running, use `app status`, `app context`, `app inspect`, and `app query` for diff --git a/apps/web/src/content/docs/reference/file-format.md b/apps/web/src/content/docs/reference/file-format.md index 2917d83..15a1c5b 100644 --- a/apps/web/src/content/docs/reference/file-format.md +++ b/apps/web/src/content/docs/reference/file-format.md @@ -28,7 +28,7 @@ pub struct DocumentSnapshot { } ``` -The `format` field is `"inkfinite.document"` and `format_version` is currently `2`. The document contains normalized pages, layers, shapes, bindings, and assets. Pages own ordered layers; layers own ordered root shapes; containers own their ordered child shapes. Container child order drives frame presentation and SVG export. Card fields are stored in the container's semantic metadata while title and body remain ordinary text and Markdown children. IDs remain stable across saves and replicas. +The `format` field is `"inkfinite.document"` and `format_version` is currently `2`. The document contains normalized pages, layers, shapes, bindings, and assets. Binding records may include a `relation_type` alongside their source and target shape IDs. Pages own ordered layers; layers own ordered root shapes; containers own their ordered child shapes. Container child order drives frame presentation and SVG export. Card fields are stored in the container's semantic metadata while title and body remain ordinary text and Markdown children. IDs remain stable across saves and replicas. The CLI can print a deterministic JSON projection for inspection and CI. That projection is not a second file format and cannot replace the Automerge history or causal heads stored in the canonical file. diff --git a/crates/inkfinite-cli/src/cli/app.rs b/crates/inkfinite-cli/src/cli/app.rs index c8835f8..111ec4d 100644 --- a/crates/inkfinite-cli/src/cli/app.rs +++ b/crates/inkfinite-cli/src/cli/app.rs @@ -191,6 +191,9 @@ fn query(args: AppQueryArgs, json_output: bool, stdout: &mut dyn Write) -> Resul name: args.name, role: args.role, tag: args.tag, + relation_type: args.relation_type, + incoming_to: args.incoming_to.map(inkfinite_core::ShapeId::from), + outgoing_from: args.outgoing_from.map(inkfinite_core::ShapeId::from), shape_kind: args.shape_kind, page_id: args.page.map(PageId::from), layer_id: args.layer.map(LayerId::from), diff --git a/crates/inkfinite-cli/src/cli/args.rs b/crates/inkfinite-cli/src/cli/args.rs index 6c86e3c..39988bb 100644 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -392,9 +392,12 @@ pub struct ConnectArgs { /// Select the unique target shape by semantic role. #[arg(long, conflicts_with = "target")] pub target_role: Option, - /// Binding registry key. + /// Binding registry key. Use `relation` for a semantic-only connection. #[arg(long, default_value = "arrow")] pub kind: String, + /// Optional semantic relationship type, such as `depends_on`. + #[arg(long)] + pub relation_type: Option, /// Named handle on the source shape. #[arg(long, default_value = "end")] pub source_handle: String, @@ -526,6 +529,15 @@ pub struct QueryArgs { /// Match one exact semantic tag. #[arg(long)] pub tag: Option, + /// Match one exact semantic relationship type. + #[arg(long)] + pub relation_type: Option, + /// Restrict relationship records to those incoming to this shape. + #[arg(long)] + pub incoming_to: Option, + /// Restrict relationship records to those outgoing from this shape. + #[arg(long)] + pub outgoing_from: Option, /// Match an exact shape registry key. #[arg(long = "kind")] pub shape_kind: Option, @@ -676,6 +688,15 @@ pub struct AppQueryArgs { /// Match one exact semantic tag. #[arg(long)] pub tag: Option, + /// Match one exact semantic relationship type. + #[arg(long)] + pub relation_type: Option, + /// Restrict relationship records to those incoming to this shape. + #[arg(long)] + pub incoming_to: Option, + /// Restrict relationship records to those outgoing from this shape. + #[arg(long)] + pub outgoing_from: Option, /// Match an exact shape registry key. #[arg(long = "kind")] pub shape_kind: Option, diff --git a/crates/inkfinite-cli/src/cli/connect.rs b/crates/inkfinite-cli/src/cli/connect.rs index 6f8d818..b8abf3a 100644 --- a/crates/inkfinite-cli/src/cli/connect.rs +++ b/crates/inkfinite-cli/src/cli/connect.rs @@ -55,6 +55,7 @@ pub fn connect_shapes(args: ConnectArgs, json_output: bool, stdout: &mut dyn Wri target_shape_id: target_shape, source_handle: args.source_handle, anchor, + relation_type: args.relation_type, version: RecordVersion(1), }; let transaction = target.transaction( diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs index a2a7b3b..159fdf3 100644 --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -60,7 +60,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "mutation_commands": { "apply": ["--transaction", "--dry-run"], "import svg": ["--input", "--page", "--layer", "--dry-run", "--transaction-out", "--app"], - "connect": ["--binding-id", "--source", "--source-role", "--target", "--target-role", "--dry-run", "--transaction-out", "--app"], + "connect": ["--binding-id", "--source", "--source-role", "--target", "--target-role", "--kind", "--relation-type", "--dry-run", "--transaction-out", "--app"], "layout": ["align", "distribute"], "shape": ["create", "patch", "delete", "kinds", "describe"], "semantic_placement": ["inside", "below", "right-of", "align-left", "align-center", "align-right", "align-top", "align-middle", "align-bottom"], @@ -68,7 +68,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( }, "path_format": "forward_slashes", "protocol": { "id": PROTOCOL_ID, "version": PROTOCOL_VERSION }, - "query_filters": ["id", "name", "role", "tag", "kind", "page", "layer", "parent", "bounds"], + "query_filters": ["id", "name", "role", "tag", "relation-type", "incoming-to", "outgoing-from", "kind", "page", "layer", "parent", "bounds"], "query_options": ["detail", "limit"], "render_filters": ["page", "layer", "shape", "role", "region"], "schemas": ["document", "transaction", "protocol", "protocol-request", "protocol-response", "protocol-error"], diff --git a/crates/inkfinite-cli/src/cli/document.rs b/crates/inkfinite-cli/src/cli/document.rs index 5b66376..b5ad6bf 100644 --- a/crates/inkfinite-cli/src/cli/document.rs +++ b/crates/inkfinite-cli/src/cli/document.rs @@ -78,6 +78,9 @@ pub fn query_document(args: QueryArgs, json_output: bool, stdout: &mut dyn Write name: args.name, role: args.role, tag: args.tag, + relation_type: args.relation_type, + incoming_to: args.incoming_to.map(inkfinite_core::ShapeId::from), + outgoing_from: args.outgoing_from.map(inkfinite_core::ShapeId::from), shape_kind: args.shape_kind, page_id: args.page.map(PageId::from), layer_id: args.layer.map(LayerId::from), diff --git a/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs index cbf7c60..005058b 100644 --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -699,6 +699,41 @@ fn structured_commands_use_semantic_selectors_for_layout_and_connections() { ]); assert_success(&connected); assert_eq!(parse_stdout(&connected)["created"][0]["kind"], "binding"); + + let relation = run([ + "connect", + path(&document_path), + "--binding-id", + "binding:service-database", + "--source", + "shape:one", + "--target", + "shape:two", + "--kind", + "relation", + "--relation-type", + "depends_on", + "--json", + ]); + assert_success(&relation); + + let outgoing = run([ + "query", + path(&document_path), + "--outgoing-from", + "shape:one", + "--relation-type", + "depends_on", + "--detail", + "--json", + ]); + assert_success(&outgoing); + let result = parse_stdout(&outgoing); + assert_eq!( + result["records"], + serde_json::json!([{ "kind": "binding", "id": "binding:service-database" }]) + ); + assert_eq!(result["details"][0]["record"]["relation_type"], "depends_on"); } #[test] diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs index b7fb1dd..cefc2e8 100644 --- a/crates/inkfinite-core/src/editor.rs +++ b/crates/inkfinite-core/src/editor.rs @@ -168,6 +168,8 @@ pub struct EditorBinding { pub handle: String, /// Target anchor. pub anchor: BindingAnchor, + /// Optional semantic relationship type. + pub relation_type: Option, } /// Ordering information accompanying an editor projection. @@ -443,6 +445,7 @@ pub fn project_editor(snapshot: &DocumentSnapshot) -> EditorProjection { to_shape_id: binding.target_shape_id.clone(), handle: binding.source_handle.clone(), anchor: binding.anchor, + relation_type: binding.relation_type.clone(), }, ) }) @@ -1350,11 +1353,18 @@ mod tests { target_shape_id: ShapeId::from("shape:child"), source_handle: "end".into(), anchor: BindingAnchor::Center, + relation_type: Some("depends_on".into()), version: RecordVersion(1), }; snapshot.document.bindings.insert(binding.id.clone(), binding); let projection = project_editor(&snapshot); assert_eq!(projection.order.page_ids, snapshot.document.page_ids); assert_eq!(projection.bindings[&BindingId::from("binding:one")].handle, "end"); + assert_eq!( + projection.bindings[&BindingId::from("binding:one")] + .relation_type + .as_deref(), + Some("depends_on") + ); } } diff --git a/crates/inkfinite-core/src/engine/operations.rs b/crates/inkfinite-core/src/engine/operations.rs index 37f6036..6d7334c 100644 --- a/crates/inkfinite-core/src/engine/operations.rs +++ b/crates/inkfinite-core/src/engine/operations.rs @@ -8,7 +8,7 @@ use super::hierarchy::{ insert_shape_child, is_descendant, layer, layer_mut, move_anchored, next_version, page, page_mut, remove_shape_child, shape, shape_mut, shape_siblings, }; -use super::validation::ensure_binding_endpoints; +use super::validation::{ensure_binding_endpoints, ensure_relationship_reference}; use super::{ AssetId, AssetPatch, BTreeMap, BTreeSet, Document, EngineError, LayerContentsDisposition, LayerId, LayerPatch, LayoutAxis, Operation, PageId, RecordVersion, ShapeAlignment, ShapeId, ShapeParent, ShapePatch, ShapeProperties, @@ -106,6 +106,7 @@ pub fn apply_operation(document: &mut Document, operation: &Operation) -> Result Operation::CreateBinding { binding } => { ensure_absent(document.bindings.contains_key(&binding.id), "binding", &binding.id)?; ensure_version_one(binding.version, "new binding")?; + ensure_relationship_reference(document, binding)?; ensure_binding_endpoints(document, binding)?; document.bindings.insert(binding.id.clone(), binding.clone()); Ok(vec![Operation::DeleteBinding { diff --git a/crates/inkfinite-core/src/engine/query.rs b/crates/inkfinite-core/src/engine/query.rs index 9e971d4..9d274fd 100644 --- a/crates/inkfinite-core/src/engine/query.rs +++ b/crates/inkfinite-core/src/engine/query.rs @@ -7,6 +7,8 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult let document = &snapshot.document; let mut records = Vec::new(); let mut bounds = BTreeMap::new(); + let relationship_query = + query.relation_type.is_some() || query.incoming_to.is_some() || query.outgoing_from.is_some(); for page in document.pages.values() { if matches_common(query, page.id.as_str(), Some(&page.name)) && query.role.is_none() @@ -15,6 +17,7 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult && query.layer_id.is_none() && query.parent_id.is_none() && query.bounds.is_none() + && !relationship_query { records.push(RecordId::Page(page.id.clone())); } @@ -28,6 +31,7 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult && query.layer_id.as_ref().is_none_or(|id| id == &layer.id) && query.parent_id.is_none() && query.bounds.is_none() + && !relationship_query { records.push(RecordId::Layer(layer.id.clone())); } @@ -57,7 +61,8 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult && query .bounds .as_ref() - .is_none_or(|filter| intersects(&shape_bounds, filter)); + .is_none_or(|filter| intersects(&shape_bounds, filter)) + && !relationship_query; if matches { records.push(RecordId::Shape(shape.id.clone())); bounds.insert(shape.id.clone(), shape_bounds); @@ -72,6 +77,18 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult && query.layer_id.is_none() && query.parent_id.is_none() && query.bounds.is_none() + && query + .relation_type + .as_ref() + .is_none_or(|relation_type| binding.relation_type.as_ref() == Some(relation_type)) + && query + .incoming_to + .as_ref() + .is_none_or(|shape_id| &binding.target_shape_id == shape_id) + && query + .outgoing_from + .as_ref() + .is_none_or(|shape_id| &binding.source_shape_id == shape_id) { records.push(RecordId::Binding(binding.id.clone())); } @@ -85,6 +102,7 @@ pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult && query.layer_id.is_none() && query.parent_id.is_none() && query.bounds.is_none() + && !relationship_query { records.push(RecordId::Asset(asset.id.clone())); } diff --git a/crates/inkfinite-core/src/engine/tests.rs b/crates/inkfinite-core/src/engine/tests.rs index 52d2203..d47b02d 100644 --- a/crates/inkfinite-core/src/engine/tests.rs +++ b/crates/inkfinite-core/src/engine/tests.rs @@ -614,6 +614,73 @@ fn permissions_preconditions_and_final_invariants_reject_without_mutation() { assert_eq!(engine.snapshot().unwrap(), before); } +#[test] +fn typed_relationships_are_queryable_by_direction_and_type() { + let mut engine = engine(); + let binding_id = BindingId::from("binding:depends-on"); + let relation = BindingRecord { + id: binding_id.clone(), + kind: BindingKind::from("relation"), + source_shape_id: ShapeId::from("shape:a"), + target_shape_id: ShapeId::from("shape:b"), + source_handle: "end".into(), + anchor: BindingAnchor::Center, + relation_type: Some("depends_on".into()), + version: RecordVersion(1), + }; + let create = transaction( + &mut engine, + "actor:local", + "create-typed-relationship", + vec![Operation::CreateBinding { binding: relation }], + ); + engine.commit(create).unwrap(); + + let outgoing = engine + .query(&Query { + outgoing_from: Some(ShapeId::from("shape:a")), + relation_type: Some("depends_on".into()), + ..Query::default() + }) + .unwrap(); + assert_eq!( + outgoing.records, + vec![crate::proto::RecordId::Binding(binding_id.clone())] + ); + + let incoming = engine + .query(&Query { incoming_to: Some(ShapeId::from("shape:b")), include_records: true, ..Query::default() }) + .unwrap(); + assert_eq!(incoming.records, vec![crate::proto::RecordId::Binding(binding_id)]); + assert!( + matches!(incoming.details.first(), Some(crate::proto::QueryRecord::Binding(binding)) if binding.relation_type.as_deref() == Some("depends_on")) + ); +} + +#[test] +fn relationship_types_reject_empty_values() { + let mut engine = engine(); + let relation = BindingRecord { + id: BindingId::from("binding:invalid-relation"), + kind: BindingKind::from("relation"), + source_shape_id: ShapeId::from("shape:a"), + target_shape_id: ShapeId::from("shape:b"), + source_handle: "end".into(), + anchor: BindingAnchor::Center, + relation_type: Some(" ".into()), + version: RecordVersion(1), + }; + let create = transaction( + &mut engine, + "actor:local", + "create-invalid-relationship", + vec![Operation::CreateBinding { binding: relation }], + ); + assert!( + matches!(engine.commit(create), Err(EngineError::Schema(message)) if message.contains("empty relationship type")) + ); +} + #[test] fn queries_bounds_alignment_and_distribution_share_the_transaction_engine() { let mut engine = engine(); @@ -689,6 +756,7 @@ fn remote_changes_are_repaired_on_a_fork_before_adoption() { target_shape_id: ShapeId::from("shape:missing"), source_handle: "end".into(), anchor: BindingAnchor::Center, + relation_type: None, version: RecordVersion(1), }, ); @@ -855,6 +923,7 @@ fn offline_list_text_delete_and_reparent_edits_converge() { target_shape_id: ShapeId::from("shape:b"), source_handle: "end".into(), anchor: BindingAnchor::Center, + relation_type: None, version: RecordVersion(1), }, ); diff --git a/crates/inkfinite-core/src/engine/validation.rs b/crates/inkfinite-core/src/engine/validation.rs index 498c74b..fffb617 100644 --- a/crates/inkfinite-core/src/engine/validation.rs +++ b/crates/inkfinite-core/src/engine/validation.rs @@ -94,6 +94,7 @@ pub fn validate_document(document: &Document) -> Result<(), EngineError> { return Err(EngineError::Invariant("one or more shapes are unlisted".into())); } for binding in document.bindings.values() { + ensure_relationship_reference(document, binding)?; ensure_binding_endpoints(document, binding)?; } Ok(()) @@ -221,6 +222,43 @@ pub fn ensure_acyclic(document: &Document, start: &ShapeId) -> Result<(), Engine Ok(()) } +/// Validates the optional semantic relationship attached to a binding. +/// +/// Relationship references use the same stable shape IDs as visual bindings, +/// but their type is checked independently so a semantic connection does not +/// rely on routing fields or coordinates. +/// +/// # Errors +/// +/// Returns an error when a relationship type is empty or one of its shape +/// references is missing. +pub fn ensure_relationship_reference(document: &Document, binding: &crate::BindingRecord) -> Result<(), EngineError> { + if binding.relation_type.is_none() && binding.kind.as_str() != "relation" { + return Ok(()); + } + let relation_type = binding.relation_type.as_deref(); + if relation_type.is_some_and(|value| value.trim().is_empty()) { + return Err(EngineError::Schema(format!( + "binding {} has an empty relationship type", + binding.id + ))); + } + if !document.shapes.contains_key(&binding.source_shape_id) { + return Err(EngineError::Invariant(format!( + "relationship {} references missing source shape {}", + binding.id, binding.source_shape_id + ))); + } + if !document.shapes.contains_key(&binding.target_shape_id) { + return Err(EngineError::Invariant(format!( + "relationship {} references missing target shape {}", + binding.id, binding.target_shape_id + ))); + } + Ok(()) +} + +/// Validates the shape references used by visual binding routing. pub fn ensure_binding_endpoints(document: &Document, binding: &crate::BindingRecord) -> Result<(), EngineError> { if !document.shapes.contains_key(&binding.source_shape_id) || !document.shapes.contains_key(&binding.target_shape_id) diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index 7384520..beedf2f 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -943,6 +943,9 @@ pub struct BindingRecord { pub source_handle: String, /// Attachment point on the target. pub anchor: BindingAnchor, + /// Optional semantic relationship type, such as `depends_on`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relation_type: Option, /// Version used by optimistic operation preconditions. pub version: RecordVersion, } diff --git a/crates/inkfinite-core/src/proto/mod.rs b/crates/inkfinite-core/src/proto/mod.rs index 5d034ce..24747ed 100644 --- a/crates/inkfinite-core/src/proto/mod.rs +++ b/crates/inkfinite-core/src/proto/mod.rs @@ -423,6 +423,12 @@ pub struct Query { pub role: Option, /// Match one exact tag. pub tag: Option, + /// Match one exact semantic relationship type. + pub relation_type: Option, + /// Restrict relationship records to those incoming to this shape. + pub incoming_to: Option, + /// Restrict relationship records to those outgoing from this shape. + pub outgoing_from: Option, /// Match one exact shape registry key. pub shape_kind: Option, /// Restrict the query to one page. diff --git a/crates/inkfinite-core/src/render/tests.rs b/crates/inkfinite-core/src/render/tests.rs index 7320de8..bf9157d 100644 --- a/crates/inkfinite-core/src/render/tests.rs +++ b/crates/inkfinite-core/src/render/tests.rs @@ -441,6 +441,7 @@ fn fixture_document() -> Document { target_shape_id: ShapeId::from("shape:stencil-process"), source_handle: "start".into(), anchor: BindingAnchor::Edge { x: 1.0, y: 0.0 }, + relation_type: None, version: RecordVersion(1), }, ), @@ -453,6 +454,7 @@ fn fixture_document() -> Document { target_shape_id: ShapeId::from("shape:ellipse"), source_handle: "end".into(), anchor: BindingAnchor::Center, + relation_type: None, version: RecordVersion(1), }, ), diff --git a/crates/inkfinite-mcp/src/lib.rs b/crates/inkfinite-mcp/src/lib.rs index c25381e..880a887 100644 --- a/crates/inkfinite-mcp/src/lib.rs +++ b/crates/inkfinite-mcp/src/lib.rs @@ -61,6 +61,12 @@ pub struct QueryRecordsParams { pub role: Option, /// Match one exact semantic tag. pub tag: Option, + /// Match one exact semantic relationship type. + pub relation_type: Option, + /// Restrict relationship records to those incoming to this shape. + pub incoming_to: Option, + /// Restrict relationship records to those outgoing from this shape. + pub outgoing_from: Option, /// Match one exact built-in shape kind. pub shape_kind: Option, /// Restrict results to one page. @@ -488,6 +494,9 @@ impl InkfiniteMcp { name: params.name, role: params.role, tag: params.tag, + relation_type: params.relation_type, + incoming_to: params.incoming_to.map(Into::into), + outgoing_from: params.outgoing_from.map(Into::into), shape_kind: params.shape_kind, page_id: params.page_id.map(Into::into), layer_id: params.layer_id.map(Into::into), diff --git a/packages/bindings/src/editor.ts b/packages/bindings/src/editor.ts index 239adfc..0cc7dbf 100644 --- a/packages/bindings/src/editor.ts +++ b/packages/bindings/src/editor.ts @@ -249,6 +249,10 @@ export type EditorBinding = { * Target anchor. */ anchor: BindingAnchor; + /** + * Optional semantic relationship type. + */ + relation_type: string | null; }; /** diff --git a/packages/bindings/src/model.ts b/packages/bindings/src/model.ts index c9025db..9ef1d5b 100644 --- a/packages/bindings/src/model.ts +++ b/packages/bindings/src/model.ts @@ -544,6 +544,10 @@ export type BindingRecord = { * Attachment point on the target. */ anchor: BindingAnchor; + /** + * Optional semantic relationship type, such as `depends_on`. + */ + relation_type?: string | null; /** * Version used by optimistic operation preconditions. */ diff --git a/packages/bindings/src/transaction.ts b/packages/bindings/src/transaction.ts index 9fade2b..3268497 100644 --- a/packages/bindings/src/transaction.ts +++ b/packages/bindings/src/transaction.ts @@ -584,6 +584,18 @@ export type Query = { * Match one exact tag. */ tag: string | null; + /** + * Match one exact semantic relationship type. + */ + relation_type: string | null; + /** + * Restrict relationship records to those incoming to this shape. + */ + incoming_to: ShapeId | null; + /** + * Restrict relationship records to those outgoing from this shape. + */ + outgoing_from: ShapeId | null; /** * Match one exact shape registry key. */ diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index ff5b58b..da885fd 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -405,7 +405,10 @@ export const ShapeRecord = { ? { ...shape.metadata, tags: [...shape.metadata.tags], - customMetadata: JSON.parse(JSON.stringify(shape.metadata.customMetadata)) as Record, + customMetadata: JSON.parse(JSON.stringify(shape.metadata.customMetadata)) as Record< + string, + unknown + >, ...(shape.metadata.provenance ? { provenance: { ...shape.metadata.provenance } } : {}) } : undefined; @@ -483,7 +486,7 @@ export const ShapeRecord = { } }; -export type BindingType = 'arrow-end'; +export type BindingType = 'arrow-end' | 'relation'; export type BindingHandle = 'start' | 'end'; /** @@ -500,6 +503,8 @@ export type BindingRecord = { toShapeId: string; handle: BindingHandle; anchor: BindingAnchor; + /** Optional semantic relationship type, such as `depends_on`. */ + relationType?: string; }; export const BindingRecord = { @@ -511,12 +516,34 @@ export const BindingRecord = { toShapeId: string, handle: BindingHandle, anchor?: BindingAnchor, - id?: string + id?: string, + relationType?: string ): BindingRecord { if (!anchor) { anchor = { kind: 'center' }; } - return { id: id ?? createId('binding'), type: 'arrow-end', fromShapeId, toShapeId, handle, anchor }; + return { + id: id ?? createId('binding'), + type: 'arrow-end', + fromShapeId, + toShapeId, + handle, + anchor, + ...(relationType === undefined ? {} : { relationType }) + }; + }, + + /** Creates a typed relationship that does not participate in arrow routing. */ + createRelation(fromShapeId: string, toShapeId: string, relationType: string, id?: string): BindingRecord { + return { + id: id ?? createId('binding'), + type: 'relation', + fromShapeId, + toShapeId, + handle: 'end', + anchor: { kind: 'center' }, + relationType + }; }, /** @@ -872,22 +899,29 @@ export function validateDoc(document: Document): ValidationResult { errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`); } + const semanticRelationship = binding.type === 'relation' || binding.relationType !== undefined; + const visualBinding = binding.type !== 'relation'; + const recordLabel = semanticRelationship ? 'Relationship' : 'Binding'; const fromShape = document.shapes[binding.fromShapeId]; if (!fromShape) { - errors.push(`Binding '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); - } else if (fromShape.type !== 'arrow') { + errors.push(`${recordLabel} '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); + } else if (visualBinding && fromShape.type !== 'arrow') { errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`); } if (!document.shapes[binding.toShapeId]) { - errors.push(`Binding '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); + errors.push(`${recordLabel} '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); + } + + if (binding.relationType !== undefined && binding.relationType.trim() === '') { + errors.push(`Relationship '${bindingId}' has an empty relation type`); } - if (binding.handle !== 'start' && binding.handle !== 'end') { + if (visualBinding && binding.handle !== 'start' && binding.handle !== 'end') { errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); } - if (binding.anchor.kind === 'edge') { + if (visualBinding && binding.anchor.kind === 'edge') { if (binding.anchor.nx < -1 || binding.anchor.nx > 1) { errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`); } diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index bf104e8..6b7f717 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -101,6 +101,7 @@ export function toCanonicalDocumentSnapshot( binding.anchor.kind === 'center' ? { kind: 'center' } : { kind: 'edge', x: binding.anchor.nx, y: binding.anchor.ny }, + relation_type: binding.relationType ?? null, version: 1 }; } @@ -193,7 +194,10 @@ export function fromEditorProjection(projection: EditorProjection, snapshot?: Na anchor: binding.anchor.kind === 'center' ? { kind: 'center' } - : { kind: 'edge', nx: binding.anchor.x, ny: binding.anchor.y } + : { kind: 'edge', nx: binding.anchor.x, ny: binding.anchor.y }, + ...(binding.relation_type === null || binding.relation_type === undefined + ? {} + : { relationType: binding.relation_type }) }; } const assets = Object.fromEntries( @@ -278,7 +282,10 @@ export function fromCanonicalDocumentSnapshot(snapshot: NativeDocumentSnapshot): anchor: binding.anchor.kind === 'center' ? { kind: 'center' } - : { kind: 'edge', nx: binding.anchor.x, ny: binding.anchor.y } + : { kind: 'edge', nx: binding.anchor.x, ny: binding.anchor.y }, + ...(binding.relation_type === null || binding.relation_type === undefined + ? {} + : { relationType: binding.relation_type }) }; } @@ -874,6 +881,7 @@ function nativeBinding(binding: import('../model').BindingRecord): NativeBinding binding.anchor.kind === 'center' ? { kind: 'center' } : { kind: 'edge', x: binding.anchor.nx, y: binding.anchor.ny }, + relation_type: binding.relationType ?? null, version: 1 }; } diff --git a/packages/core/tests/canonical.test.ts b/packages/core/tests/canonical.test.ts index bb21cd0..04e0b0c 100644 --- a/packages/core/tests/canonical.test.ts +++ b/packages/core/tests/canonical.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { createEditorReconciliationRequest, toCanonicalDocumentSnapshot } from '../src/persistence/canonical'; +import { + createEditorReconciliationRequest, + fromCanonicalDocumentSnapshot, + toCanonicalDocumentSnapshot +} from '../src/persistence/canonical'; import { contentObjectToCard } from '../src/cards'; -import { LayerRecord, PageRecord, ShapeRecord, type Document, type PathProps } from '../src/model'; +import { BindingRecord, LayerRecord, PageRecord, ShapeRecord, type Document, type PathProps } from '../src/model'; describe('toCanonicalDocumentSnapshot', () => { it('projects browser shapes into the canonical renderer input', () => { @@ -36,6 +40,42 @@ describe('toCanonicalDocumentSnapshot', () => { }); }); + it('round-trips typed relationships through the native projection', () => { + const page = PageRecord.create('Page 1', 'page:one'); + const source = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 40, h: 20, fill: 'red', stroke: 'none', radius: 0 }, + 'shape:source' + ); + const target = ShapeRecord.createRect( + page.id, + 100, + 0, + { w: 40, h: 20, fill: 'blue', stroke: 'none', radius: 0 }, + 'shape:target' + ); + page.shapeIds = [source.id, target.id]; + const relation = BindingRecord.createRelation(source.id, target.id, 'depends_on', 'binding:depends-on'); + const document: Document = { + pages: { [page.id]: page }, + shapes: { [source.id]: source, [target.id]: target }, + bindings: { [relation.id]: relation } + }; + + const snapshot = toCanonicalDocumentSnapshot(document, { documentId: 'document:relation' }); + expect(snapshot.document.bindings[relation.id]).toMatchObject({ + kind: 'relation', + relation_type: 'depends_on', + source_shape_id: source.id, + target_shape_id: target.id + }); + + const roundTripped = fromCanonicalDocumentSnapshot(snapshot); + expect(roundTripped.bindings[relation.id]).toMatchObject(relation); + }); + it('persists card metadata and child ordering in the native container', () => { const page = PageRecord.create('Page 1', 'page:one'); const cardShapes = contentObjectToCard( diff --git a/packages/core/tests/model.test.ts b/packages/core/tests/model.test.ts index 0e25000..0d77a51 100644 --- a/packages/core/tests/model.test.ts +++ b/packages/core/tests/model.test.ts @@ -458,6 +458,18 @@ describe("BindingRecord", () => { expect(binding.id).toBe("binding:custom"); }); + it("should create a typed semantic relationship", () => { + const relation = BindingRecord.createRelation("service", "database", "depends_on", "binding:depends-on"); + + expect(relation).toMatchObject({ + id: "binding:depends-on", + type: "relation", + fromShapeId: "service", + toShapeId: "database", + relationType: "depends_on" + }); + }); + it.each([{ handle: "start" as const }, { handle: "end" as const }])( "should create binding with handle: $handle", ({ handle }) => { @@ -677,16 +689,47 @@ describe("validateDoc", () => { "rect1", ); const binding = BindingRecord.create("arrow1", "rect1", "end", { kind: "center" }, "binding1"); + const relation = BindingRecord.createRelation("rect1", "arrow1", "depends_on", "relation1"); page.shapeIds = ["arrow1", "rect1"]; doc.pages = { page1: page }; doc.shapes = { arrow1: arrow, rect1: rect }; - doc.bindings = { binding1: binding }; + doc.bindings = { binding1: binding, relation1: relation }; const result = validateDoc(doc); expect(result.ok).toBe(true); }); + + it("should reject an empty semantic relationship type", () => { + const doc = Document.create(); + const page = PageRecord.create("Page 1", "page1"); + const source = ShapeRecord.createRect("page1", 0, 0, { + w: 50, + h: 50, + fill: "#fff", + stroke: "#000", + radius: 0, + }, "source"); + const target = ShapeRecord.createRect("page1", 100, 0, { + w: 50, + h: 50, + fill: "#fff", + stroke: "#000", + radius: 0, + }, "target"); + const relation = BindingRecord.createRelation(source.id, target.id, "", "relation1"); + + page.shapeIds = [source.id, target.id]; + doc.pages = { page1: page }; + doc.shapes = { source, target }; + doc.bindings = { relation1: relation }; + + const result = validateDoc(doc); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toContain("Relationship 'relation1' has an empty relation type"); + }); }); describe("invalid documents", () => { diff --git a/schemas/document-snapshot.schema.json b/schemas/document-snapshot.schema.json index 15d4b1b..fb6018b 100644 --- a/schemas/document-snapshot.schema.json +++ b/schemas/document-snapshot.schema.json @@ -165,6 +165,13 @@ "$ref": "#/$defs/BindingKind", "description": "Registry key describing binding behavior." }, + "relation_type": { + "description": "Optional semantic relationship type, such as `depends_on`.", + "type": [ + "string", + "null" + ] + }, "source_handle": { "description": "Named source handle, such as `start` or `end`.", "type": "string" diff --git a/schemas/protocol-request.schema.json b/schemas/protocol-request.schema.json index 0b9fe2b..ea85c2b 100644 --- a/schemas/protocol-request.schema.json +++ b/schemas/protocol-request.schema.json @@ -185,6 +185,13 @@ "$ref": "#/$defs/BindingKind", "description": "Registry key describing binding behavior." }, + "relation_type": { + "description": "Optional semantic relationship type, such as `depends_on`.", + "type": [ + "string", + "null" + ] + }, "source_handle": { "description": "Named source handle, such as `start` or `end`.", "type": "string" @@ -1266,6 +1273,17 @@ "description": "Include complete matching records in the response.", "type": "boolean" }, + "incoming_to": { + "anyOf": [ + { + "$ref": "#/$defs/ShapeId" + }, + { + "type": "null" + } + ], + "description": "Restrict relationship records to those incoming to this shape." + }, "layer_id": { "anyOf": [ { @@ -1294,6 +1312,17 @@ "null" ] }, + "outgoing_from": { + "anyOf": [ + { + "$ref": "#/$defs/ShapeId" + }, + { + "type": "null" + } + ], + "description": "Restrict relationship records to those outgoing from this shape." + }, "page_id": { "anyOf": [ { @@ -1312,6 +1341,13 @@ "null" ] }, + "relation_type": { + "description": "Match one exact semantic relationship type.", + "type": [ + "string", + "null" + ] + }, "role": { "description": "Match one exact semantic role.", "type": [ diff --git a/schemas/protocol-response.schema.json b/schemas/protocol-response.schema.json index 681e8c4..ed5f643 100644 --- a/schemas/protocol-response.schema.json +++ b/schemas/protocol-response.schema.json @@ -203,6 +203,13 @@ "$ref": "#/$defs/BindingKind", "description": "Registry key describing binding behavior." }, + "relation_type": { + "description": "Optional semantic relationship type, such as `depends_on`.", + "type": [ + "string", + "null" + ] + }, "source_handle": { "description": "Named source handle, such as `start` or `end`.", "type": "string" diff --git a/schemas/transaction-draft.schema.json b/schemas/transaction-draft.schema.json index 7e06563..5d3e6f1 100644 --- a/schemas/transaction-draft.schema.json +++ b/schemas/transaction-draft.schema.json @@ -185,6 +185,13 @@ "$ref": "#/$defs/BindingKind", "description": "Registry key describing binding behavior." }, + "relation_type": { + "description": "Optional semantic relationship type, such as `depends_on`.", + "type": [ + "string", + "null" + ] + }, "source_handle": { "description": "Named source handle, such as `start` or `end`.", "type": "string"