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:
6
packages/settings/settings-file/README.i18n.yaml
Normal file
6
packages/settings/settings-file/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings-file/README.md
|
||||
README.md: 78ff50bd3f9b7c0bd52d50a837bc3cb7dad7c5be
|
||||
README.zh.md: 2be0494bd7fd40cb59ff3999f5405adc49ff2a2d
|
||||
45
packages/settings/settings-file/README.md
Normal file
45
packages/settings/settings-file/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @deepseek-ai/dsh-settings-file
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home |
|
||||
| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` |
|
||||
| `watch` | Watch the document and hot-publish external edits | `true` |
|
||||
| `debounceMs` | Watcher write-settle window in milliseconds | `100` |
|
||||
|
||||
Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
|
||||
- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
|
||||
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
|
||||
- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
|
||||
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
|
||||
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
|
||||
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
|
||||
- **The native watcher receives a canonical path.** Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix. File access and user-facing diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form event paths inside libuv.
|
||||
- **Dispose quiesces in every watch mode.** Teardown marks the provider closed, closes the watcher when present, then waits out every queued or in-flight document operation, so nothing publishes after disposal.
|
||||
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
|
||||
- **Host configuration adapters receive the resolved path.** `ctx.settings.documentPath` is the absolute `resolveSpec()` filename, including a custom YAML/JSON path; `prepareDocument()` preserves an existing file or exclusively creates an absent empty file with owner-only permissions before the Host opens it. The browser receives only an availability flag, never reconstructs `$DSH_HOME`, and never submits a filesystem target.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the consuming plugin owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check.
|
||||
- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart.
|
||||
- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described.
|
||||
- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.
|
||||
45
packages/settings/settings-file/README.zh.md
Normal file
45
packages/settings/settings-file/README.zh.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# @deepseek-ai/dsh-settings-file
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
基于文件的设置提供方。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 含义 | 默认 |
|
||||
|---|---|---|
|
||||
| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json`) | harness home 下的 `settings.yaml` |
|
||||
| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME` 或 `~/.dsh` |
|
||||
| `watch` | 监听文档并热发布外部编辑 | `true` |
|
||||
| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` |
|
||||
|
||||
默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。
|
||||
|
||||
## 行为
|
||||
|
||||
- **启动失败并明确报错,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
|
||||
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入会明确报错并拒绝执行,而不是覆盖用户的手工编辑。
|
||||
- **写入持有跨进程写锁。** 读-渲染-rename 流程在以 `wx` 创建的同级锁文件 `<file>.lock` 保护下运行,带指数退避与 2 s 的获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
|
||||
- **写回原子、仅属主可访问、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
|
||||
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
|
||||
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
|
||||
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
|
||||
- **原生 watcher 接收规范化路径。** 在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀。文件访问和面向用户的诊断仍使用配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。
|
||||
- **dispose(资源释放)在每种 watch 模式下都保证完全停稳。** 卸载先把提供方标记为已关闭,在 watcher 存在时将其关闭,再等待所有已排队或进行中的文档操作完成,之后不再有任何发布。
|
||||
- **按内容抑制自写。** 提供方缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
||||
- **Host 配置适配器会收到解析后的路径。** `ctx.settings.documentPath` 是 `resolveSpec()` 得出的绝对文件名,包括自定义 YAML/JSON 路径;`prepareDocument()` 会保留现有文件,或在 Host 打开文档前,以仅属主可访问的权限独占创建缺失的空文件。浏览器只收到可用性标志,绝不重建 `$DSH_HOME`,也绝不提交文件系统目标。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接生效:本提供方只存储并发布 namespace 分节,任何模型效果都经由 `ctx.settings` 的消费方产生,并由各消费方自己的接口文档说明。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接失效;请求前缀的任何变更均由消费方插件负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
|
||||
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
|
||||
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
|
||||
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是暂缓实现的 seam 层功能。
|
||||
53
packages/settings/settings-file/package.json
Normal file
53
packages/settings/settings-file/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-settings-file",
|
||||
"description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/settings/settings-file"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-home-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
370
packages/settings/settings-file/src/index.ts
Normal file
370
packages/settings/settings-file/src/index.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* File-backed settings provider. One YAML or JSON document under the user's
|
||||
* harness home carries every namespace section; external edits hot-publish
|
||||
* through the seam, and every write re-reads the document under a
|
||||
* cross-process writer lock before patching it as a comment-preserving
|
||||
* leaf-level diff.
|
||||
* @module @deepseek-ai/dsh-settings-file
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { SettingsProvider, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Plugin config: file location and hot-reload behavior. */
|
||||
export interface Config {
|
||||
/** Settings document path; defaults to `settings.yaml` under the harness home. */
|
||||
path?: string
|
||||
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Watch the document and hot-publish external edits; defaults to true. */
|
||||
watch?: boolean
|
||||
/** Watcher write-settle window in milliseconds; defaults to 100. */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/** Document format derived from the configured file extension. */
|
||||
type SettingsFormat = 'yaml' | 'json'
|
||||
|
||||
const FORMATS: Record<string, SettingsFormat> = {
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.json': 'json',
|
||||
}
|
||||
|
||||
/** Fully resolved provider parameters; defaulting happens here, never inline. */
|
||||
interface ResolvedSpec {
|
||||
filename: string
|
||||
format: SettingsFormat
|
||||
watch: boolean
|
||||
debounceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime spec from plugin config: an explicit `path` wins,
|
||||
* otherwise the document lives at `<harness home>/settings.yaml`.
|
||||
* @param config - raw plugin config.
|
||||
* @returns the resolved file location, format, and watch behavior.
|
||||
*/
|
||||
export function resolveSpec(config: Config): ResolvedSpec {
|
||||
const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
|
||||
const format = FORMATS[extname(filename)]
|
||||
if (format === undefined) {
|
||||
throw new Error(`settings-file: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
|
||||
}
|
||||
return {
|
||||
filename,
|
||||
format,
|
||||
watch: config.watch ?? true,
|
||||
debounceMs: config.debounceMs ?? 100,
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a parsed YAML value is a map for diffing purposes. */
|
||||
function isMapLike(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the difference between one node's stored and next value as minimal
|
||||
* `setIn`/`deleteIn` edits, recursing through maps, so every untouched node —
|
||||
* and the key node of every changed pair — keeps its comments, anchors, and
|
||||
* formatting. Non-map values (arrays and scalars) replace wholesale when
|
||||
* unequal, taking any comments inside them along.
|
||||
*/
|
||||
function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void {
|
||||
if (isMapLike(current) && isMapLike(next)) {
|
||||
for (const key of Object.keys(current)) {
|
||||
if (!(key in next)) document.deleteIn([...path, key])
|
||||
}
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
patchNode(document, [...path, key], current[key], value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!deepEqualJson(current, next)) document.setIn([...path], next)
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Whether an exclusive file create found an existing document. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/** File-backed settings provider (`settings.yaml`/`.json`). */
|
||||
export class FileSettingsProvider extends SettingsProvider {
|
||||
static Config: z<Config> = z.object({
|
||||
path: z.string(),
|
||||
dshHome: z.string(),
|
||||
watch: z.boolean().default(true),
|
||||
debounceMs: z.number().min(0).default(100),
|
||||
})
|
||||
|
||||
private readonly spec: ResolvedSpec
|
||||
/**
|
||||
* Raw text of the last successfully parsed or persisted document;
|
||||
* `undefined` while the file is absent. Watcher events whose content equals
|
||||
* this cache are no-ops, which is also the self-write suppression.
|
||||
*/
|
||||
private text: string | undefined
|
||||
/**
|
||||
* Single exclusive operation chain: watcher reloads and document writes run
|
||||
* one at a time in queue order (settled tail), so a write can never render
|
||||
* from text a concurrent reload is busy replacing, and a reload can never
|
||||
* read a half-committed write.
|
||||
*/
|
||||
private operations: Promise<void> = Promise.resolve()
|
||||
/** Set at dispose: refuse new watcher events and let in-flight work no-op. */
|
||||
private closed = false
|
||||
|
||||
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
|
||||
private isClosed(): boolean {
|
||||
return this.closed
|
||||
}
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx)
|
||||
// Programmatic construction may bypass Schemastery normalization; resolve
|
||||
// the same defaults in one explicit step either way.
|
||||
this.spec = resolveSpec(config)
|
||||
}
|
||||
|
||||
/** The local document is always writable through {@link SettingsProvider.update}. */
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** The resolved YAML/JSON document path exposed to local configuration surfaces. */
|
||||
override get documentPath(): string {
|
||||
return this.spec.filename
|
||||
}
|
||||
|
||||
/** Materialize an absent owner-only document, then return its resolved path. */
|
||||
override prepareDocument(): Promise<string> {
|
||||
return this.enqueue(async () => {
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
try {
|
||||
await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
|
||||
} catch (error) {
|
||||
if (isEEXIST(error)) return
|
||||
throw error
|
||||
}
|
||||
this.text = ''
|
||||
if (!this.isClosed()) this.publish({})
|
||||
})
|
||||
return this.spec.filename
|
||||
})
|
||||
}
|
||||
|
||||
protected async load(): Promise<Record<string, unknown>> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
this.text = undefined
|
||||
return {}
|
||||
}
|
||||
const doc = this.parse(text)
|
||||
this.text = text
|
||||
return doc
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
// One document backs every namespace, so writes from different namespace
|
||||
// queues serialize with each other and with watcher reloads on the one
|
||||
// operation chain: each render must see the text the previous operation
|
||||
// committed, or a sibling section silently vanishes from disk.
|
||||
return this.enqueue(() => this.persistSection(ns, section))
|
||||
}
|
||||
|
||||
/** Queue one exclusive document operation behind every earlier one. */
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const task = this.operations.then(operation)
|
||||
this.operations = task.then(() => undefined, () => undefined)
|
||||
return task
|
||||
}
|
||||
|
||||
/** Queue a reload; only an invariant violation escaping a commit can reject it. */
|
||||
private queueRefresh(): void {
|
||||
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
|
||||
// Only an invariant violation escaping the commit path can reject a
|
||||
// refresh; keep the operation queue alive and surface it as an error so
|
||||
// one poisoned commit cannot silently end hot reloading forever.
|
||||
this.ctx.logger.error('settings-file: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
// The writer lock's exclusive create needs the parent to exist before
|
||||
// writeFileAtomic gets its own chance to create it.
|
||||
// 0700: the harness home holds user-private documents.
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
// Read-modify-write: fold in any on-disk state this process has not
|
||||
// observed yet — an external edit still inside the watcher debounce
|
||||
// window, a change the watcher missed, or another process's write — so
|
||||
// the render below can never resurrect a stale document. An unparsable
|
||||
// on-disk document fails the write loud instead of silently overwriting
|
||||
// a user's manual edit.
|
||||
await this.reconcileFromDisk()
|
||||
const output = this.spec.format === 'yaml'
|
||||
? this.renderYaml(ns, section)
|
||||
: this.renderJson(ns, section)
|
||||
// 0600: a document that may hold personal values is never world-readable.
|
||||
await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
|
||||
this.text = output
|
||||
})
|
||||
}
|
||||
|
||||
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
// The base init loads and publishes; a parse failure there is a boot
|
||||
// failure: an existing-but-invalid document must fail loud, never be
|
||||
// silently ignored or overwritten.
|
||||
yield* super[Service.init]()
|
||||
const watcher = this.spec.watch
|
||||
? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: this.spec.debounceMs,
|
||||
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
||||
},
|
||||
})
|
||||
: undefined
|
||||
if (watcher !== undefined) {
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('ready', () => {
|
||||
// The base init's load raced the watcher's own setup: a change written
|
||||
// between that read and the watcher becoming active never fires an
|
||||
// event. One reconcile at ready closes the gap.
|
||||
if (this.closed) return
|
||||
this.queueRefresh()
|
||||
})
|
||||
watcher.on('error', (error) => {
|
||||
this.ctx.logger.warn('settings-file: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
}
|
||||
yield async () => {
|
||||
// Quiesce every operation chain, even when no watcher is configured.
|
||||
this.closed = true
|
||||
await watcher?.close()
|
||||
await this.operations
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one document text into raw sections, failing on a non-map root. */
|
||||
private parse(text: string): Record<string, unknown> {
|
||||
let root: unknown
|
||||
if (this.spec.format === 'yaml') {
|
||||
// `prettyErrors` is on only for `linePos`; `error.message` is never
|
||||
// used, because the parser quotes the offending source line and a
|
||||
// settings document can hold a `role('secret')` value.
|
||||
const document = parseDocument(text, { prettyErrors: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`settings-file: invalid document at ${this.spec.filename}: ${
|
||||
document.errors.map((error) => {
|
||||
const at = error.linePos?.[0]
|
||||
/* v8 ignore next -- `prettyErrors` populates linePos on every error; the guard answers its optional type */
|
||||
return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
|
||||
}).join('; ')}`)
|
||||
}
|
||||
root = document.toJS() ?? {}
|
||||
} else {
|
||||
root = text.trim().length === 0 ? {} : JSON.parse(text)
|
||||
}
|
||||
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
|
||||
throw new TypeError(`settings-file: ${this.spec.filename} must be a map of namespace sections`)
|
||||
}
|
||||
return root as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the document after a watcher event. Unchanged content (including
|
||||
* this provider's own writes) is a no-op; an unreadable or unparsable
|
||||
* document keeps the last good sections and warns — a live hot-reload must
|
||||
* never take the process down. An invariant violation escaping a commit is
|
||||
* not a reload failure and propagates to the queue's error surface.
|
||||
*/
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.closed) return
|
||||
try {
|
||||
await this.reconcileFromDisk()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
||||
this.ctx.logger.warn('settings-file: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the on-disk text against the cache and publish any difference
|
||||
* into the seam. Absence publishes the empty document; an unreadable or
|
||||
* unparsable file throws, so each caller picks its policy — a reload warns
|
||||
* and keeps the last good document, a write fails loud.
|
||||
*/
|
||||
private async reconcileFromDisk(): Promise<void> {
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(this.spec.filename, 'utf8')
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
text = undefined
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
if (text === undefined) {
|
||||
this.text = undefined
|
||||
this.publish({})
|
||||
return
|
||||
}
|
||||
const doc = this.parse(text)
|
||||
this.text = text
|
||||
this.publish(doc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the next YAML text by patching one namespace in the
|
||||
* comment-preserving document. The next section lands as a leaf-level diff
|
||||
* against the stored one — only changed values set, only removed keys
|
||||
* delete — so comments inside the section survive edits to their siblings,
|
||||
* not just comments outside it.
|
||||
*/
|
||||
private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
|
||||
if (this.text === undefined) {
|
||||
return new Document({ [ns]: section }).toString()
|
||||
}
|
||||
// this.text only ever caches content that parsed successfully, so this
|
||||
// re-parse (for the mutable comment-preserving tree) cannot fail, and
|
||||
// parse() already rejected any non-map root.
|
||||
const document = parseDocument(this.text)
|
||||
const root: unknown = document.toJS()
|
||||
patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section)
|
||||
return document.toString()
|
||||
}
|
||||
|
||||
/** Render the next JSON text by replacing one namespace key. */
|
||||
private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
|
||||
const root = this.text === undefined
|
||||
? {}
|
||||
: this.parse(this.text)
|
||||
root[ns] = section
|
||||
return `${JSON.stringify(root, null, 2)}\n`
|
||||
}
|
||||
}
|
||||
|
||||
export default FileSettingsProvider
|
||||
31
packages/settings/settings-file/src/invariant.ts
Normal file
31
packages/settings/settings-file/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-settings-file`.
|
||||
* @module @deepseek-ai/dsh-settings-file/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-settings-file'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'settings-file-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this provider's contracts are file round-trip,
|
||||
* watcher timing, and atomic-write behavior — IO effects proven by package
|
||||
* tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
98
packages/settings/settings-file/tests/concurrency.spec.ts
Normal file
98
packages/settings/settings-file/tests/concurrency.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
145
packages/settings/settings-file/tests/loader-composition.spec.ts
Normal file
145
packages/settings/settings-file/tests/loader-composition.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
439
packages/settings/settings-file/tests/local.spec.ts
Normal file
439
packages/settings/settings-file/tests/local.spec.ts
Normal 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' }])
|
||||
})
|
||||
})
|
||||
126
packages/settings/settings-file/tests/lock-race.spec.ts
Normal file
126
packages/settings/settings-file/tests/lock-race.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
225
packages/settings/settings-file/tests/watcher.spec.ts
Normal file
225
packages/settings/settings-file/tests/watcher.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
33
packages/settings/settings-file/tsconfig.json
Normal file
33
packages/settings/settings-file/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/home-paths"
|
||||
},
|
||||
{
|
||||
"path": "../settings"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user