fix(settings-local): one operation chain, read-modify-write under a writer lock, and diff-shaped YAML edits

Review round three found the provider's write path could destroy state it
never observed:

- Watcher reloads and document writes ran on two independent promise
  chains, and a write rendered the whole next document from the cached
  text. An external edit still inside the debounce window (or missed
  outright) was overwritten, and the follow-up reload no-oped because the
  post-rename content matched the cache — the edit vanished without a
  trace. Reloads and writes now share one operation chain, and every write
  starts by reconciling the on-disk text into the seam before rendering,
  so unobserved sibling sections survive and publish first. An unparsable
  on-disk document fails the write loud instead of being overwritten.
- The initial load raced the watcher's own setup: a change written between
  that read and the watcher becoming active never fired an event. The
  watcher's ready signal now queues one reconcile, closing the gap.
- Two processes sharing a harness home rendered from independent caches,
  last writer winning. Writes now hold a wx-created <file>.lock sibling
  around the read-render-rename cycle with bounded backoff, a crashed-
  holder stale takeover, and a deadline failure; readers stay lock-free
  because the rename commit is atomic.
- renderYaml replaced the whole namespace node, dropping every comment
  inside the section. The next section now lands as a leaf-level diff
  (set changed values, delete removed keys), so comments, anchors, and
  formatting survive on every untouched node and on the key of every
  changed pair; arrays still replace wholesale when unequal.
This commit is contained in:
Yichen Jiang
2026-07-30 13:39:22 +08:00
parent bdc6d95d56
commit 85a3a158dd
5 changed files with 545 additions and 56 deletions

View File

@@ -204,6 +204,102 @@ describe('persist', () => {
expect(written).toContain('theme: light')
})
it('keeps comments inside the section when a sibling key changes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'ui-theme:',
' # chosen during onboarding',
' theme: light',
' fontSize: 12',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ fontSize: 18 })
const written = await readFile(path, 'utf8')
expect(written).toContain('# chosen during onboarding')
expect(written).toContain('theme: light')
expect(written).toContain('fontSize: 18')
})
it('keeps a changed key\'s own-line comment while replacing its value', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'ui-theme:',
' # chosen during onboarding',
' theme: light',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'dark' })
const written = await readFile(path, 'utf8')
expect(written).toContain('# chosen during onboarding')
expect(written).toContain('theme: dark')
})
it('deletes only the removed key on replace, keeping sibling comments', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'ui-theme:',
' # chosen during onboarding',
' theme: light',
' fontSize: 12',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.replace({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('# chosen during onboarding')
expect(written).toContain('theme: light')
expect(written).not.toContain('fontSize')
})
it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const TagsSchema: z<{ tags: string[]; label: string }> = z.object({
tags: z.array(z.string()).default([]),
label: z.string().default(''),
})
await writeFile(path, [
'workspace:',
' tags:',
' # pinned by hand',
' - alpha',
' label: draft',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema)
await scope.update({ label: 'final' })
const untouched = await readFile(path, 'utf8')
expect(untouched).toContain('# pinned by hand')
expect(untouched).toContain('label: final')
// A changed array replaces wholesale; comments inside it go with it.
await scope.update({ tags: ['beta'] })
const replaced = await readFile(path, 'utf8')
expect(replaced).not.toContain('# pinned by hand')
expect(replaced).toContain('- beta')
})
it('keeps a comment-only document\'s comment when the first section lands', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
// Parses to a null root: the document exists but holds no sections yet.
await writeFile(path, '# reserved for future settings\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('# reserved for future settings')
expect(written).toContain('theme: light')
})
it('creates a json document from scratch', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')