Merge branch 'worktree-config-settings-seam' into worktree-llm-dynamic-config
# Conflicts: # docs/capability-seams.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/module-graph.md # examples/tui-agent/cordis.yml # packages/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/llm/llm-deepseek/README.i18n.yaml # packages/llm/llm-deepseek/README.zh.md # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.zh.md # packages/settings/settings-local/src/index.ts # packages/util/README.i18n.yaml # packages/util/README.md # packages/util/README.zh.md # scripts/doc-budgets.manifest.json
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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-local/README.md
|
||||
README.md: af8df7c030757b330e034a1c46507fbe75c9bab8
|
||||
README.zh.md: fc8943263b339baad1a92a1d0b0977b926e40f6e
|
||||
README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257
|
||||
README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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()` writes back atomically while preserving the user's YAML comments and any section owned by a plugin that is not currently loaded.
|
||||
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
|
||||
|
||||
@@ -18,9 +18,13 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
|
||||
## 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.
|
||||
- **Write-back is atomic, owner-only, and symlink-proof.** `persist` 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 writes patch one namespace in the comment-preserving document; JSON re-serializes.
|
||||
- **Cross-namespace writes serialize on one document.** Every namespace shares the file, so persists from different namespace queues chain internally; each render sees the text the previous write committed.
|
||||
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight reload, so nothing publishes after disposal.
|
||||
- **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, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). 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.
|
||||
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight 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.
|
||||
|
||||
## Model Experience
|
||||
@@ -33,6 +37,7 @@ No direct invalidation; the consuming plugin owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No cross-process write lock** — concurrent writers (for example TUI and web on one home) converge by atomic replace plus watcher reload, last write wins; a lockfile is deferred until real contention shows up.
|
||||
- **Comment preservation is YAML-only** — JSON documents re-serialize without comments (JSON has none) and lose hand formatting.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 原子写回,并保留用户的 YAML 注释以及当前未加载插件所拥有的分节。
|
||||
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -18,9 +18,13 @@
|
||||
## 行为
|
||||
|
||||
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
|
||||
- **写回原子、仅属主可读、抗符号链接。** `persist` 以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。YAML 写回在保留注释的文档里只修补目标 namespace;JSON 重新序列化。
|
||||
- **跨 namespace 写入在同一文档上串行。** 所有 namespace 共享一个文件,来自不同 namespace 队列的 persist 在内部串联;每次渲染都基于上一次写入提交后的文本。
|
||||
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的重载,之后不再有任何发布。
|
||||
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。
|
||||
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
|
||||
- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
|
||||
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
|
||||
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
|
||||
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态,因此其间写入的变更绝不会触发事件;ready 时的对账补上这个启动缺口。
|
||||
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher,再等完排队与进行中的操作,之后不再有任何发布。
|
||||
- **按内容抑制自写。** provider 缓存最后可用文本;watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
|
||||
|
||||
## Model Experience
|
||||
@@ -33,6 +37,7 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无跨进程写锁** — 并发写入者(例如同一 home 上的 TUI 与 web)靠原子替换加 watcher 重载收敛,后写胜出;lockfile 等真实冲突出现再做。
|
||||
- **注释保留仅限 YAML** — JSON 文档重新序列化,无注释(JSON 本身没有)且丢失手工排版。
|
||||
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
|
||||
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
|
||||
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
|
||||
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* 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 `update()` writes back preserving the user's comments.
|
||||
* 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-local
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { extname, join, resolve } from 'node:path'
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { Settings, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Plugin config: file location and hot-reload behavior. */
|
||||
export interface Config {
|
||||
@@ -64,11 +66,53 @@ export function resolveSpec(config: Config): ResolvedSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 create failed because the path already exists. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer-lock protocol constants. These are robustness invariants of the
|
||||
* cross-process write protocol, not deployment tunables: a holder rewrites one
|
||||
* small document in milliseconds, so contention resolves well inside the
|
||||
* retry deadline, and a lock older than the stale age can only belong to a
|
||||
* crashed holder.
|
||||
*/
|
||||
const LOCK_RETRY_INITIAL_MS = 20
|
||||
const LOCK_RETRY_MAX_MS = 200
|
||||
const LOCK_TIMEOUT_MS = 2_000
|
||||
const LOCK_STALE_MS = 5_000
|
||||
|
||||
/** File-backed settings provider (`settings.yaml`/`.json`). */
|
||||
export class SettingsLocal extends Settings {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -85,10 +129,13 @@ export class SettingsLocal extends Settings {
|
||||
* this cache are no-ops, which is also the self-write suppression.
|
||||
*/
|
||||
private text: string | undefined
|
||||
/** Serializes watcher-triggered reloads so reads never interleave. */
|
||||
private refreshTask: Promise<void> = Promise.resolve()
|
||||
/** Serializes whole-document writes across namespace queues; settled tail. */
|
||||
private persistChain: Promise<void> = Promise.resolve()
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -125,21 +172,99 @@ export class SettingsLocal extends Settings {
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
// One document backs every namespace, so writes from different namespace
|
||||
// queues must serialize here: each render must see the text the previous
|
||||
// write committed, or the loser's section silently vanishes from disk.
|
||||
// The stored tail is settled on both outcomes, so chaining needs no catch.
|
||||
const task = this.persistChain.then(() => this.persistSection(ns, section))
|
||||
this.persistChain = task.then(() => undefined, () => undefined)
|
||||
// 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-local: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
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 })
|
||||
this.text = output
|
||||
// The writer lock's exclusive create needs the parent to exist before
|
||||
// writeFileAtomic gets its own chance to create it.
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true })
|
||||
await this.withWriterLock(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 })
|
||||
this.text = output
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the cross-process writer lock around one read-render-rename cycle.
|
||||
* The lock is a `wx`-created sibling (`<file>.lock`); the rename-based
|
||||
* commit keeps readers lock-free, so only writers contend. A lock older
|
||||
* than {@link LOCK_STALE_MS} is a crashed holder and is broken with a
|
||||
* warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write.
|
||||
*/
|
||||
private async withWriterLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${this.spec.filename}.lock`
|
||||
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
||||
let delay = LOCK_RETRY_INITIAL_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
|
||||
break
|
||||
} catch (error) {
|
||||
if (!isEEXIST(error)) throw error
|
||||
}
|
||||
const ageMs = await this.lockAgeMs(lockPath)
|
||||
// The holder released between the failed create and the stat: the lock
|
||||
// is free right now, so retry without burning backoff or deadline.
|
||||
if (ageMs === undefined) continue
|
||||
if (ageMs > LOCK_STALE_MS) {
|
||||
this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath)
|
||||
await rm(lockPath, { force: true })
|
||||
continue
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
|
||||
}
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
await rm(lockPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Age of the writer lock, or `undefined` when it vanished after a failed create. */
|
||||
private async lockAgeMs(lockPath: string): Promise<number | undefined> {
|
||||
try {
|
||||
return Date.now() - (await stat(lockPath)).mtimeMs
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
@@ -157,13 +282,14 @@ export class SettingsLocal extends Settings {
|
||||
})
|
||||
watcher.on('all', () => {
|
||||
if (this.closed) return
|
||||
this.refreshTask = this.refreshTask.then(() => this.refresh()).catch((error: unknown) => {
|
||||
// Only an invariant violation escaping the commit path can reject a
|
||||
// refresh; keep the reload queue alive and surface it as an error so
|
||||
// one poisoned commit cannot silently end hot reloading forever.
|
||||
this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename)
|
||||
this.ctx.logger.error(error)
|
||||
})
|
||||
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-local: watcher error on %s', this.spec.filename)
|
||||
@@ -171,10 +297,10 @@ export class SettingsLocal extends Settings {
|
||||
})
|
||||
yield async () => {
|
||||
// Quiesce: stop accepting events, close the watcher, then wait out any
|
||||
// queued or in-flight refresh so nothing publishes after disposal.
|
||||
// queued or in-flight operation so nothing publishes after disposal.
|
||||
this.closed = true
|
||||
await watcher.close()
|
||||
await this.refreshTask
|
||||
await this.operations
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,46 +327,62 @@ export class SettingsLocal extends Settings {
|
||||
* 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.
|
||||
* 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
|
||||
let text: string
|
||||
try {
|
||||
await this.reconcileFromDisk()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
|
||||
this.ctx.logger.warn('settings-local: 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)) {
|
||||
this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
return
|
||||
}
|
||||
if (this.text === undefined || this.isClosed()) return
|
||||
if (!isENOENT(error)) throw error
|
||||
text = undefined
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
if (text === undefined) {
|
||||
this.text = undefined
|
||||
this.publish({})
|
||||
return
|
||||
}
|
||||
if (text === this.text || this.isClosed()) return
|
||||
let doc: Record<string, unknown>
|
||||
try {
|
||||
doc = this.parse(text)
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
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. */
|
||||
/**
|
||||
* 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.
|
||||
// re-parse (for the mutable comment-preserving tree) cannot fail, and
|
||||
// parse() already rejected any non-map root.
|
||||
const document = parseDocument(this.text)
|
||||
document.set(ns, section)
|
||||
const root: unknown = document.toJS()
|
||||
patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section)
|
||||
return document.toString()
|
||||
}
|
||||
|
||||
|
||||
103
packages/settings/settings-local/tests/concurrency.spec.ts
Normal file
103
packages/settings/settings-local/tests/concurrency.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// 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 'cordis'
|
||||
import z from '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 { SettingsLocal } 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 SettingsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, 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('breaks a stale writer lock with a warning and writes through', 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`, 'crashed-holder\n')
|
||||
const past = (Date.now() - 60_000) / 1000
|
||||
await utimes(`${path}.lock`, past, past)
|
||||
await scope.update({ value: 9 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 9')
|
||||
})
|
||||
|
||||
it('times out on a lock a live holder never releases', 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`, 'busy-holder\n')
|
||||
await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/)
|
||||
}, 10_000)
|
||||
|
||||
it('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/)
|
||||
})
|
||||
})
|
||||
@@ -204,6 +204,102 @@ describe('persist', () => {
|
||||
expect(written).toContain('theme: light')
|
||||
})
|
||||
|
||||
it('keeps comments inside the section when a sibling key changes', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'ui-theme:',
|
||||
' # chosen during onboarding',
|
||||
' theme: light',
|
||||
' fontSize: 12',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ fontSize: 18 })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# chosen during onboarding')
|
||||
expect(written).toContain('theme: light')
|
||||
expect(written).toContain('fontSize: 18')
|
||||
})
|
||||
|
||||
it('keeps a changed key\'s own-line comment while replacing its value', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'ui-theme:',
|
||||
' # chosen during onboarding',
|
||||
' theme: light',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'dark' })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# chosen during onboarding')
|
||||
expect(written).toContain('theme: dark')
|
||||
})
|
||||
|
||||
it('deletes only the removed key on replace, keeping sibling comments', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, [
|
||||
'ui-theme:',
|
||||
' # chosen during onboarding',
|
||||
' theme: light',
|
||||
' fontSize: 12',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.replace({ theme: 'light' })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# chosen during onboarding')
|
||||
expect(written).toContain('theme: light')
|
||||
expect(written).not.toContain('fontSize')
|
||||
})
|
||||
|
||||
it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const TagsSchema: z<{ tags: string[]; label: string }> = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
label: z.string().default(''),
|
||||
})
|
||||
await writeFile(path, [
|
||||
'workspace:',
|
||||
' tags:',
|
||||
' # pinned by hand',
|
||||
' - alpha',
|
||||
' label: draft',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema)
|
||||
await scope.update({ label: 'final' })
|
||||
const untouched = await readFile(path, 'utf8')
|
||||
expect(untouched).toContain('# pinned by hand')
|
||||
expect(untouched).toContain('label: final')
|
||||
// A changed array replaces wholesale; comments inside it go with it.
|
||||
await scope.update({ tags: ['beta'] })
|
||||
const replaced = await readFile(path, 'utf8')
|
||||
expect(replaced).not.toContain('# pinned by hand')
|
||||
expect(replaced).toContain('- beta')
|
||||
})
|
||||
|
||||
it('keeps a comment-only document\'s comment when the first section lands', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
// Parses to a null root: the document exists but holds no sections yet.
|
||||
await writeFile(path, '# reserved for future settings\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: 'light' })
|
||||
const written = await readFile(path, 'utf8')
|
||||
expect(written).toContain('# reserved for future settings')
|
||||
expect(written).toContain('theme: light')
|
||||
})
|
||||
|
||||
it('creates a json document from scratch', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.json')
|
||||
|
||||
100
packages/settings/settings-local/tests/lock-race.spec.ts
Normal file
100
packages/settings/settings-local/tests/lock-race.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Writer-lock races that cannot be timed from outside: a contender whose lock
|
||||
// vanishes between the failed exclusive create and the stat, a stat failing
|
||||
// for a reason other than absence, and a temp-file write failing mid-cycle.
|
||||
// The fs/promises seam is partially mocked to inject exactly one failure at a
|
||||
// chosen path suffix; everything else passes through to the real filesystem.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from '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 { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** One-shot failure injections keyed by operation, matched on a path suffix. */
|
||||
failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
const inject = (op: 'writeFile' | 'stat', path: unknown): void => {
|
||||
const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix))
|
||||
if (index === -1) return
|
||||
const [failure] = state.failures.splice(index, 1)
|
||||
throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code })
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
inject('writeFile', path)
|
||||
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
|
||||
}) as typeof actual.writeFile,
|
||||
stat: (async (path: unknown, ...rest: never[]) => {
|
||||
inject('stat', path)
|
||||
return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
|
||||
}) as typeof actual.stat,
|
||||
}
|
||||
})
|
||||
|
||||
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
state.failures.length = 0
|
||||
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 SettingsLocal>[1]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SettingsLocal, config)
|
||||
cleanups.push(async () => { await fiber.dispose() })
|
||||
await fiber
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('writer-lock races', () => {
|
||||
it('retries immediately when the contending lock vanished before the stat', 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)
|
||||
// The exclusive create loses to a holder that releases before the stat:
|
||||
// no lock file actually exists, so the stat sees honest absence and the
|
||||
// very next attempt takes the lock.
|
||||
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
|
||||
await scope.update({ value: 3 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 3')
|
||||
})
|
||||
|
||||
it('propagates a stat failure that does not mean absence', 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)
|
||||
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
|
||||
state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' })
|
||||
await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/)
|
||||
})
|
||||
|
||||
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.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' })
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
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'
|
||||
@@ -155,6 +155,7 @@ describe('watcher pipeline', () => {
|
||||
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)
|
||||
})
|
||||
@@ -169,4 +170,56 @@ describe('watcher pipeline', () => {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/README.md
|
||||
README.md: ff6cdeb57a265dbaa9d5f50de1d558f1e3cb581f
|
||||
README.zh.md: d820a5c1fa804455c439f1a155e7628f5118a49b
|
||||
README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5
|
||||
README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d
|
||||
|
||||
@@ -9,10 +9,10 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
|
||||
- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
|
||||
- `get(ns)` — resolved value, `undefined` while unregistered.
|
||||
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
|
||||
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
|
||||
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
|
||||
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest.
|
||||
- Service teardown refuses new writes and drains every queued write before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
|
||||
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners.
|
||||
- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
|
||||
|
||||
## Provider contract
|
||||
|
||||
@@ -33,5 +33,5 @@ No direct invalidation; a consumer that folds a settings value into the request
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
|
||||
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider is last-write-wins).
|
||||
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).
|
||||
- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
|
||||
- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
|
||||
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
||||
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
||||
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
||||
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
||||
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener。
|
||||
- 服务卸载先拒绝新写入并排干全部排队写入后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
|
||||
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
|
||||
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
|
||||
|
||||
## Provider 契约
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
|
||||
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 为后写胜出)。
|
||||
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
|
||||
- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。
|
||||
|
||||
@@ -60,20 +60,24 @@ export interface SettingsScope<T> {
|
||||
/**
|
||||
* Observe committed changes to this namespace's resolved value. Invocations
|
||||
* of one callback run asynchronously, one at a time, in commit order; a
|
||||
* rejection is contained and logged like a sync throw.
|
||||
* rejection is contained and logged like a sync throw. After the disposer
|
||||
* returns, no further invocation starts — one already queued is skipped;
|
||||
* one already started still settles, and service disposal waits for it.
|
||||
* @param callback - invoked after each commit with the next and previous values.
|
||||
* @returns the disposer removing this observer.
|
||||
*/
|
||||
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
||||
/**
|
||||
* Merge a partial patch into this namespace's user layer and persist it.
|
||||
* @param patch - plain-object patch over the user section.
|
||||
* @param patch - plain-object patch over the user section; JSON-shaped data
|
||||
* only (non-JSON values reject with their path before anything persists).
|
||||
*/
|
||||
update(patch: object): Promise<void>
|
||||
/**
|
||||
* Replace this namespace's user section wholesale; absent keys re-inherit
|
||||
* the composition `base` and schema defaults (`replace({})` resets all).
|
||||
* @param section - the complete next user section.
|
||||
* @param section - the complete next user section; JSON-shaped data only,
|
||||
* as for {@link update}.
|
||||
*/
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
@@ -88,6 +92,11 @@ declare module 'cordis' {
|
||||
* Committed change to one registered namespace's resolved value. Emitted
|
||||
* after the provider persisted (for `update`) or published (`provider`)
|
||||
* the change; never emitted when the resolved value is deep-equal.
|
||||
* Listener failures are contained and logged — a sync throw and an async
|
||||
* rejection alike — except `INVARIANT`-coded failures, which rethrow
|
||||
* after every listener ran; that rethrow reaches the emitter only from
|
||||
* synchronous listeners, so invariant checks on this event must not be
|
||||
* async functions.
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
* @param next - the new resolved value.
|
||||
* @param prev - the previous resolved value.
|
||||
@@ -127,17 +136,76 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */
|
||||
function describeRejected(value: unknown): string {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null
|
||||
const name = proto?.constructor?.name
|
||||
return name === undefined || name === 'Object' ? 'a non-plain object' : `a ${name}`
|
||||
}
|
||||
return `a ${typeof value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one write input in a single walk that doubles as the durable-boundary
|
||||
* shape check: only JSON data (plain objects, arrays, strings, finite numbers,
|
||||
* booleans, `null`) may reach a provider document. `structuredClone` alone
|
||||
* would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then
|
||||
* silently distorts on the reload round-trip. `undefined` entries in objects
|
||||
* are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
|
||||
* an `undefined` array entry is rejected rather than coerced.
|
||||
* @param root - plain-object write input (caller-checked).
|
||||
* @param reject - builds the boundary error from a value label and its `$`-rooted path.
|
||||
* @returns the detached JSON-shaped clone.
|
||||
*/
|
||||
function cloneJsonShaped(
|
||||
root: Record<string, unknown>,
|
||||
reject: (label: string, path: string) => TypeError,
|
||||
): Record<string, unknown> {
|
||||
const visiting = new WeakSet<object>()
|
||||
const clone = (value: unknown, path: string): unknown => {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw reject('a non-finite number', path)
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (visiting.has(value)) throw reject('a circular reference', path)
|
||||
visiting.add(value)
|
||||
const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`))
|
||||
// Un-mark on exit so one object referenced twice without a cycle passes.
|
||||
visiting.delete(value)
|
||||
return entries
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
if (visiting.has(value)) throw reject('a circular reference', path)
|
||||
visiting.add(value)
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined) continue
|
||||
out[key] = clone(entry, `${path}.${key}`)
|
||||
}
|
||||
visiting.delete(value)
|
||||
return out
|
||||
}
|
||||
throw reject(describeRejected(value), path)
|
||||
}
|
||||
return clone(root, '$') as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer `over` onto `under`: plain objects merge recursively, every other
|
||||
* value (arrays included) replaces the lower layer wholesale, and `undefined`
|
||||
* entries in `over` are ignored so a sparse patch cannot erase lower keys.
|
||||
* value (arrays included) replaces the lower layer wholesale. `over` never
|
||||
* carries `undefined` entries — sections come from parsed documents and write
|
||||
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
||||
* cannot erase lower keys.
|
||||
*/
|
||||
function mergeLayers(under: unknown, over: unknown): unknown {
|
||||
if (over === undefined) return under
|
||||
if (!isPlainObject(under) || !isPlainObject(over)) return over
|
||||
const merged: Record<string, unknown> = { ...under }
|
||||
for (const [key, value] of Object.entries(over)) {
|
||||
if (value === undefined) continue
|
||||
merged[key] = key in merged ? mergeLayers(merged[key], value) : value
|
||||
}
|
||||
return merged
|
||||
@@ -155,6 +223,8 @@ interface SettingsWatcher {
|
||||
callback: (next: never, prev: never) => void | Promise<void>
|
||||
/** Settled tail: invocations of this callback run one at a time, in commit order. */
|
||||
tail: Promise<void>
|
||||
/** Cleared by the disposer: a queued invocation checks this before starting. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** One live namespace registration owned by a registrant fiber. */
|
||||
@@ -179,6 +249,8 @@ export abstract class Settings extends Service {
|
||||
private document: Record<string, unknown> = {}
|
||||
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
|
||||
private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
|
||||
/** In-flight watcher invocation segments, drained by the dispose teardown. */
|
||||
private readonly pendingTails = new Set<Promise<void>>()
|
||||
/** Set at service dispose: refuse new writes while queued ones drain. */
|
||||
private stopped = false
|
||||
|
||||
@@ -199,10 +271,12 @@ export abstract class Settings extends Service {
|
||||
*/
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// Teardown: refuse new writes, then wait until every queued write chain
|
||||
// settles so disposal completes only once storage is quiescent.
|
||||
// Teardown: refuse new writes and new watcher starts, then wait until
|
||||
// every queued write chain and every started watcher invocation settles
|
||||
// so disposal completes only once storage and observers are quiescent.
|
||||
// Invocations queued but not yet started skip via the stopped check.
|
||||
this.stopped = true
|
||||
await Promise.allSettled([...this.writeQueues.values()])
|
||||
await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails])
|
||||
}
|
||||
this.publish(await this.load())
|
||||
}
|
||||
@@ -252,9 +326,12 @@ export abstract class Settings extends Service {
|
||||
return {
|
||||
get: () => registration.resolved as T,
|
||||
watch: (callback) => {
|
||||
const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve() }
|
||||
const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true }
|
||||
registration.watchers.add(watcher)
|
||||
return () => registration.watchers.delete(watcher)
|
||||
return () => {
|
||||
watcher.active = false
|
||||
registration.watchers.delete(watcher)
|
||||
}
|
||||
},
|
||||
update: patch => this.update(ns, patch),
|
||||
replace: section => this.replace(ns, section),
|
||||
@@ -325,13 +402,10 @@ export abstract class Settings extends Service {
|
||||
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||
}
|
||||
// Snapshot at call time: the queue must never read a caller-owned object
|
||||
// the caller may keep mutating while the write waits its turn.
|
||||
let snapshot: Record<string, unknown>
|
||||
try {
|
||||
snapshot = structuredClone(input)
|
||||
} catch {
|
||||
throw new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped (structured-cloneable) data`)
|
||||
}
|
||||
// the caller may keep mutating while the write waits its turn. The same
|
||||
// walk is the JSON-shape boundary check (see cloneJsonShaped).
|
||||
const snapshot = cloneJsonShaped(input, (label, path) =>
|
||||
new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`))
|
||||
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
|
||||
// Chain past a failed predecessor: one rejected write must not poison the
|
||||
// namespace queue for every later caller.
|
||||
@@ -407,11 +481,20 @@ export abstract class Settings extends Service {
|
||||
// Serialize per watcher: invocations of one callback run one at a time
|
||||
// in commit order, so a slow stale invocation can never apply after a
|
||||
// newer one. Sync throws and async rejections land in the same handler.
|
||||
watcher.tail = watcher.tail
|
||||
.then(() => watcher.callback(next as never, prev as never))
|
||||
// The activity check runs when the queued invocation would start, so a
|
||||
// disposer (or service stop) that ran while it waited prevents the
|
||||
// start entirely; started invocations drain at service dispose.
|
||||
const segment = watcher.tail
|
||||
.then(() => {
|
||||
if (!watcher.active || this.isStopped()) return
|
||||
return watcher.callback(next as never, prev as never)
|
||||
})
|
||||
.then(() => undefined, (error: unknown) => {
|
||||
this.warnWatcherFailure(registration.ns, error)
|
||||
})
|
||||
watcher.tail = segment
|
||||
this.pendingTails.add(segment)
|
||||
void segment.then(() => this.pendingTails.delete(segment))
|
||||
}
|
||||
// Fan the event out one listener at a time (the plain emit stops at the
|
||||
// first throwing listener, starving the rest). Invariant violations are
|
||||
@@ -422,14 +505,21 @@ export abstract class Settings extends Service {
|
||||
const args = ['settings/updated', registration.ns, next, prev, source]
|
||||
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
|
||||
try {
|
||||
listener(registration.ns, next, prev, source)
|
||||
const returned = listener(registration.ns, next, prev, source)
|
||||
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
|
||||
// An emit listener may still be an async function; its rejection
|
||||
// cannot reach the synchronous INVARIANT rethrow below, so it is
|
||||
// contained here instead of becoming an unhandled rejection.
|
||||
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
|
||||
this.warnListenerFailure(registration.ns, error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
|
||||
invariantFailure ??= error
|
||||
continue
|
||||
}
|
||||
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', registration.ns)
|
||||
this.ctx.logger.warn(error)
|
||||
this.warnListenerFailure(registration.ns, error)
|
||||
}
|
||||
}
|
||||
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||
@@ -440,6 +530,12 @@ export abstract class Settings extends Service {
|
||||
this.ctx.logger.warn('settings: watcher for "%s" failed', ns)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
|
||||
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
||||
private warnListenerFailure(ns: SettingsNamespace, error: unknown): void {
|
||||
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns)
|
||||
this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hooks a consumer hands to {@link installSettingsSection}. */
|
||||
|
||||
@@ -433,11 +433,11 @@ describe('second review regressions', () => {
|
||||
expect(applied).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('rejects a plain object that is not structured-cloneable', async () => {
|
||||
it('rejects a function value as not JSON-shaped', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update({ theme: () => 'dark' }))
|
||||
.rejects.toThrow(/JSON-shaped/)
|
||||
.rejects.toThrow(/JSON-shaped.*function at \$\.theme/)
|
||||
})
|
||||
|
||||
it('rejects a write still queued when the service disposes', async () => {
|
||||
@@ -532,6 +532,100 @@ describe('publish', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('third review regressions', () => {
|
||||
it('skips a queued watch invocation whose disposer ran before it started', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
const dispose = scope.watch(watcher)
|
||||
// The commit chains the invocation as a microtask; the disposer runs in
|
||||
// the same synchronous frame, before that invocation could start.
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
dispose()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for an in-flight watch invocation at service dispose', async () => {
|
||||
const { ctx, provider, fiber } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
let release: (() => void) | undefined
|
||||
let finished = false
|
||||
scope.watch(async () => {
|
||||
await new Promise<void>((resolve) => { release = resolve })
|
||||
finished = true
|
||||
})
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
await vi.waitFor(() => { expect(release).toBeDefined() })
|
||||
let disposed = false
|
||||
const disposal = fiber.dispose().then(() => { disposed = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 15))
|
||||
expect(disposed).toBe(false)
|
||||
release!()
|
||||
await disposal
|
||||
expect(finished).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a Date at its path before anything persists', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
await expect(scope.update({ value: { at: new Date(0) } }))
|
||||
.rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/)
|
||||
expect(provider.persisted).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a Map', { value: new Map() }, /Map at \$\.value/],
|
||||
['a bigint', { value: [10n] }, /bigint at \$\.value\[0\]/],
|
||||
['a symbol', { value: Symbol('x') }, /symbol at \$\.value/],
|
||||
['a non-finite number', { value: Number.NaN }, /non-finite number at \$\.value/],
|
||||
['an undefined array entry', { value: [undefined] }, /undefined at \$\.value\[0\]/],
|
||||
['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/],
|
||||
])('rejects %s that structuredClone would admit', async (_label, patch, message) => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
await expect(scope.update(patch)).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a circular patch instead of storing an alias-looped document', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic['self'] = cyclic
|
||||
await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/)
|
||||
const loop: unknown[] = []
|
||||
loop.push(loop)
|
||||
await expect(scope.update({ value: loop })).rejects.toThrow(/circular reference at \$\.value\[0\]/)
|
||||
})
|
||||
|
||||
it('accepts one object referenced twice without a cycle', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
|
||||
const shared = { leaf: 1 }
|
||||
await scope.update({ value: { left: shared, right: shared } })
|
||||
expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } })
|
||||
})
|
||||
|
||||
it('contains an async settings/updated listener rejection and keeps other listeners running', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
// An async listener violates the event's synchronous signature, but an
|
||||
// unlinted JS plugin can still register one. Declaring the return as
|
||||
// unknown keeps this file's typed surface legal (unknown-returning
|
||||
// functions are assignable to void positions) while the runtime value is
|
||||
// still the rejected promise the containment guard must handle.
|
||||
const boom = (): unknown => Promise.reject(new Error('async listener boom'))
|
||||
ctx.on('settings/updated', boom)
|
||||
const second = vi.fn()
|
||||
ctx.on('settings/updated', second)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
// Containment gives the rejection a handler; vitest observes no unhandled
|
||||
// rejection out of this test.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
})
|
||||
|
||||
describe('watch', () => {
|
||||
it('stops after its disposer runs', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
|
||||
Reference in New Issue
Block a user