Persist a created page to markfluence.yaml - #153
Conversation
SetNested sets fields inside the mapping reached by a path of keys --
{"pages", "docs/a.md"} addresses one file's entry -- creating any level that
does not exist yet. #139's write half needs it so create can record a page in
markfluence.yaml instead of in the file.
It lives here rather than in internal/project so goccy stays confined to one
package. The dialect, its refusals, and every rule about emitting a value that
reads back as itself are one package's business; a second package building
nodes is how two spellings of "write a YAML scalar" come to disagree.
The sharp part was measured rather than guessed, and it is why the write
verifies itself. **A node's column is what goccy indents by**, so a mapping
value built at the default position comes out at the left margin however deep
it was appended -- appending `space: ENG` inside a pages: entry emits it as a
*top-level setting*. That is valid YAML saying something entirely different,
and nothing downstream would refuse it: the loader would read a project-wide
space nobody wrote. indentColumn is two spaces per level, the same fix
seqIndentColumn already applies to a block sequence's items.
So verifyNested re-reads the result and asks whether each field landed at the
path holding what was asked for, not merely whether the document parses --
"did it parse" proves nothing against a field at the wrong depth. Sabotaging
indentColumn to return 1 turns four tests red with "the rewritten file lost
docs/b.md" rather than writing a corrupt file.
Three smaller measured things. goccy renders a one-pair mapping as a
MappingValueNode and a multi-pair one as a MappingNode, so a single-field
entry arrives in a different shape and is promoted. A sequence is always
emitted flow, unlike a frontmatter rewrite which preserves the style it found:
a block sequence's item column depends on its mapping's depth, so preserving
block style would add a second place where a miscomputed column silently
changes meaning. And a comment-only file -- which is exactly what the marker
ships as and what export plants -- keeps its comment, which rides along with
the first key written.
SetPageEntry writes one file's entry into markfluence.yaml, creating the block and the entry as needed, so create can record a page where that file's metadata already lives (#139 D9). Read-modify-write, once per page rather than once per run, and that is the point rather than laziness: create writes each file's frontmatter as that page is published, so a run that dies partway leaves every already-created page recorded. The manifest has to keep that property, and one small file read and written per created page is the price (D10). Two verifications, not one. frontmatter.SetNested already re-reads its own output and checks each field landed at the path. This then re-runs the *loader* over the result -- parseConfig, split out of loadConfig for exactly this -- so a write producing a file markfluence could not read, or could read as something else, fails before anything reaches disk. A tool that corrupts the file it is recording success in is the worst version of this feature, and a test pins that a file which would not load afterwards is left byte-identical. r.Config is refreshed on success, so a caller that goes on to resolve another file in the same run sees the entry it just wrote -- which create needs, since it publishes a batch against one root. A root with no project file refuses rather than creating one: whether markfluence may create a project file is #5's question, not something a create should answer silently.
…re its metadata lives Completes #139's bootstrap flow: a pristine markdown file, published, with its coordinates recorded in the project file rather than written into the markdown. It replaces PR 1's interim refusal, which created the page and told the author to copy the id into the entry by hand. Where the metadata goes is **inferred, not flagged** (D9): to the manifest when a pages: entry already claims the file, or when the project has chosen the manifest and the file says nothing; into the file's frontmatter otherwise. So a project that has chosen the manifest never accidentally grows frontmatter, a project without one behaves exactly as before, and a half-migrated tree does not sprout entries behind the author -- a file that already carries its own frontmatter keeps it, which a test pins. persistToManifest is writeBackFrontmatter's counterpart: the same five fields in the same canonical order, in the other location. parent is written as the resolved id, exactly as the frontmatter path writes it; unlike frontmatter there is no room for a trailing comment naming the original path, and none is needed -- the entry's key already says which file this is, which is what that comment existed to disambiguate. One bug the live run found, which the verification caught rather than wrote to disk: `pages: {}` is a *flow* mapping, and it is exactly the shape a project that has chosen the manifest and registered nothing has. Appending a block entry to a flow mapping emits "pages: {\n a.md:" and does not parse. A populated flow mapping needed its existing children moved too, since they carry the columns flow gave them. toBlock handles both; style is deliberately not preserved, since a nested entry cannot be written in flow style without re-emitting the whole line. Verified live end to end: create a pristine parent, create a pristine child under it, then `update docs/*.md` publishes both from the manifest -- with both markdown files still carrying no frontmatter at all. Pages purged.
CLAUDE.md gains nested.go's measured trap -- a node's column is what goccy indents by, so a value built at the default position turns a field inside a pages: entry into a top-level setting -- and why verifyNested asks "did it land at the path" rather than "did it parse". Plus SetPageEntry's write-once-per-page reasoning and its double verification. The user-facing wart the live run exposed is now documented in docs/root-model.md and docs/markdown_file.md: a `parent:` naming a .md file is root-relative inside an entry and file-relative in frontmatter and on `create --parent`. The translation is deliberate and nothing breaks, but moving a value between the two locations by hand needs re-spelling -- and `create` records the resolved id, so a round trip never hits it. _plans/039 records PR 2 as built, including that it was descoped to create rather than blocked on #151: it needed nothing from fix.
Including the two things the live run found that the unit tests had not -- the
flow-style pages: {} mapping, and the parent-spelling asymmetry between an
entry and frontmatter -- and that PR 2 was descoped to create rather than
blocked on #151, since it needed nothing from fix.
Found by a live run rather than by the unit tests, and caught by
verifyNested rather than written to disk.
`pages: {}` is a *flow* mapping, and it is exactly the shape a project that
has chosen the manifest and registered nothing has -- the shape the docs
recommend. Appending a block entry to a flow mapping emits "pages: {" and a
newline, which does not parse, so create failed after creating the page
rather than recording it.
A *populated* flow mapping needs its existing children moved too: they carry
the columns flow gave them, and converting only the outer mapping produced a
different unparseable file. toBlock re-positions recursively.
Style is deliberately not preserved, unlike a frontmatter sequence rewrite: a
nested entry cannot be written in flow style without re-emitting the whole
line, and block is the only readable style for a mapping of mappings. Valid
YAML in a readable style is the contract; matching an author's flow braces is
not.
…places A successful create was leaving a file permanently unpublishable, which is the worst thing in the review. toManifest sent a file with *both* an entry and its own frontmatter to the manifest, writing the resolved `parent: 101` into the entry while the file kept `parent: p.md`. The two locations then disagree about a coordinate, so every later update and check of that file fails until somebody edits one by hand. It is PR 1's "self-inflicted corruption" mirrored -- and this time it came from a run that reported success. The rule is now "the project has chosen the manifest **and** the file carries no markfluence frontmatter of its own", which is what D9's "wherever that file's metadata already is" means for a file whose metadata is already there. A test reproduces the old behaviour by reverting the condition and fails with the disagreement message. Two more from the same review: A file with no manifest key is refused too. One outside the root has none (KeyFor reports false), and writing an entry keyed by the empty string produced a file that would not parse -- where before this change it simply got frontmatter, which is still the right answer. labels is no longer written to an entry. writeBackFrontmatter has never written labels, so the manifest path was writing a sixth field its counterpart does not -- and writing them rewrote them, since labels.Set carries the normalized names: a declared [Runbook, ci/cd] came back [ci/cd, runbook] while the case warning still told the author to update the file to match. And a persist failure is classified with CodeOr rather than a hardcoded VALIDATION, so a read-only project file reports IO as the frontmatter path already does (#133). Tests: D10's claim is now pinned by an actual mid-batch failure -- the previous test exercised none, so a single deferred write at the end would have passed it -- and asserts the earlier pages are recorded and the failed one keeps its page id.
…that needs it
Two more shapes the writer refused that the loader accepts. Both ended the
same way: create made the page and recorded it nowhere, which is the state
D10 exists to prevent.
**indentColumn was a constant**, so any pages: block not indented by exactly
two spaces could never be written to. A four-space file -- which the loader
reads happily -- produced "the rewritten file does not parse". One-space was
worse in kind: the output nested the new entry *inside* its predecessor, valid
YAML saying something else, caught only because verifyNested checks depth
rather than parseability. childColumn now takes the column from an existing
sibling.
Getting that right needed two more measured details. An *empty* mapping has no
sibling to ask, so it uses its own column -- falling back to the constant put a
just-created entry's first field at the entry key's column, making it the
entry's sibling rather than its child. And toBlock resets the converted
mapping's own column, since a flow mapping's token sits at the brace: leaving
it put the first entry written into `pages: {}` at column 8.
**Keys were never quoted.** Values have had the readsBackAs fallback since
#130; keys had nothing, so a legitimate filename holding a YAML indicator --
`#x.md`, `- a.md`, `a: b.md`, `!a.md`, `|a.md`, a tab, a leading space -- was
refused rather than quoted. keyNode applies the same discipline to the other
side of the colon, and an existing quoted key is matched rather than
duplicated.
Three more from the review, all the same shape: the writer assumed a file it had written while the loader accepts a wider dialect, and each gap ended as "page created, entry not written". **A BOM-prefixed project file could never be written to.** parseConfig strips one deliberately (#100), so it is a shape markfluence accepts -- but the writer saw it, parsed the first key as "pages", created a *second* pages: block beside it, and the reload then refused the file. It now comes off before the write and goes back on after, rather than being silently dropped from a file the author's editor wrote. **An entry written under an un-normalized key got a duplicate.** Keys are compared after NormalizePageKey, so "./b.md" *is* the entry for "b.md" -- matching on raw text appended a second spelling, and the loader then refused the file for having two keys naming one path. existingKeySpelling finds the entry that is there and updates it. **The write is atomic now.** os.WriteFile truncates first, so an interrupted write leaves the project file truncated -- and unlike the frontmatter path, which risks one page's metadata, this file holds *every* entry in the project. replaceFile writes a temporary file in the same directory and renames, so an interrupted run leaves the original untouched. An existing file's mode is preserved.
Ten, with no corruption or data loss -- the verification held. The recurring defect is the lesson: the writer assumed a file it had written (two-space indent, LF, no BOM, normalized bare keys) while the loader accepts a far wider dialect, and every gap became "page created, entry not written", the state D10 and S7 exist to prevent. The severest produced a broken state from a *successful* run: a file with keys in both locations had a resolved parent id written into its entry while its frontmatter kept a path, leaving the two in permanent disagreement.
|
Adversarial review found ten; all fixed in four commits. No corruption or data loss — The recurring defect was one level down, and it's the lesson worth keeping: the writer assumed a file it had written — two-space indent, LF, no BOM, normalized bare keys — while the loader accepts a far wider dialect. Every gap became "page created, entry not written", which is exactly the state D10 and S7 exist to prevent. 1. A successful run could leave a file permanently unpublishableThe worst of the ten, and the only one that produced a broken state without reporting a failure.
That's PR 1's "self-inflicted corruption" mirrored — and this time from a run that said 2-5. The dialect gaps, each of which created a page and recorded nothingA constant indent. Any Getting that right needed two more measured details, and I'd have missed both without probing: an empty mapping has no sibling to ask, so it uses its own column — falling back to the constant put a just-created entry's first field at the entry key's column, making it the entry's sibling rather than its child. And Keys were never quoted. Values have had the A BOM. An un-normalized existing key. A file with no manifest key — one outside 6-9. SmallerA persist I/O failure was reported as 10. The write is atomic now
Test qualityBoth findings were fair and both are fixed:
Known and acceptedMatching
|
A concurrency window this PR opens. SetPageEntry is a read-modify-write with no serialization, and the project file is shared by every page -- where before the manifest each page's metadata went into its own file, so two concurrent creates could not collide. They now can: A reads, B reads, A writes, B writes, and A's entry is gone while A's page exists. That is "page created, entry not recorded" one more time, arriving from concurrency rather than from a refusal. Bounded but live: create docs/*.md is sequential within one process and page creation is a human act (#139 keeps it out of CI), but concurrent sessions against one repository are a normal working arrangement here. Optimistic rather than locked. trySetPageEntry re-reads immediately before replacing and reports a change instead of overwriting; SetPageEntry retries once against the new content and reports the collision if it happens twice. The precedent is client.SetContentProperty, which retries once on top of a versioned PUT for the same shape of reason. A lock file would be stronger and would bring stale-lock handling with it, which is more machinery than a verb a person invokes by hand warrants -- and one retry suffices because the window is a single file rewrite, so the loser re-reads the winner's file and merges. The give-up path is driven by beforeReplace, a nil-in-production test hook, the SetRetryLogger arrangement: racing a real writer makes a slow and flaky test of a branch that exists so a collision is never resolved by guessing. The first version of the test wrote the competing entry *before* calling SetPageEntry, so the initial read already saw it and no collision happened -- it passed with the detection removed, which is how it was caught. Removed rather than kept, since it claimed to test concurrency and did not.
|
One more, found by following the review's own pattern into a dimension neither pass was pointed at.
|
**toBlock computed its column from an absolute depth**, which is the very bug
childColumn was added to fix -- still present on the flow path. A document
whose root mapping is itself indented (" pages: {}") is legal YAML the loader
accepts, and the entry then came out at pages:' own column: "the rewritten
file lost pages", or at four-space indent a parse failure. Via create that is
a page on the server with nothing recording it, the D10 class this PR set out
to close. Every child column now comes from the parent key's real position.
**The CodeOr classification was dead code, and the plan's claim of symmetry
was false.** CodeOr only consults CodeFor when client.FromRequest(err) is
true, and SetPageEntry always returns a *ConfigError -- so every failure fell
through to the hardcoded VALIDATION, exactly what the previous commit said it
had fixed. ConfigError now carries IO, project.IsIOFailure exposes it, and
create picks CodeIO for a write failure as the frontmatter path does (#133).
**replaceFile replaced a symlinked project file with a regular one.**
os.WriteFile, which the frontmatter path uses, follows a symlink, so making
the write atomic changed behaviour: a markfluence.yaml symlinked to a shared
config quietly became a local copy and later edits to the shared file stopped
applying. It writes through the link now.
**create's help still said metadata goes into the frontmatter.** CLAUDE.md
makes CMD --help the reference, so that is the documented place for it: the
Long and both persist flags now say where a page is recorded and that
markfluence.yaml is a shared file being modified. The schema's createResult
description covered only a frontmatter persist failure.
**SetNested dropped Field.Comment** where Render and setField honour it.
Exported and taking []Field, so the next caller to set one -- recording the
original parent: path the way parentField does -- would have lost it in
silence. It goes through valueWithComment now.
The most useful finding corrects a claim this plan already made: the CodeOr classification was dead code, since CodeOr only consults CodeFor for an error that came from a request and SetPageEntry always returns a *ConfigError. So the claimed symmetry with the frontmatter path never existed, in the plan, the commit message, or the PR comment. The narrow lesson: a fix that routes through a helper is not a fix until the helper's precondition has been checked. And toBlock's absolute column was the third instance of one class, recurring because each fix was applied where the bug was found rather than everywhere the pattern lived.
|
A second review pass, local this time, found five — and the most useful of them corrects a fix I claimed to have made in the comment above. The
|
PR 2 of 2 for #139 — the write half.
markfluence createnow records a new page's metadata in the project file'spages:block, so the bootstrap flow works end to end and a markdown file never needs frontmatter at all.This is what PR 1 left as a hand-copy step, and it replaces PR 1's interim refusal (
this file's metadata lives in markfluence.yaml; fix cannot reconcile a pages: entry yetwasfix;createtold you to paste the id into the entry yourself).After which
markfluence.yamlholds both entries and both markdown files still contain nothing but their bodies.Scoped to
createThe plan gave PR 2
fixmigrating inline keys as well. #151 now proposes removingfixaltogether, so that half is an open question rather than dropped work — and PR 2 needed nothing from it, which is why it was descoped rather than blocked on #151.The writer, and the trap that shaped it
frontmatter.SetNestedlives ininternal/frontmatterrather thaninternal/projectso goccy stays confined to one package: the dialect, its refusals, and every rule about emitting a value that reads back as itself are one package's business, and a second package building nodes is how two spellings of "write a YAML scalar" come to disagree.Its sharp edge was measured, not reasoned about. A node's column is what goccy indents by, so a mapping value built at the default position is emitted at the left margin however deep it was appended — appending
space: ENGinside apages:entry comes out as a top-level setting:Valid YAML, entirely different meaning, and nothing downstream would refuse it — the loader would read a project-wide space nobody wrote.
indentColumnis two spaces per level, the same fixseqIndentColumnalready applies to a block sequence's items.So the write verifies itself, and asks the right question.
verifyNestedre-reads its own output and checks each field landed at the path holding what was asked for — not merely that the document parses, because "did it parse" proves nothing against a field at the wrong depth. SabotagingindentColumnto return 1 turns four tests red withthe rewritten file lost "docs/b.md"instead of writing a corrupt file.project.SetPageEntryverifies twiceRead-modify-write, once per page rather than once per run — because
createwrites each file's frontmatter as that page is published, so a run that dies partway leaves every already-created page recorded. The manifest had to keep that property (D10).Then two verifications:
SetNested's own, andparseConfig(split out ofloadConfig) re-running the loader's rules over the result, so a write producing a file markfluence could not read — or could read as something else — fails before anything reaches disk. A tool that corrupts the file it is recording success in is the worst version of this feature, and a test pins that a file which would not load afterwards is left byte-identical.A root with no project file refuses rather than creating one: whether markfluence may create a project file is #5's question, not something a
createshould answer silently.Where metadata goes is inferred, not flagged
Per D9: to the manifest when a
pages:entry already claims the file, or when the project has chosen the manifest and the file says nothing; into the file's frontmatter otherwise. So a project that has chosen the manifest never accidentally grows frontmatter, a project without one behaves exactly as before, and a half-migrated tree does not sprout entries behind the author — a file that already carries its own frontmatter keeps it, which a test pins.--no-persistrecords nothing anywhere.persistToManifestiswriteBackFrontmatter's counterpart: the same five fields, the same canonical order, the other location.parentis written as the resolved id exactly as the frontmatter path writes it; unlike frontmatter there is no room for a trailing comment naming the original path, and none is needed — the entry's key already says which file this is, which is what that comment existed to disambiguate.Two things the live run found that the unit tests had not
pages: {}is a flow mapping — and it is precisely the shape a project that has chosen the manifest and registered nothing has, which is the shape the docs recommend. Appending a block entry to a flow mapping emitspages: {followed by a newline and does not parse. A populated flow mapping needed its children re-positioned too, since they carry the columns flow gave them.toBlockhandles both, converting to block rather than preserving style: a nested entry cannot be written in flow style without re-emitting the whole line.Worth noting how this surfaced —
verifyNestedrefused the write andcreatereported a failure with the page id intact, rather than leaving a corrupt project file. That is the argument for verifying, demonstrated by accident.A
parent:naming a.mdfile is spelled differently in the two locations — root-relative inside an entry (like everypages:key), file-relative in frontmatter and oncreate --parent(matching how markdown links work). The translation is deliberate and nothing breaks, but it is a trap for anyone moving a value between locations by hand, so it is now documented inroot-model.mdandmarkdown_file.md.createrecords the resolved id rather than a path, so a round trip never hits it.Verified live
Against the personal space on
mozilla-hub.atlassian.net, frompages: {}:Both markdown files verified byte-identical to
# Parent/# Childafterwards. Pages purged.Tests
SetNestedon every shape that comes up: a new entry beside an existing one, creating the whole path, a comment-only file (what the marker ships as and whatexportplants), quoting,page_idas an integer, a nullparent, an empty declaredlabels: [], flow→block for both empty and populated, goccy's one-pairMappingValueNodeshape, canonical field order, and a refusal for a non-mapping level.SetPageEntryfor the write, the in-place update leaving neighbours alone, the refusal leaving the file byte-identical, and no project file.createfor the manifest write, the frontmatter path when there is nopages:, a frontmatter-carrying file keeping its frontmatter,--no-persist, and recording each page as it goes. The two blockers-by-sabotage:indentColumnandtoManifest.make checkclean.