refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,98 @@
// Cross-instance and writer-lock behavior: two providers on one document are
// the in-process equivalent of two dsh processes sharing a harness home —
// neither knows the other's cache, so only the read-modify-write cycle under
// the `<file>.lock` sibling keeps both namespaces alive on disk.
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { FileSettingsProvider } from '../src/index.ts'
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof FileSettingsProvider>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(FileSettingsProvider, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('cross-instance writes', () => {
it('keeps both namespaces when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
const alpha = first.settings.register(settingsNamespace('alpha'), AlphaSchema)
const beta = second.settings.register(settingsNamespace('beta'), BetaSchema)
const rounds = [1, 2, 3, 4, 5]
await Promise.all([
(async () => { for (const value of rounds) await alpha.update({ value }) })(),
(async () => { for (const value of rounds) await beta.update({ value }) })(),
])
const text = await readFile(path, 'utf8')
expect(text).toContain('alpha:')
expect(text).toContain('beta:')
// A third instance resolves both final values from the shared document.
const third = await boot({ path, watch: false })
expect(third.settings.register(settingsNamespace('alpha'), AlphaSchema).get()).toEqual({ value: 5 })
expect(third.settings.register(settingsNamespace('beta'), BetaSchema).get()).toEqual({ value: 5 })
})
})
describe('writer lock', () => {
it('waits for a busy writer lock instead of failing', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await writeFile(`${path}.lock`, 'holder\n')
const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120)
cleanups.push(async () => { clearTimeout(release) })
await scope.update({ value: 7 })
expect(await readFile(path, 'utf8')).toContain('value: 7')
})
it('does not steal an old writer lock', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'alpha:\n value: 4\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
const lockPath = `${path}.lock`
await writeFile(lockPath, 'slow-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(lockPath, past, past)
await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/)
expect(await readFile(path, 'utf8')).toContain('value: 4')
expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n')
}, 10_000)
it.skipIf(process.platform === 'win32')('surfaces a non-contention lock failure as the write error', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await chmod(dir, 0o500)
cleanups.push(() => chmod(dir, 0o700))
await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/)
})
})

View File

@@ -0,0 +1,145 @@
/**
* Real-composition guard: the provider and a consumer plugin boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml hot-publishes into the consumer's scope, and the same
* consumer booted WITHOUT a settings entry keeps its entry-config resolution —
* the documented optional-inject fallback.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import z from '@deepseek-ai/schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import FileSettingsProvider from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
interface ConsumerState {
scope: SettingsScope<ThemeConfig> | undefined
seen: ThemeConfig[]
/** What the consumer is actually running with, settings or not. */
applied: ThemeConfig | undefined
}
async function loadComposition(
options?: { withSettings?: boolean },
): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
const withSettings = options?.withSettings ?? true
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
const state: ConsumerState = { scope: undefined, seen: [], applied: undefined }
const consumer = {
name: 'settings-consumer',
apply: (ctx: Context) => {
// The documented consumer shape: no hard dependency — entry config alone
// is the running state, and the scoped inject overlays the user layer
// only while a settings service exists.
const base: Partial<ThemeConfig> = { fontSize: 16 }
state.applied = ThemeSchema(base as ThemeConfig)
ctx.inject(['settings'], (child: Context) => {
const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base })
state.scope = scope
state.applied = scope.get()
scope.watch((next) => {
state.seen.push(next)
state.applied = next
})
})
},
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
...withSettings
? [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-file'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
]
: [],
'- id: consumer',
' name: test-settings-consumer',
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-settings-file', FileSettingsProvider],
['test-settings-consumer', consumer],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, state, settingsPath }
}
describe('settings-file real composition', () => {
it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
const { ctx, state, settingsPath } = await loadComposition()
// Composition resolution: user layer over the consumer's composition base.
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
})
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
})
it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => {
const { ctx, state } = await loadComposition({ withSettings: false })
// No settings service anywhere in the composition…
expect(ctx.get('settings')).toBeUndefined()
// …so the consumer runs on schema defaults plus its composition base, and
// never receives a scope.
expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 })
expect(state.scope).toBeUndefined()
expect(state.seen).toEqual([])
})
})

View File

@@ -0,0 +1,439 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { FileSettingsProvider, resolveSpec } from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof FileSettingsProvider>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(FileSettingsProvider, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('resolveSpec', () => {
it('defaults watch and debounce when construction bypasses schema normalization', () => {
const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
expect(spec.watch).toBe(true)
expect(spec.debounceMs).toBe(100)
})
})
describe('boot and reads', () => {
it('resolves defaults over an absent file and reports writable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
expect(ctx.settings.writable).toBe(true)
expect(ctx.settings.documentPath).toBe(path)
})
it('prepares an absent owner-only document without changing resolved settings', async () => {
const dir = await tempDir()
const path = join(dir, 'nested', 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe('')
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('preparing an existing document preserves its contents', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const contents = 'ui-theme:\n theme: light\n'
await writeFile(path, contents)
const ctx = await boot({ path, watch: false })
await expect(ctx.settings.prepareDocument()).resolves.toBe(path)
expect(await readFile(path, 'utf8')).toBe(contents)
})
it('reads sections from an existing yaml document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
})
it('reads sections from a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('defaults the file location under the configured harness home', async () => {
const dir = await tempDir()
const ctx = await boot({ dshHome: dir, watch: false })
expect(ctx.settings.documentPath).toBe(join(dir, 'settings.yaml'))
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
expect(written).toContain('theme: light')
})
it('reads an empty yaml document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('reads an empty json document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it.skipIf(process.platform === 'win32')('fails loud at boot when the document exists but is unreadable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
})
it('fails loud when the document path names a directory', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await mkdir(path)
await expect(boot({ path, watch: false })).rejects.toThrow(/EISDIR|directory/i)
})
it('fails loud on an unsupported extension', async () => {
const dir = await tempDir()
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
.rejects.toThrow(/not supported/)
})
it('fails loud at boot on unparsable yaml', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 })
await expect(boot({ path, watch: false })).rejects.toThrow()
})
it('fails loud at boot when the root is not a map of sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '- just\n- a list\n')
await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
})
})
describe('persist', () => {
it('writes the merged section, creating the file with owner-only permissions', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
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('theme: light')
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
// Atomic replace leaves no temp artifact behind.
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
it('serializes cross-namespace writes into one on-disk document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema)
const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema)
await Promise.all([
alpha.update({ theme: 'light' }),
beta.update({ fontSize: 20 }),
])
const text = await readFile(path, 'utf8')
expect(text).toContain('alpha:')
expect(text).toContain('beta:')
expect(alpha.get().theme).toBe('light')
expect(beta.get().fontSize).toBe(20)
})
it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const victim = join(dir, 'victim.txt')
await writeFile(victim, 'precious')
// A hostile sibling plants the historic fixed temp name as a symlink.
await symlink(victim, `${path}.tmp`)
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
expect(await readFile(victim, 'utf8')).toBe('precious')
expect((await lstat(path)).isSymbolicLink()).toBe(false)
if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await readFile(path, 'utf8')).toContain('theme: light')
})
it('preserves comments and unregistered sections across updates', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'# personal settings',
'ui-theme:',
' theme: light',
'# owned by a plugin that is not loaded right now',
'future-plugin:',
' keep: me',
'',
].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('# personal settings')
expect(written).toContain('# owned by a plugin that is not loaded right now')
expect(written).toContain('keep: me')
expect(written).toContain('fontSize: 18')
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')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
})
it('rejects and recovers when the document path becomes a directory', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const backup = join(dir, 'settings.committed.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await rename(path, backup)
await mkdir(path)
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
await rm(path, { recursive: true })
await rename(backup, path)
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
expect(scope.get().theme).toBe('light')
// The failed persist must not poison the document write chain.
await scope.update({ theme: 'dark' })
expect(scope.get().theme).toBe('dark')
})
it('round-trips a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
})
})
describe('watch', () => {
it('publishes an external edit to registered scopes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get().theme).toBe('light')
await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
})
it('keeps the last good document over an invalid edit, then recovers', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
// Replace the external edit atomically so this case observes one complete
// invalid document instead of a transient empty file during truncation.
await writeFileAtomic(path, 'ui-theme: [unclosed\n', { mode: 0o600 })
// The bad edit must never take the live tree down or reset the value.
await new Promise(resolve => setTimeout(resolve, 300))
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
await writeFileAtomic(path, 'ui-theme:\n theme: dark\n', { mode: 0o600 })
await vi.waitFor(() => {
expect(scope.get().theme).toBe('dark')
}, { timeout: 5000 })
})
it('treats file removal as an empty document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await rm(path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
}, { timeout: 5000 })
})
it('does not republish its own persisted write', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 10 })
const events: unknown[] = []
ctx.on('settings/updated', (ns, _next, _prev, source) => {
events.push({ ns, source })
})
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
await new Promise(resolve => setTimeout(resolve, 300))
expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
})
})

View File

@@ -0,0 +1,126 @@
// A temp-file write failure cannot be timed from outside. The `fs/promises` API
// injects it once so the test can prove that the writer lock still releases.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { FileSettingsProvider } from '../src/index.ts'
const state = vi.hoisted(() => ({
failTempWrite: false,
failDocumentCreate: false,
holdDocumentCreate: false,
documentCreateStarted: undefined as (() => void) | undefined,
continueDocumentCreate: undefined as Promise<void> | undefined,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
writeFile: (async (path: unknown, ...rest: never[]) => {
if (state.holdDocumentCreate && String(path).endsWith('settings.yaml')) {
state.holdDocumentCreate = false
state.documentCreateStarted!()
await state.continueDocumentCreate!
}
if (state.failDocumentCreate && String(path).endsWith('settings.yaml')) {
state.failDocumentCreate = false
throw Object.assign(new Error('ENOSPC: injected document create failure'), { code: 'ENOSPC' })
}
if (state.failTempWrite && String(path).endsWith('.tmp')) {
state.failTempWrite = false
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
}
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
}) as typeof actual.writeFile,
}
})
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
state.failTempWrite = false
state.failDocumentCreate = false
state.holdDocumentCreate = false
state.documentCreateStarted = undefined
state.continueDocumentCreate = undefined
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lockrace-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof FileSettingsProvider>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(FileSettingsProvider, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('writer-lock failure cleanup', () => {
it('skips publication when an in-flight document create completes during teardown', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = new Context()
const fiber = ctx.plugin(FileSettingsProvider, { path, watch: false })
cleanups.push(async () => { await fiber.dispose() })
await fiber
const settings = ctx.settings
settings.register(settingsNamespace('alpha'), AlphaSchema)
const published: number[] = []
ctx.on('settings/document-updated', (_ns, revision) => { published.push(revision) })
let markStarted!: () => void
const started = new Promise<void>((resolve) => { markStarted = resolve })
let releaseCreate!: () => void
state.continueDocumentCreate = new Promise<void>((resolve) => { releaseCreate = resolve })
state.documentCreateStarted = markStarted
state.holdDocumentCreate = true
const preparing = settings.prepareDocument()
await started
let disposed = false
const disposing = fiber.dispose()
void disposing.then(() => { disposed = true })
await vi.waitFor(() => {
expect((settings as unknown as { closed: boolean }).closed).toBe(true)
})
expect(disposed).toBe(false)
releaseCreate()
await expect(preparing).resolves.toBe(path)
await disposing
expect(await readFile(path, 'utf8')).toBe('')
expect(published).toEqual([])
})
it('surfaces an exclusive document-create failure and releases the lock', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
state.failDocumentCreate = true
await expect(ctx.settings.prepareDocument()).rejects.toThrow(/ENOSPC/)
await expect(access(`${path}.lock`)).rejects.toThrow()
})
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'alpha:\n value: 1\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
state.failTempWrite = true
await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/)
// The document is untouched and the writer lock was released on the way out.
expect(await readFile(path, 'utf8')).toContain('value: 1')
await expect(access(`${path}.lock`)).rejects.toThrow()
})
})

View File

@@ -0,0 +1,225 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { FileSettingsProvider } from '../src/index.ts'
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
vi.mock('chokidar', async () => {
const { EventEmitter } = await import('node:events')
class FakeWatcher extends EventEmitter {
close = vi.fn(() => Promise.resolve())
}
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
return {
watch: vi.fn((path: string, options: unknown) => {
const watcher = new FakeWatcher()
instances.push({ path, options, watcher })
return watcher
}),
__instances: instances,
}
})
interface FakeChokidar {
__instances: Array<{
path: string
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
watcher: import('node:events').EventEmitter
}>
}
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
const chokidar = await import('chokidar') as unknown as FakeChokidar
return chokidar.__instances
}
const ThemeSchema: z<{ theme: string }> = z.object({
theme: z.string().default('dark'),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof FileSettingsProvider>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(FileSettingsProvider, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(scope.get()).toEqual({ theme: 'dark' })
await writeFile(path, 'ui-theme:\n theme: light\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'light' })
})
})
it('keeps the last good document when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'light' })
})
it('keeps the reload queue alive after an invariant violation escapes a commit', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let arm = true
ctx.on('settings/updated', () => {
if (!arm) return
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const [instance] = await fakeInstances()
await writeFile(path, 'ui-theme:\n theme: broken-commit\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get().theme).toBe('broken-commit')
})
arm = false
await writeFile(path, 'ui-theme:\n theme: recovered\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get().theme).toBe('recovered')
})
})
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = new Context()
const fiber = ctx.plugin(FileSettingsProvider, { path, debounceMs: 5 })
await fiber
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let disposed = false
let postDisposeCommits = 0
ctx.on('settings/updated', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'ui-theme:\n theme: darker\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('all', 'change', path)
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('ready')
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'dark' })
})
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const editor = ctx.settings.register(settingsNamespace('editor'), z.object({
tabWidth: z.number().default(2),
}))
// The external edit has landed on disk but its watcher event has not
// fired yet (a debounce window, or a missed event): the write must fold
// it in, not resurrect the stale document.
await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n')
await theme.update({ theme: 'darker' })
const text = await readFile(path, 'utf8')
expect(text).toContain('tabWidth: 8')
expect(text).toContain('theme: darker')
// The fold published the unobserved section before the write committed.
expect(editor.get()).toEqual({ tabWidth: 8 })
})
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, 'ui-theme:\n theme: written-before-ready\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(() => {
expect(scope.get().theme).toBe('written-before-ready')
})
})
it('fails a write loud when the on-disk document turned invalid unobserved', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const broken = 'ui-theme: [unclosed\n flow: {\n'
await writeFile(path, broken)
await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/)
// The user's manual edit stays on disk untouched and the cache keeps the
// last good value.
expect(await readFile(path, 'utf8')).toBe(broken)
expect(scope.get()).toEqual({ theme: 'light' })
})
})