Skip to content

Release: relationships on the fact side, composite keys, Mermaid/DBML export, merge-conflict and YAML fixes - #159

Merged
liam-machine merged 90 commits into
mainfrom
develop
Oct 9, 2026
Merged

liam-machine merged 90 commits into
mainfrom
develop

Conversation

@liam-machine

Copy link
Copy Markdown
Owner

Releases everything on develop since 1.6.7. The deploy workflow bumps the patch (1.6.7 → 1.6.8). The full notes are under ## Unreleased in CHANGELOG.md.

Highlights

Also released

  • @erd-studio/core and @erd-studio/renderer 0.1.3 go out via publish-packages.yml. Every API change is additive and optional.
  • AI helper files move to harness v29, so users get the usual Update All prompt.

Verified on develop (1c8fc2e)

  • CI is green.
  • Locally: compile, build, 3702 tests passed (1 skipped), CLI smoke test (doctor 0, diff --all 1), mcp-server build and smoke test, VSIX 24 files, npm audit clean.
  • The Cloudflare secrets are present, and the D1 baseline is already recorded.

Closes #133
Closes #138
Closes #144
Closes #145
Closes #149
Closes #150
Closes #151
Closes #152
Closes #157

🤖 Generated with Claude Code

liam-machine and others added 30 commits October 8, 2026 16:18
…tionship mode safe

- A model save no longer rewrites the whole `relationships:` list. It is
  synced entry by entry: an unchanged list is not touched, a changed entry
  keeps its YAML node (comments, unknown keys such as `role:`), entries the
  reader skips stay where they are, and a change that cannot be made in
  place is refused with the model file and line.
- A canvas edit that would rewrite or delete a model file open with
  unsaved edits is refused, naming the file, instead of replacing them.
- A domain or model file that does not parse is counted by what its text
  shows, so a merge conflict cannot switch the project between per-domain
  and library relationships.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s, kept line endings (S1-S5)

- S1: the unsaved-tab guard compared resolved paths letter for letter, so a
  tab VS Code spelled in another case (a `c:` drive letter, a macOS folder
  typed differently) was missed. One helper, `dirtyFiles` / `pathKey` in
  src/providers/dirtyDocuments.ts, compares case-insensitively on win32 and
  darwin; the canvas guard and Move Relationships use it.
- S2: a save wrote `dataType: unknown` (the reader's default) into every
  column the file left untyped. A column node keeps a key it leaves out or
  empty when the desired value is only that read default.
- S3: model yml and domain JSON written straight to disk keep their own line
  endings: LogicalModelService.renderModel / renameModel, minimalEdits'
  flow-mapping branch (Move Relationships) and the v4 -> v5 migration's
  domain rewrite, through src/services/lineEndings.ts.
- S4: the positional take-over in syncRelationships reuses an old entry's
  node only when it shares an end with the new one, so an unrelated link no
  longer inherits another's comment or `role:`.
- S5: Draw from dbt refuses, naming the file, when a model file it would
  write is open with unsaved edits, before anything is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tten-back cardinality default

- projectDiscovery.samePath compares through the shared, vscode-free
  `pathKey` (moved to src/services/pathKey.ts), so project ownership and the
  project picker treat a folder spelled in another case as the same folder
  on Windows and macOS.
- Stripping legacy domain tags keeps a schema yml's CRLF line endings.
- A relationship entry taken over by a rename leaves every field that reads
  the same as written, so a missing cardinality is not written back as
  `many-to-one`.
- renameModel keeps the old file's line endings when it has to regenerate it.
- Draw from dbt names what failed when it cannot read the model library to
  plan the diagram, instead of failing the command generically.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… with an optional role (#133)

A library relationship was saved in the file of whichever model it was
drawn *from*. Drawing from a dimension to a fact, or pressing the edge's
⇄ swap, put it in the dimension's file, so adding a new fact meant editing
every dimension it joins.

The many (FK) end now always owns it. core's canonicalRelationship()
turns a one-to-many into the swapped many-to-one (one-to-one and
many-to-many keep the drawn direction), and every path that chooses a
library file applies it: writeLibraryRelationship (add, ⇄ swap, edit),
routeToLibrary (Draw from dbt), planMoveToLibrary / resolveConflict, and
the CLI's add-relationship fix. A drag that starts on a key is turned
round before the dialog opens, because the dialog's many-to-one default
would otherwise record the dimension as the many side.

The same two columns joined either way round are one link: the reverse
is refused as a duplicate (host and dialog), mergeLibraryRelationships
draws it once, and DiscrepancyService matches it whichever end each stage
reads it from — dbt may test a link from its one side, which the many-side
logical entry must still match.

"Move Relationships to Model Library" also re-homes library entries
stored on their one side (planRehome), on the same disk-only,
all-or-nothing writer, keeping roles and never overwriting an entry the
fact already holds.

Optional `role` (a label, not identity; normalised, max 60 chars) is
parsed, written by every writer, drawn on the edge, editable in the
dialog and kept through ⇄ swaps; addRelationship / editRelationship carry
it with payload validation. Schemas, SCHEMA_CONTENT (harness v26), the
setup skill, the reference doc, CLAUDE.md and the changelog describe it;
the changelog warns that 1.6.7 drops roles when it rewrites a model's list.

Merges main's 1.6.7 release stamp so the new notes sit under Unreleased.

Rebased onto the relationship-list safety fix: LogicalModelService now
syncs `role` entry by entry with the other relationship keys, so a
role-only edit is saved and clearing a role removes the key.

Closes #133

(cherry picked from commit ec56fe3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
isReferencedKey() counted any primary-key column as the end being pointed
at, so a drag from a hub to a satellite, whose hub hash key is part of its
composite key, was left as drawn and stored on the hub. Only a model's
whole PK or NK counts as the referenced end now, which also covers a fact
keyed by its dimension keys.

Adds Data Vault orientation tests and a host-level lifecycle test (add with
role, ⇄ swap there and back across two diagrams, edit, model rename, column
removal). Taken from da3d1b4 without its tidy-up pop-up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
removeLibraryRelationships matched the exact ends, so deleting a line left
a teammate's reverse copy on the other model, which then drew again, and an
edit left a twin that disagreed. Removal (delete, edit, ⇄) now drops every
copy of the link in either model file and in the domain file, by core's
new linkKey / sameLink: two `model.column` ends, unordered, without case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…der (#133)

mergeLibraryRelationships kept the first copy in model order, so a 1.6.7
copy saved backwards on the dimension could hide the fact's correct one.
The winner is now fixed: a copy in its home (not one-to-many) beats one
that is not, then one not leaving its model's whole primary key, then the
lowest model name, then the first entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion (#133)

relationshipsForAddedModels forced many-to-one in the direction dbt's
relationships test was declared, so a test on the dimension was stored
backwards on the dimension and markDraftKeys flagged its key as a foreign
key. The derived cardinality (from `unique` evidence) is now put in its
stored form first, links are deduped either way round, and a key column is
never flagged a foreign key by the draft.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t rewrite safely (#133)

planRehome dropped the dimension's copy of a link whenever the fact
already held one, even when the two disagreed (cardinality, direction or
role). Both copies are now left in place and listed in the preview as
needing attention.

setYamlRelationships re-renders a whole `relationships:` list, so a file
whose list holds a comment or an entry the reader cannot understand (an
unknown key, an unrecognised cardinality, a skipped entry) is no longer
rewritten: relationshipsRewriteLoses() marks it, the plan leaves what was
bound for it where it is, and the preview names the file. The check runs
again before writing, in case the file changed while the dialog was open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
core's linkKey / sameLink (two `model.column` ends, unordered, without
case) and the new reverseRelationship (ends swapped, many-to-one ↔
one-to-many) replace the copies that had grown up: discrepancyService's
linkKey and readFromOtherEnd, libraryRelationships' sameColumnPair and
hasLibraryRelationship, the CLI diff's exact-end library lookup, and the
New Relationship dialog's inline reverse check and edit-time flip.
canonicalRelationship is now reverseRelationship applied to a one-to-many.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-column v4 file (#133)

Input fixtures from 0872b88: relationships stored one-to-many on the one
side, a 1.6.7 backwards copy alone and next to the fact's, the stray FK
flag Draw from dbt left on a key, a diagram-file copy that differs, and a
hand-edited v4 domain whose columns have no text name. Both golden suites
pin how they load; no existing golden changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…delete by pair and the move's left-alone cases

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ards on a dimension (#133)

1.6.7's dialog defaulted to many-to-one from the drag's start, so a line
drawn from a dimension's key was stored on the dimension as a many-to-one
pointing at the fact: probably the most common old shape, and one Move left
alone. planRehome now also turns round a many-to-one whose fromColumn is
its own model's whole primary (or natural) key and whose toColumn is not
the target's whole key, into the target's file as many-to-one, keeping its
role. It follows the existing rules: an agreeing copy on the fact means the
dimension's is just dropped, a disagreeing one leaves both listed, locked
files stay untouched, and a second run finds nothing. Anything without
that key evidence is left alone; the stray FK flag on the dimension key is
not cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…diagrams as one (#133)

planMoveToLibrary grouped domain-file relationships by the directional
relationshipKey, so `dim → fct one-to-many` in one diagram and
`fct → dim one-to-one` in another never met as a conflict: both went to
toLibrary with the same stored ends, one replaced the other, the
one-to-one was lost and the preview listed the link twice. They are now
grouped by linkKey, with each copy's cardinality read in the direction of
the first one seen: copies that disagree are an ordinary conflict for the
existing QuickPick, and copies that agree once turned round merge into one
many-to-one on the fact. Domain entries are taken out by linkKey too, so
resolveConflict and the domain-file rewrite remove both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rawn once (#133)

keyEvidence(model, column) -> whole-key | not-key | unknown is the one place
relationship direction reads key flags; contradictsKeys and keyedRelationship
("keys win") build on it, so dbt evidence can be added there later (L1).

M: a many-to-one is read the other way round only when the target declares a
whole key that is a different column, never when it flags no key at all.

D1: mergeLibraryRelationships dedupes a domain file's own copies of one link
too (it skipped them when the library was empty), and the read winner breaks
ties by content rather than entry order, preferring a labelled copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s overwritten (#133)

planRelationshipWrite is the one pure planner for the canvas's add / ⇄ /
edit / delete, run inside the provider's commitRelationshipWrite (one
applyDomainEdit, one undo step). An edit folds every copy of the link into
one entry in its home with the drawn line's role, else any copy's (F2, F4);
⇄ refuses while copies carry different roles, and refuses to make a whole
key the many side (F3b), which the New Relationship dialog warns about with
"Create anyway". A delete in a library project names other diagrams that
still draw their own copy (D3), writing nothing.

routeToLibrary never overwrites a stored link, and leaves a link another
diagram draws from its own copy in the new domain's file (F1).

Move stores diagram copies as keyedRelationship reads them, so one run is
final (F3); conflicts are whole definitions — cardinality, role, and the FK
end of a one-to-one (F5, F6) — and a many-to-many drawn both ways is one link
stored from its lower end. A diagram role lands on the entry the library
keeps, also when that entry is being turned round (F7); diagram copies the
library's version replaces are listed (D2), as is every entry turned round
(D4). applyMoveToModel removes a turned entry by itself, never a sibling copy,
and is shared by the command and the checker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…precated in model files (#133)

D5: erd-studio diff's set-cardinality fix names the entry exactly as it
belongs in the yml — on its many side, never one-to-many — the file it goes
in, and `movesFrom` when the change moves it to the other model's file;
remove-relationship names the stored entry and its file. One-to-one and
many-to-many keep the direction they are stored in. The setup skill's fix
table follows.

D6: the model-file schema marks one-to-many deprecated (still read), and
the reference doc notes a toModel with no model file is not drawn, keys win,
and the Move's definitions.

SCHEMA_CONTENT states the keys-win rule; harness v27, fixture copies
regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eal planner (#133)

Every small state of one link's stored copies (two models, two diagrams,
six key profiles) through every canvas edit, Add from dbt, rename and Move,
against the storage invariants. The edits run planRelationshipWrite and the
Move runs planMoveToLibrary / applyMoveToModel — the provider's and the
command's own functions, not copies. CI runs the small scope (~11 s) and
fails on any signature outside ALLOWED (L4's case-only renames, and renames
of another diagram's own copy, out of scope); STATESPACE_SCOPE=full and
STATESPACE_BFS=1 widen it. A sampled cross-check drives the real provider
and Move command over files (~20 s). The findings tests pin F1–F7 and D1–D3
on the real code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#133 L4)

Core's respellRelationship spells each end as the domain's models and
columns spell it (exact first, then without case); mergeLibraryRelationships
applies it to everything it draws, including the domain-only path, and the
v4 reader to inline relationships. `toModel: Dim_Customer` now draws, and
graphTransformer stays exact.

Writes normalise only the entry they touch: the yml sync matches ends
without case, so a respelled entry keeps its node (comments, unknown keys).
Column and model renames and removals cascade without case through library
entries and the domain file (renameColumnInDomainRelationships /
renameModelInDomainRelationships, shared with the checker). Move and
routeToLibrary resolve a model to one object and one file by its real name.

The checker's four case-only rename rows are gone from its allowlist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ry key" (#133 L1)

Core's keyEvidence moves to keyEvidence.ts with column sets and dbt's tests:
the model's flags (the full PK or NK set is the key, anything else in a
keyed model is not), else a unique test, a unique_combination_of_columns or
a relationships test (buildDbtKeyIndex, unique beats not-unique in any
order). orientLink decides a link's direction from its two ends' evidence,
symmetrically; with nothing to go on it is not decided.

The index reaches every consumer: the read winner (getDomain's
dbtKeyIndex, from the canvas and computeDomainDiff, so CLI and canvas agree),
the write planner's ⇄ refusal, Move's turn-round (loadDbtKeyIndex) and the
editable payload's DisplayColumn.dbtKey (withDbtKeyHints; never the viewer,
the CLI or the goldens). DisplayColumn.isForeignKeyDeclared carries the
stored flag; the logical goldens gain it.

Drag orientation is orientLink (isReferencedKey is gone). The dialog states
which side holds the foreign key and why, asks with two model-named choices
when nothing says (Create waits), offers "Turn round" against the keys, and
offers "Mark … as primary key" when the one side has no key (ticked when dbt
says unique). markKey travels on addRelationship / editRelationship,
validated at the boundary (validateMarkKey) and applied by the planner in the
same WorkspaceEdit; refused when a key appeared meanwhile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A drag onto another column of the same model opens the New Relationship
dialog for a self-reference (employee.manager_id → employee.employee_id),
oriented by orientLink like any other; only a column dropped on itself is
refused, with a toast that says how to draw one. The dialog drops its "model
cannot relate to itself" error for "a column can't point at itself", and the
host refuses that pair at the boundary too (validateRelationshipEnds).

Several loops on one node nest: graphTransformer gives each its loopIndex
among the node's self-loops by edge id, and FkEdge widens the radius by it.
The details panel lists a self-reference once, under outgoing, marked ↻.

Storage, the physical stage, the diff, Draw from dbt and Move already
handled one model at both ends; tests now pin each: stored in the model's
own file (a one-to-many swapped there), turned round by Move inside one file
written once, derived many-to-one from dbt, matched by the diff, and named
on employee.yml by erd-studio diff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#133 L2)

A composite foreign key is one entry per column pair, all sharing a
`compositeKey`, stored together on the FK side — so ERD Studio 1.6.7, which
reads each entry as a single link and drops unknown keys on save, never
loses a pair (mixed167.compositeKey runs 1.6.7's writer to prove it; the
rejected array form would be deleted outright).

Core: Relationship / DisplayRelationship.compositeKey, normaliseCompositeKey,
ColumnPair, groupKey, compositeGroupProblem (2–8 pairs, one model to one
model, one cardinality, many-to-one or one-to-one, no column twice) and
contradictsKeysOf on column sets. The readers keep the key; the read path
assembles valid groups after the per-link winner (named and labelled by the
first member, placed at the first one's spot) and draws invalid ones as
singles with one warning.

Writes: planRelationshipWrite treats an edge of a group as the group —
add/edit take extraPairs (named fk_<toModel>, _2, …; single links already
stored are taken in, "Grouped N existing relationships into …"; a pair
another group owns is refused), ⇄ and cardinality change every member
(never many-to-many; keys win on the sets), an edit down to one pair writes a
single link, delete expands to the group, and removing a member column
removes the whole group (library and domain file). extraPairs is validated
at the boundary (validateExtraPairs; markKey and the self-reference check
read every pair). The model-file writer and the Move's minimal edits keep
compositeKey.

Move: diagram groups move as units (planGroupMoves) — conflicts over whole
group definitions, singles of a moving group taken out and listed, a group
left whole (and said why) when its file is missing or locked or the library
holds a pair otherwise; planRehome turns a backwards group round together.

Schemas, the reference doc, SCHEMA_CONTENT (composites, self-references,
real spelling, marking the referenced key; harness v28, fixture copies
regenerated) and a composite-and-self golden.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he CLI (#133 L2)

The schema yml and the manifest yield compositeForeignKeys from dbt ≥ 1.9
model-level `foreign_key` constraints and `dbt_constraints.foreign_key`
tests (two or more columns; `to` by ref(), or a rendered relation by its last
identifier against node name then alias). buildPhysicalDomain merges both
sources by groupKey, and derivePhysicalRelationships emits each declaration's
members with a compositeKey (the constraint's name, else fk_<toModel>) —
many-to-one, one-to-one when the from set is a declared unique combination —
deduping a pair a relationships test also declares. The physical stage stays
derived; its goldens are unchanged.

Compare to Physical matches pair by pair and reports each composite once:
matched, one cardinality difference, or one informational extra (`composite`,
`undeclaredPairs`) — target links a composite consumes are never missing.
The sync plan carries pairs under a joined selection key and asks dbt to
declare an undeclared composite rather than delete it from the logical
model. `erd-studio diff` gains the advisory declare-composite-foreign-key fix
(blocking under --strict); add-relationship and set-cardinality carry pairs
on the canonical file. The discrepancy panel shows the informational row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#133 L2)

graphTransformer folds a composite's members into one edge carrying its
pairs (foldComposites / relationshipDisplayKey / relationshipEdgeId, ids and
discrepancy keys joined `a+b`), a missing composite into one ghost edge.
FkEdge shows a ⧉ N chip and lists the pairs in its hover text; ⇄, delete and
the details panel act on the first pair, which the host expands to the
group. Selecting the edge highlights every member column, and the details
panel lists a composite as one row.

The New Relationship dialog gains "+ Add another column pair" rows (each
with ×), titles itself "New Composite Relationship", disables many-to-many
with two or more pairs, checks each row (both columns, a column used twice,
a column pointing at itself), says when a pair is already a relationship it
will take in, and reads direction, the key choice and "Mark … as primary
key" on the column sets. Edit opens with every pair; messages carry
extraPairs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… L5)

Renaming a column on the canvas rewrote the open diagram and the model
library, but another diagram's own copy of a relationship (a per-diagram
project, or a copy left as it is) kept the old name and stopped drawing.
applyDomainEdit gains `otherDomains`: a rewrite run on every other v5 domain
file, each one it changes added to the same WorkspaceEdit (one undo step,
saved and flushed on undo like the model files it writes) and refused by
name when that file is open with unsaved edits. A column rename rewrites
the other diagrams' copies (renameColumnInDomainRelationships, without
case); a model rename rewrites their copies and, so they keep drawing the
model, their logical.models entry and stored position.

The exhaustive checker's rename mirrors the provider; its last four
allowlist rows go, and the allowlist is empty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r nothing (#133 L2)

Found by the extended exhaustive check:

- A write found a composite from the stored entries (the first copy of each
  link), so a stray copy of a member in the other model's file could make a
  delete or ⇄ act on one member only. The planner, the delete expansion and
  column removal now take the group from what the read path draws
  (drawnGroupOf), whichever copies won; column removal reads it from the
  whole library, so no copy of a member outlives the key in the open diagram.
- groupOf takes the entry itself when it is in the list (a file holding two
  groups of one name) and counts a link saved twice as one pair, as the read
  path does.
- A valid library group whose members are stored in different files was
  drawn with its members in opposite directions (two edges); every member is
  now read the way the first is.
- Move judged another copy of a member on its single column, which can
  contradict the group's column-set reading, then dropped only that member's
  re-home and moved half the group. Every copy is now read as Move stores it
  (a member as part of its group), and a group's re-homes are kept or dropped
  together. A single entry Move re-homes no longer carries a stale
  compositeKey, and a diagram keeping a member's link as another entry that
  says something else leaves the group whole, named in the preview.
- Move compared a library group with a diagram's by groupKey, whose
  canonical from-model differs for a group stored the other way round, so a
  backwards library group left the diagrams' copies behind; it now compares
  the pairs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-references and composite keys (#133 X)

The main universe gains four dbt-evidence key profiles (dbtStar,
dbtOnlyUnique, flagsBeatDbt, dbtCombo — the index reaches the read winner,
the write planner and the Move, as on the canvas) and a case-variant star
(the other end's model spelled `Dim` / `Fct`, the column `K`), with the
invariants C1 (an end resolving without case is drawn, in the real
spelling), C2 (a write respells only what it touches), E1 (flags dominate
dbt), E2 (orientLink is symmetric) and E3 (no silent default from drag
order). Its Move mirror looks models up as the command does, by real name.

relationshipStateSpace.universes.ts adds two universes on the same planner,
read path, Move and renderer: SELF (emp.mgr ↔ emp.id; S1 self-home, I2/I3,
I5, M3) and GROUP (a composite of two pairs, its partial and its 1.6.7-split
singles; G1 one edge, G2 atomic, G3 home, G4 no pair lost to 1.6.7, G5
regroup, G6 column removal, I5).

The real-provider cross-check writes each profile's dbt tests as a schema
yml and gives the real Move its loadDbtKeyIndex. CI scope ~16 s alone
(9,768 + 1,317 states); full scope (40,784 states) and BFS depth 3 are clean.
The allowlist stays empty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
liam-machine and others added 29 commits October 9, 2026 22:12
When two people move models on different branches, git can leave
<<<<<<< / ======= / >>>>>>> markers in a domain file's viewConfig.positions.
parseDomainJson handed that text to JSON.parse, so the failure was a plain
'invalid-json' DomainFileError: the canvas spent ~1.2s re-reading it (the
reason is transient, meant for a file caught mid-write) and then showed the
full-screen "Invalid JSON" page, which reads as corruption. A model yml with
markers showed as a YAML error on whatever line the parser stopped.

Core now checks text that has already failed to parse for a complete
conflict (git's is_conflict_marker shapes: an opening line, a separator and
a closing line in that order, a diff3 base allowed; BOM and CRLF tolerated),
so a valid file can never be flagged and a lone Markdown "=======" is not a
conflict. A domain file gets DomainFileError.mergeConflict (always present,
false by default) and the first marker's line, keeps reason 'invalid-json'
and is not transient; a model file gets ModelLoadError.mergeConflict with
kind 'yamlOther'. Both are flags rather than new union members, because
widening DomainFileErrorReason or ModelLoadErrorKind would break exhaustive
switches and Records in consumers (the Confluence app, telemetry) and need a
MINOR package release; a host that predates the flag still says
"invalid JSON" / "YAML error", which stays true. Additive, PATCH.

The canvas error screen explains the conflict, drops Retry and offers
"Open as Text to Resolve"; it reloads by itself on the save that resolves it
(every document change already re-reads the domain). The model node,
notification and CLI say "unresolved git merge conflict"; diff / export
report the code merge-conflict, and doctor fills conflictedDomainFiles.
Telemetry is unchanged (still a 'json' load failure).

Closes #145

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both PRs added at the top of CHANGELOG's Fixed list and at the same spot in
cli.doctor.test.ts, so whichever merged second would conflict. Moved here so
#147 and #148 merge cleanly in either order. No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The orphaned-canvas safety net armed a 5 s timer whenever no domain was
loaded. Its effect depended only on `domain`, so it kept running behind
the full-screen load-error page and set `showReconnectOverlay`, which
nothing ever reset. Pressing Retry cleared the error and rendered the
loading screen with the overlay already up, offering a pointless
save-all + window reload (for ~1.2 s on invalid JSON while the host
re-read the file).

The timing rule now lives in `useReconnectWatchdog(waiting)`, with
`waiting = !domain && !error`. An `error` is an answer from the host,
so it stops the timer and hides the overlay; Retry clears the error and
starts a fresh 5 s grace period, so a host that then stays silent still
gets the overlay. The hook returns `timedOut && waiting`, so a stale
value can never show for a render.

Closes #149

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… to the user

Order: doctor's resolve-merge-conflicts next step now comes straight after
confirm-venv and before install-dbt, create-profile, run-deps,
run-parse/refresh-parse and run-catalog. A conflicted diagram cannot load
whatever state dbt is in, so an assistant working down the list must raise
it before installing dbt or running anything against the warehouse.
confirm-venv stays first: it is a safety question about running a program
the project ships. fix-model-yaml keeps its existing place.

Wording: the step's why no longer tells the reader to keep a side and run
git add. It asks an assistant to show the user each file and line, say
either side is safe when only positions differ, otherwise ask which side to
keep, and not to pick a side or run git itself; then re-run doctor. It reads
"This file still holds" for one file. The model-file conflict hint
(unreadableHint, used by doctor's fix-model-yaml and diff's fix explain)
says the same, and fix-model-yaml adds that a merge conflict is the user's
to resolve. The terminal lines are short and neutral: "unresolved git merge
conflict — resolve it (keep one side), save, then re-run doctor|diff"
(describeUnreadable now takes the command to name).

Tests: the doctor test now asserts the order (conflict before install-dbt,
run-parse and run-catalog; confirm-venv before the conflict) and the new
wording; the diff test checks the fix explain and the terminal line.

Docs: @erd-studio/core's README documents DomainFileError's mergeConflict
and line, that a merge-conflict error is not transient, and a model's
loadError.mergeConflict; parseLogicalModelText's docstring names the
ModelMergeConflictError it throws for conflicted text. CHANGELOG's #145
entry mentions the doctor ordering and wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… it ignores

Two review findings on #144.

Doctor listed every column holding a `data_type:` key under "read as
dataType and saved back as dataType" — including columns that also hold a
`dataType`. Those are never renamed (the reader ignores their `data_type`
and the writer keeps it as written), so no save ever cleared the warning.
`erd.dataTypeAliasFiles[]` now carries `columns` (read from `data_type`;
renamed the next time the file is written) and a new `ignored` (beside a
`dataType` that wins; only deleting it clears the line). The writer and
doctor share one rule, `dataTypeAliasFate`, matching core's reader — so an
empty `dataType:` beside a `data_type` (where the reader does use
`data_type`) is now renamed too, the empty key giving way and any comment
on it moving to the renamed key. Human output prints one line per case,
singular for one column ("payment_id uses …"), counted for more, and a
long list names the first five then "and N more". Still a warning, never
a next step.

"The next save" overpromised: the key is renamed only when ERD Studio
rewrites that model's file — a canvas edit to the model or to a
relationship stored in it (applyModelEdit, commitRelationshipWrite's
changed models, a rename, Draw from dbt / Add Existing Model routing),
not moving nodes or Move Relationships to Model Library (surgical edits,
no column sync). CHANGELOG, the reference doc, doc comments and the
doctor line now say so, and the CHANGELOG notes the Confluence app shows
the type once it updates to this version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…st's timeout

The first test in each file that parsed Mermaid paid the whole cold load
of both releases (mermaid 10.0.0 and mermaid-current) inside vitest's 5 s
test timeout: the parsers were dynamic imports in the shared helper, and
10.0.0 also lazily loads its ER grammar on the first parse.

Measured (Node 24, 18 cores):
- Idle, alone: import 10.0.0 ~0.36 s, its first parse 0.33-0.58 s,
  import mermaid-current 0.31-0.61 s; every later parse ~0-4 ms.
- Full `npm test`, before: the first Mermaid test in all three files
  timed out in both runs - exportMermaid "opens with erDiagram" 11.3 s /
  9.0 s, diagramExport "as mermaid" 10.7 s / 13.1 s, exportDiagram.golden
  "showcase mermaid golden" 8.7 s / 18.1 s; a third run 6.2 s / 6.0 s
  (both failed) / 5.7 s.
- Full `npm test`, after (two runs): 3588 passed, 1 skipped, 0 failed;
  no test in those files over 184 ms.

exportParsers.ts now has a memoised loadMermaidParsers() that imports
both releases once and warms each with one parse; parseMermaid and
parseMermaidOldest go through it, and a failed load is forgotten so the
next call retries. exportMermaid, exportDiagram.golden and
test/unit/diagramExport call it in a beforeAll with its own 60 s timeout
(MERMAID_LOAD_TIMEOUT_MS), so the global testTimeout is unchanged.

The DBML parsers were already outside any test's timeout: they are
static imports, loaded while vitest collects the file, and the first
readDbmlEverywhere costs ~15 ms, so exportDbml.test.ts is unchanged.

Closes #152

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… to the user

describeDomainError passed core's neutral "Resolve them in the file" message
straight through, which an assistant running the setup skill read as an
instruction to resolve the conflict itself. For a DomainFileError with
mergeConflict it now says what doctor's resolve-merge-conflicts step and the
model-file hint say: show the user the file and line, ask which side to keep,
never pick a side or run git. Core's message is unchanged (the Confluence app
shows it); the code stays merge-conflict and the path stays project-relative.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, and read aliases as the reader does

Dropping an empty `dataType:` beside a `data_type` the reader uses now
merges every comment on both keys (the dropped key's first) instead of
keeping one and discarding the rest, and gives the next key a blank line
only when the source really had one — the yaml library marks one after
any empty value that soaked up the comment lines below it. The rename
rule also resolves a `dataType` alias before deciding it is empty, so an
alias of `~` reads as empty here as it does in core's reader, and doctor
no longer says to delete the `data_type` the type actually comes from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ad of resolving them

Depends on #148 (#145): it teaches the /erd-studio-setup skill the signals
that PR adds to the CLI, and must merge after it.

- SKILL.md (Stage 1): a `resolve-merge-conflicts` step, a `merge-conflict`
  error or a `fix-model-yaml` with `mergeConflict: true` means git left two
  versions in a file; stop, follow troubleshooting.md, never pick a side or
  run git. Stage 4 step 7 and Stage 5 carve the conflict out of "fix every
  fix-model-yaml". Still 498 lines (four paragraphs rewrapped to make room).
- references/troubleshooting.md: new "Merge conflicts" section - every
  signal (`erd.conflictedDomainFiles`, `erd.unreadableModelFiles` /
  `mergeConflict: true`, diff code `merge-conflict`), show file and line in
  plain words, positions-only means either side is safe (edit only on the
  user's say-so), otherwise ask; never `git add` / `checkout --ours/--theirs`
  / `merge` / `commit` or any git command; re-run doctor once saved.
- references/verify-and-fix.md, building-the-model.md: a merge-conflict
  fix-model-yaml is not a YAML fix; "a key twice" now reads "written twice
  by hand".
- SCHEMA_CONTENT: the YAML quoting rule's "fix that before anything else"
  gets the same exception.
- HARNESS_VERSION 28 -> 29; fixture schema skill regenerated (marker + the
  one SCHEMA_CONTENT line). allowed-tools unchanged (no git pre-approved).
- Tests pin the guidance against doctor.ts / diff.ts's own spellings.

Closes #150

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ot SyntaxError

A canvas open before git wrote conflict markers into its domain file still
draws the old document; an edit made before the refresh parsed the document
with a bare JSON.parse, so the refusal toast read as a raw parser error.

Every user-facing read of the open domain document (applyDomainEdit, the
edit handlers, the relationship write, Add from dbt, the sync plan) now goes
through one helper, parseDomainDocument, over core's parseDomainJson — the
load path's parser. An empty, invalid or conflicted document throws
EditRefused with core's plain message ("…has unresolved git merge conflicts
(first at line N)…"), the file named project-relative, before any
WorkspaceEdit is built, so nothing is written and the handler posts one
error toast. Telemetry sniffs (recordCanvasOpen, recordRelationshipState),
the other-panels refresh and the other-domains rewrite keep their tolerant
JSON.parse. Valid documents edit exactly as before.

Depends on #148 (core's merge-conflict DomainFileError, #145): merge after it.

Closes #151

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the empty dataType beside a data_type (its key or value), or the
data_type key itself, carries a YAML anchor, a write no longer renames
or drops either key: dropping it left aliases such as *x dangling and
the file unparseable, and writing into it would retype every alias. The
type stays in data_type, where the reader takes it from, and an edit is
written there. dataTypeAliasFate returns 'anchored' for this case, so
doctor lists those columns apart (erd.dataTypeAliasFiles[].anchored).
readDefaultsLeftOut now reads aliases through, so `dataType: *x` of an
empty anchor stays an alias instead of becoming `unknown`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ours/theirs and re-run wording

The CLI sets `mergeConflict: true` only on `unreadableModelFiles[]` entries
(doctor's `erd.unreadableModelFiles`, diff's `unreadableModelFiles`); the
`fix-model-yaml` fix and doctor step carry no such field. SKILL.md, the
verify-and-fix, building-the-model and troubleshooting references and the
SCHEMA_CONTENT quoting rule now say "a fix-model-yaml whose
unreadableModelFiles entry has mergeConflict: true" (fixture schema skill
regenerated). A test checks the CLI types and fails on any skill text that
puts the flag on the fix.

Troubleshooting: conflict labels are described by content, since ours and
theirs swap during a rebase; after the assistant edits the file on the
user's say-so it re-runs doctor itself. SKILL.md Stage 5 again says the
file does not parse. Still 498 lines; no HARNESS_VERSION bump (29 is
unreleased).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…agram; a refusal isn't addFromDbtFailed

The #151 tests now drive Add models from dbt (refused before the picker
opens, and refused by the queued re-read when git conflicts the diagram
while the picker is open) and Generate Sync Plan on the physical stage
(refused, no plan file written), each with one plain merge-conflict toast
and nothing written.

Both Add-from-dbt catch sites now skip EditRefused / RelationshipsRewriteRefused
when counting addFromDbtFailed, through one isExplainedRefusal helper that
the relationship handlers' recordRelationshipFailure also uses: a refusal the
toast explains is not a breakage. This also stops the existing "model file
has unsaved changes" refusal in those two handlers from counting as a
failure. Telemetry only; nothing user-visible changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
syncMap replaced a managed key whose value was an alias (`dataType: *x`)
with a plain copy of the resolved value on every save, even when nothing
changed, dropping any comment on the line. That was already true on develop
for `dataType: *x # c` (and aliased flags, scdType, meta, rationale); the
data_type rename (#144) extended it to renamed `data_type: *x` keys, and to
the comment foldEmptyDataType moves onto the alias.

An alias that already reads as the desired value — compared as core's reader
reads the key: str() for text, bool() for flags, Number() for scdType, core's
own parser for meta / rationale / columns — is now left alone. One that
changed is replaced by the new value, carrying the alias's comments (a block
map or list takes them as its first lines). syncMeta replaces a changed alias
the same way. A dropped empty dataType's comment stays on the renamed key's
line only when it was written on the dropped key's own line; a comment line
below it stays a line above the renamed key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
11e1e26 kept an alias (`description: *g`) that already read as the model
wants. Keys are synced in MODEL_KEYS / COLUMN_KEYS order, not file order, so
a key synced later could still rewrite that alias's anchor in place
(`grain: &g foo` -> `bar`), and the kept alias then read the new value: a
key the canvas never changed was retyped.

Every alias a save judges to read as wanted — kept in syncMap or syncMeta,
under a key left alone, the data_type kept for an anchored column, or inside
a relationship entry that already matches — is now noted with a detached copy
of what it read at that moment. After every write, one that no longer reads
that (its anchor was rewritten, or dropped) becomes that plain value, keeping
the line's comments; one whose anchor did not change stays an alias. So
after a save every key the canvas did not change reads as before, every
changed key reads its new value, and no comment is lost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ases

The yaml library keeps the text a scalar was parsed from in `source`, and
the reader (core's scalarValue, and this service's) reads a number by that
text. A scalar changed in place kept its old `source`, so after
`scdType: &s 2` was edited to 1 the save still read the anchor as 2: an
alias kept beside it (`scdType: *s` in another column, `description: *s`,
a `meta` value) was not settled and followed the anchor to 1.

Every in-place scalar write in the model sync — syncMap's update and its
coerced-text pin, syncMeta, and the data_type key rename — now goes through
setScalarValue, which clears `source` with the value. Nothing prints from
`source`, so the file text is unchanged. A scalar whose new value is a map
or list is replaced by a node rather than written into the scalar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
doctor's fix-model-yaml step says "fix them before anything else", and a
model file with git conflict markers now arrives there, yet it was listed
after install-dbt, run-deps, run-parse and run-catalog. A model file that
does not parse renders as an empty model whatever state dbt is in — the
same reasoning that puts a conflicted diagram first — so the step now sits
straight after resolve-merge-conflicts (and still after confirm-venv).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Appending it at the end of ### Fixed collided with #149's line in the same
place. Beside the #145 entry it reads as the follow-on it is and merges
cleanly with the other open branches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix: say "merge conflict" when a diagram file holds git conflict markers (#145)
fix: read dbt's data_type key in model files instead of dropping the type (#144)
…rlay

fix(webview): Retry no longer flashes a "Canvas disconnected" overlay (#149)
test: load the Mermaid parsers once per file, outside any test's timeout (#152)
fix(skill): the setup guide asks the user about merge conflicts instead of resolving them (#150)
…diagram

fix(canvas): an edit on a conflicted diagram says "merge conflict", not SyntaxError (#151)
…157)

- An alias inside a relationships: entry (role: *r) no longer blocks every
  save: the entry is read with its aliases resolved against the file.
- Every alias in the file, not only those under managed keys, is kept as it
  reads before the save; one whose anchor the save rewrites, removes or
  moves after it is written out as the value it read, keeping its comment,
  so an unmanaged key never follows an edit and the save never fails with
  "Unresolved alias".
- A number prints as the text it was written with (007, 1e3, .5, +1), and a
  scalar the reader already reads as the wanted value is left as written,
  so description: true is not quoted and a reused number settles in one save.
- A plain value that becomes a map or list keeps the line's comment.
- Move Relationships to Model Library leaves (and names) a model file whose
  relationships: list holds a YAML anchor or alias, rather than re-render it
  and drop the anchor.
- A seeded randomised test covers anchors and aliases across managed,
  unmanaged and relationship keys (1500 files, edits, idempotence).
- CHANGELOG: one Added section, stray blank line removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e reader did not take as text (#157)

Review findings on the #157 fix:
- Keeping and settling every alias called Alias.resolve, which walks the
  whole document, once per alias: a save of a file with thousands of aliases
  took seconds to minutes. Both passes now resolve every alias in one walk.
- A relationship entry's keys are read only as text, so `role: true` holds
  no role; the "already reads the same" check let a canvas role "true" leave
  the boolean in place and the edit was lost. Those keys must now match as
  text exactly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix: YAML anchors, aliases and numbers in model files survive a save (#157)
@liam-machine
liam-machine merged commit 24a0b6e into main Oct 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment