Files
Socrates/apps/web/lib/sync/ops.ts
dtoro 384cbb4ae9 MVP M5 (in-memory): bidirectional sync via ModelOp + applyOps + ModelStore
Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.

apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
  association / constraint / requirement / relation, add/update/remove for
  each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
  reducer. Atomic per batch. Cascades on remove-block (drops incident
  associations + constraint applies-to + requirement satisfiers). tempId
  resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
  issuesByElement }. Validation memoized on every model change.

Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
  fixture (+ optional ?break= corruptions). Validation now lives in the
  store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
  constraints (sorted by kind), Requirements section lists real
  requirements with traced/untraced status from r.relations.

Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
  Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
  updates only for elements whose semantic data (label, kind, properties)
  changed. Object identity preserved for unchanged nodes — fixes the
  "re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
  ops via useApply(). Constraint-applies edges decompose into
  updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
  selecting a block + Del removed only the edges, leaving the block).

Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
  (block / requirement / association / property). Double-click chip →
  inline rename input → emit update-{block,requirement,association} op.
  All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
  affordance (until M6 wires real model element resolution).

Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
2026-04-29 00:42:18 +02:00

236 lines
7.5 KiB
TypeScript

// The canonical ModelOp alphabet — every mutation to a SysMLModel is
// expressible as one or more of these. See docs/sync.md §3 for the design.
//
// `tempId` lets clients optimistically create elements with a client-generated
// id; applyOps may rewrite to a canonical id and return the mapping. (M5
// in-memory: tempId IS the canonical id; the rewrite seam is here for the
// later DB / SSE iteration.)
import type {
Block,
BlockKind,
Association,
AssociationKind,
Constraint,
Property,
PropertyType,
Multiplicity,
Requirement,
RequirementRelation,
} from "../sysml/model";
// ─── Block ops ───────────────────────────────────────────────────────────
export interface AddBlockOp {
kind: "add-block";
block: Block;
tempId?: string;
}
export interface UpdateBlockOp {
kind: "update-block";
blockId: string;
patch: Partial<Pick<Block, "label" | "kind" | "stereotypes" | "description">>;
}
export interface RemoveBlockOp {
kind: "remove-block";
blockId: string;
}
// ─── Property ops ────────────────────────────────────────────────────────
export interface AddPropertyOp {
kind: "add-property";
blockId: string;
property: Property;
tempId?: string;
}
export interface UpdatePropertyOp {
kind: "update-property";
blockId: string;
propertyId: string;
patch: Partial<Pick<Property, "name" | "type" | "multiplicity" | "description">>;
}
export interface RemovePropertyOp {
kind: "remove-property";
blockId: string;
propertyId: string;
}
// ─── Association ops ─────────────────────────────────────────────────────
export interface AddAssociationOp {
kind: "add-association";
association: Association;
tempId?: string;
}
export interface UpdateAssociationOp {
kind: "update-association";
associationId: string;
patch: Partial<Pick<Association, "fromBlockId" | "toBlockId" | "label" | "kind" | "multiplicity">>;
}
export interface RemoveAssociationOp {
kind: "remove-association";
associationId: string;
}
// ─── Constraint ops ──────────────────────────────────────────────────────
export interface AddConstraintOp {
kind: "add-constraint";
constraint: Constraint;
tempId?: string;
}
export interface UpdateConstraintOp {
kind: "update-constraint";
constraintId: string;
patch: Partial<Pick<Constraint, "label" | "expression" | "appliesTo">>;
}
export interface RemoveConstraintOp {
kind: "remove-constraint";
constraintId: string;
}
// ─── Requirement ops ─────────────────────────────────────────────────────
export interface AddRequirementOp {
kind: "add-requirement";
requirement: Requirement;
tempId?: string;
}
export interface UpdateRequirementOp {
kind: "update-requirement";
requirementId: string;
patch: Partial<Pick<Requirement, "tag" | "text">>;
}
export interface RemoveRequirementOp {
kind: "remove-requirement";
requirementId: string;
}
export interface AddRelationOp {
kind: "add-relation";
requirementId: string;
relation: RequirementRelation;
}
export interface RemoveRelationOp {
kind: "remove-relation";
requirementId: string;
relationIndex: number;
}
// ─── Union ───────────────────────────────────────────────────────────────
export type ModelOp =
| AddBlockOp
| UpdateBlockOp
| RemoveBlockOp
| AddPropertyOp
| UpdatePropertyOp
| RemovePropertyOp
| AddAssociationOp
| UpdateAssociationOp
| RemoveAssociationOp
| AddConstraintOp
| UpdateConstraintOp
| RemoveConstraintOp
| AddRequirementOp
| UpdateRequirementOp
| RemoveRequirementOp
| AddRelationOp
| RemoveRelationOp;
// ─── Op constructors (call sites stay readable) ─────────────────────────
export function addBlock(block: Block, tempId?: string): AddBlockOp {
return { kind: "add-block", block, tempId };
}
export function updateBlock(blockId: string, patch: UpdateBlockOp["patch"]): UpdateBlockOp {
return { kind: "update-block", blockId, patch };
}
export function removeBlock(blockId: string): RemoveBlockOp {
return { kind: "remove-block", blockId };
}
export function addProperty(blockId: string, property: Property, tempId?: string): AddPropertyOp {
return { kind: "add-property", blockId, property, tempId };
}
export function updateProperty(blockId: string, propertyId: string, patch: UpdatePropertyOp["patch"]): UpdatePropertyOp {
return { kind: "update-property", blockId, propertyId, patch };
}
export function removeProperty(blockId: string, propertyId: string): RemovePropertyOp {
return { kind: "remove-property", blockId, propertyId };
}
export function addAssociation(association: Association, tempId?: string): AddAssociationOp {
return { kind: "add-association", association, tempId };
}
export function updateAssociation(associationId: string, patch: UpdateAssociationOp["patch"]): UpdateAssociationOp {
return { kind: "update-association", associationId, patch };
}
export function removeAssociation(associationId: string): RemoveAssociationOp {
return { kind: "remove-association", associationId };
}
export function addConstraint(constraint: Constraint, tempId?: string): AddConstraintOp {
return { kind: "add-constraint", constraint, tempId };
}
export function updateConstraint(constraintId: string, patch: UpdateConstraintOp["patch"]): UpdateConstraintOp {
return { kind: "update-constraint", constraintId, patch };
}
export function removeConstraint(constraintId: string): RemoveConstraintOp {
return { kind: "remove-constraint", constraintId };
}
export function addRequirement(requirement: Requirement, tempId?: string): AddRequirementOp {
return { kind: "add-requirement", requirement, tempId };
}
export function updateRequirement(requirementId: string, patch: UpdateRequirementOp["patch"]): UpdateRequirementOp {
return { kind: "update-requirement", requirementId, patch };
}
export function removeRequirement(requirementId: string): RemoveRequirementOp {
return { kind: "remove-requirement", requirementId };
}
export function addRelation(requirementId: string, relation: RequirementRelation): AddRelationOp {
return { kind: "add-relation", requirementId, relation };
}
export function removeRelation(requirementId: string, relationIndex: number): RemoveRelationOp {
return { kind: "remove-relation", requirementId, relationIndex };
}
// ─── Helpers ─────────────────────────────────────────────────────────────
let counter = 0;
/** Generate a temp id. Distinct prefix so applyOps can recognize them. */
export function tempId(prefix = "tmp"): string {
counter++;
return `${prefix}_${Date.now().toString(36)}_${counter}`;
}
/** Quick property factory used by clients producing add-property ops. */
export function newProperty(name: string, type: PropertyType = { kind: "string" }, multiplicity: Multiplicity = "0..1"): Property {
return { id: tempId("p"), name, type, multiplicity };
}