Skip to content

Commit b6665e5

Browse files
akoclaude
andcommitted
refactor(mcp): centralize domain-model dirty routing in one seam
The previous fix made ListDomainModels dirty-aware, but the routing was still duplicated across GetDomainModel / GetDomainModelByID / ListDomainModels — each re-implementing "session-created or dirty → reconstruct, else reader." That duplication is exactly how ListDomainModels came to miss it. Funnel all three through a single effectiveDomainModel(moduleID) seam that owns the routing, so no read path can forget it. Removes the now-redundant reconstructDomainModel wrapper (its body is inlined in the seam) and moves the fidelity note onto reconstructDomainModelFromPED. No behavior change beyond the already-shipped consistency; build/vet/test clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 957e764 commit b6665e5

2 files changed

Lines changed: 54 additions & 67 deletions

File tree

mdl/backend/mcp/backend.go

Lines changed: 44 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -316,76 +316,76 @@ func (b *Backend) GetModuleByName(name string) (*model.Module, error) {
316316
return b.reader.GetModuleByName(name)
317317
}
318318

319+
// sessionDMPrefix prefixes the synthetic domain-model ID handed out for a
320+
// session-created module; the suffix is the module name.
321+
const sessionDMPrefix = "mcp~dm~"
322+
323+
// effectiveDomainModel is the SINGLE seam for reading a module's domain model:
324+
// the live view that accounts for writes made this session. Every domain-model
325+
// read funnels through it, so none can forget the dirty routing — the omission
326+
// that made ListDomainModels return stale entities and broke CREATE ASSOCIATION
327+
// for an existing module.
328+
//
329+
// Routing, in order:
330+
// - session-created module (no on-disk domain model): reconstruct from Studio
331+
// Pro with a synthetic DM ID encoding the module name (so
332+
// moduleNameForDomainModel can resolve it back);
333+
// - dirty existing module (written this session): reconstruct from Studio Pro;
334+
// - otherwise: the on-disk reader copy (last-saved state).
335+
func (b *Backend) effectiveDomainModel(moduleID model.ID) (*domainmodel.DomainModel, error) {
336+
for _, m := range b.sessionModules {
337+
if m.ID == moduleID {
338+
return b.reconstructDomainModelFromPED(m.Name, model.ID(sessionDMPrefix+m.Name), moduleID)
339+
}
340+
}
341+
localDM, err := b.reader.GetDomainModel(moduleID)
342+
if err != nil {
343+
return nil, err
344+
}
345+
if mod, merr := b.reader.GetModule(moduleID); merr == nil && b.dirty[mod.Name] {
346+
if recon, rerr := b.reconstructDomainModelFromPED(mod.Name, localDM.ID, localDM.ContainerID); rerr == nil {
347+
return recon, nil // best-effort: fall back to the local copy on failure
348+
}
349+
}
350+
return localDM, nil
351+
}
352+
353+
// ListDomainModels returns every module's live domain model (dirty routing via
354+
// effectiveDomainModel), plus session-created modules that have no on-disk copy.
319355
func (b *Backend) ListDomainModels() ([]*domainmodel.DomainModel, error) {
320356
local, err := b.reader.ListDomainModels()
321357
if err != nil {
322358
return nil, err
323359
}
324360
out := make([]*domainmodel.DomainModel, 0, len(local)+len(b.sessionModules))
325-
// A module written this session must be reconstructed from Studio Pro's live
326-
// model here too, with the SAME dirty routing GetDomainModel applies — otherwise
327-
// the stale on-disk snapshot hides entities/associations created earlier in the
328-
// same run. This is what made CREATE ASSOCIATION fail when its from/to entities
329-
// were just created in an existing module: findEntity lists via this method, and
330-
// it previously reconstructed only session-created modules, not dirty existing
331-
// ones. Best-effort: keep the local copy if reconstruction fails.
332361
for _, dm := range local {
333-
if mod, merr := b.reader.GetModule(dm.ContainerID); merr == nil && b.dirty[mod.Name] {
334-
if recon, rerr := b.reconstructDomainModel(mod.Name, dm.ContainerID); rerr == nil {
335-
out = append(out, recon)
336-
continue
337-
}
362+
eff, eerr := b.effectiveDomainModel(dm.ContainerID)
363+
if eerr != nil {
364+
eff = dm // best-effort: keep the on-disk copy if the live view fails
338365
}
339-
out = append(out, dm)
366+
out = append(out, eff)
340367
}
341-
// Session-created modules have no on-disk domain model; reconstruct each so
342-
// references into a freshly created module resolve in the same run.
343368
for _, m := range b.sessionModules {
344-
if dm, derr := b.reconstructDomainModelFromPED(m.Name, model.ID(sessionDMPrefix+m.Name), m.ID); derr == nil {
369+
if dm, derr := b.effectiveDomainModel(m.ID); derr == nil {
345370
out = append(out, dm)
346371
}
347372
}
348373
return out, nil
349374
}
350375

351-
// GetDomainModel returns a module's domain model. If the module was written
352-
// this session (dirty), it is reconstructed from Studio Pro's live in-memory
353-
// model so in-session edits are visible; otherwise it comes from the local
354-
// reader (last-saved state).
376+
// GetDomainModel returns a module's live domain model (see effectiveDomainModel).
355377
func (b *Backend) GetDomainModel(moduleID model.ID) (*domainmodel.DomainModel, error) {
356-
// A module created over MCP this session has no on-disk domain model for the
357-
// reader to read (the reader is a pre-create snapshot). Reconstruct its live
358-
// entities from Studio Pro with a synthetic domain-model ID (encoding the
359-
// module name, so moduleNameForDomainModel can resolve it back) — so both
360-
// "create entity X.Foo" and references to X's entities (e.g. a microflow
361-
// parameter or workflow context typed X.Foo) resolve within the same run.
362-
for _, m := range b.sessionModules {
363-
if m.ID == moduleID {
364-
return b.reconstructDomainModelFromPED(m.Name, model.ID(sessionDMPrefix+m.Name), moduleID)
365-
}
366-
}
367-
mod, err := b.reader.GetModule(moduleID)
368-
if err == nil && b.dirty[mod.Name] {
369-
return b.reconstructDomainModel(mod.Name, moduleID)
370-
}
371-
return b.reader.GetDomainModel(moduleID)
378+
return b.effectiveDomainModel(moduleID)
372379
}
373380

374-
// sessionDMPrefix prefixes the synthetic domain-model ID handed out for a
375-
// session-created module; the suffix is the module name.
376-
const sessionDMPrefix = "mcp~dm~"
377-
378381
// GetDomainModelByID mirrors GetDomainModel but is keyed by the domain model's
379382
// own ID; it resolves the owning module and applies the same dirty routing.
380383
func (b *Backend) GetDomainModelByID(id model.ID) (*domainmodel.DomainModel, error) {
381384
localDM, err := b.reader.GetDomainModelByID(id)
382385
if err != nil {
383386
return nil, err
384387
}
385-
if mod, err := b.reader.GetModule(localDM.ContainerID); err == nil && b.dirty[mod.Name] {
386-
return b.reconstructDomainModel(mod.Name, localDM.ContainerID)
387-
}
388-
return localDM, nil
388+
return b.effectiveDomainModel(localDM.ContainerID)
389389
}
390390

391391
// ReconcileMemberAccesses is a no-op for the MCP backend. It is the executor's

mdl/backend/mcp/read_router.go

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -48,30 +48,17 @@ func (b *Backend) registerSynthetic(id model.ID, name string) {
4848
// pedIDRef matches a PED path reference like "$id(/entities/3)".
4949
var pedIDRef = regexp.MustCompile(`^\$id\(/entities/(\d+)\)$`)
5050

51-
// reconstructDomainModel builds a *domainmodel.DomainModel for a dirty module
52-
// from Studio Pro's live in-memory model (read over MCP), assigning synthetic
53-
// IDs to entities and associations.
54-
//
55-
// Fidelity note: PED reads expose attribute NAMES (parsed from $QualifiedName)
56-
// but not their primitive types — recovering a type costs a read per attribute.
57-
// The reconstruction therefore uses placeholder attribute types. That is
58-
// sufficient for the operations the slice routes through it (ALTER attribute
59-
// add/drop and DROP, which key on names), but DESCRIBE of an in-session-edited
60-
// entity will not show precise attribute types until the project is saved.
61-
func (b *Backend) reconstructDomainModel(moduleName string, moduleID model.ID) (*domainmodel.DomainModel, error) {
62-
// The domain-model document itself always exists on disk (nameless), so the
63-
// local reader gives us its real ID/container even when entities diverged.
64-
localDM, err := b.reader.GetDomainModel(moduleID)
65-
if err != nil {
66-
return nil, fmt.Errorf("reconstruct %s: local domain model: %w", moduleName, err)
67-
}
68-
return b.reconstructDomainModelFromPED(moduleName, localDM.ID, localDM.ContainerID)
69-
}
70-
7151
// reconstructDomainModelFromPED reads a module's live entities/associations from
7252
// Studio Pro and builds a domain model with the given IDs. It is shared by the
73-
// dirty-saved-module path (real on-disk IDs) and the session-created-module path
74-
// (synthetic IDs), the latter having no on-disk domain model for the reader.
53+
// dirty existing-module path (real on-disk IDs) and the session-created-module
54+
// path (synthetic IDs), the latter having no on-disk domain model for the reader.
55+
//
56+
// Fidelity note: PED reads expose attribute NAMES (from $QualifiedName) but not
57+
// their primitive types — recovering a type costs a read per attribute. The
58+
// shallow reconstruction uses placeholder types; enrichReconstructedEntities then
59+
// fills in real types/documentation with one batched leaf read. This is enough
60+
// for the operations routed through it (name-keyed resolution, ALTER add/drop,
61+
// DROP); a DESCRIBE of an in-session-edited entity is faithful after enrichment.
7562
func (b *Backend) reconstructDomainModelFromPED(moduleName string, dmID, containerID model.ID) (*domainmodel.DomainModel, error) {
7663
dm := &domainmodel.DomainModel{ContainerID: containerID}
7764
dm.ID = dmID
@@ -158,7 +145,7 @@ func (b *Backend) reconstructEntities(moduleName string, dmID model.ID, raw json
158145
e.Attributes = append(e.Attributes, &domainmodel.Attribute{
159146
ContainerID: id,
160147
Name: lastSegment(pa.QualifiedName),
161-
// Placeholder type — see fidelity note on reconstructDomainModel.
148+
// Placeholder type — see fidelity note on reconstructDomainModelFromPED.
162149
Type: &domainmodel.StringAttributeType{},
163150
})
164151
}

0 commit comments

Comments
 (0)