Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/core-data-structures/core.i18n.yaml # packages/README.i18n.yaml
This commit is contained in:
@@ -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 .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md
|
||||
2026-07-28-user-settings-seam.md: bf93f95168b1b6d0dec5a9fc2c9aac5531f0564a
|
||||
2026-07-28-user-settings-seam.zh.md: 8cd4dfcbb2facdd590b2c24d453ad79e9badda4d
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: user-settings seam (`ctx.settings`) and the file provider
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-user-settings-seam.zh.md)
|
||||
|
||||
> Scope: the `packages/settings/` capability family — the abstract seam, the file-backed provider, and the composition boundary between user settings and `cordis.yml`. The [web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) recorded "the profile write path" as a deferral; this seam is that write path's owner. Consumer migrations (theme, locale, default model route) and the web `settings.*` RPC surface are follow-ups, not part of this note's shipped scope.
|
||||
|
||||
## Problem
|
||||
|
||||
User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: `fiber.update` swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise.
|
||||
|
||||
## Decision
|
||||
|
||||
**Two planes with a litmus test.** `cordis.yml` (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition `base` (its entry-config subset), then the user document section.
|
||||
|
||||
**Three-package seam mirroring `session-persistence/`.** `dsh-settings` owns the abstract `Settings` service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the `settings/updated` commit event. Providers implement only `writable`/`load()`/`persist(ns, section)` and push externally observed documents through the protected `publish(doc)` — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. `dsh-settings-local` is the file provider: YAML/JSON under `resolveSpec` (explicit defaulting to `<DSH_HOME>/settings.yaml`), chokidar watch, read-modify-write persists under a cross-process writer lock with atomic `0600` tmp+rename commits, leaf-level diff patching of the written namespace (comments survive untouched nodes), and content-equality self-write suppression ([write-path integrity note](2026-07-30-settings-write-path-integrity.md)).
|
||||
|
||||
**Registrations are caller-fiber effects.** `register()` runs through the service proxy, so `this.ctx` is the registrant's context and the registration rides `ctx.effect`: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner.
|
||||
|
||||
**Fail loud at rest, last-good in motion.** Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors `Include.refresh()` and Kimi's safe runtime reload.
|
||||
|
||||
**Consumers stay optional-by-construction.** A consumer registers inside `ctx.inject(['settings'], …)`; without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Include write-back as the user layer** (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one `cordis.yml`; a per-user layer must survive template upgrades and serve TUI and web from one document.
|
||||
- **Loader-reactive `fiber.update` as the propagation channel**: constructor-time reads observe nothing; the seam's explicit `watch()` makes hot-update a consumer contract instead of framework magic.
|
||||
- **A domain-aware settings service** (getters per product area): the coupling objection from design review stands; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema.
|
||||
- **Multi-layer precedence now** (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend.
|
||||
- **A cross-process lockfile now** (Pi's proper-lockfile): initially deferred as "atomic replace plus watcher convergence until real contention shows up" — review showed convergence loses unobserved sibling namespaces, so the deferral is superseded by the [write-path integrity note](2026-07-30-settings-write-path-integrity.md)'s hand-rolled writer lock.
|
||||
|
||||
## Consequences
|
||||
|
||||
Deferred, in dependency order: the web `settings.raw`/`settings.describe`/`settings.update` RPC surface (which must redact `role('secret')` fields before exposure); first consumer migrations (`ui-theme`, locale, api-gateway default route) retiring `PROFILE_MAPPINGS` and the profile json; `${env:VAR}` value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:用户设置 seam(`ctx.settings`)与文件 provider
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-user-settings-seam.md) | 中文
|
||||
|
||||
> 范围:`packages/settings/` 能力族——抽象 seam、文件 provider,以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项;本 seam 就是该写路径的归属。消费者迁移(主题、语言、默认模型路由)与 web `settings.*` RPC 面是后续工作,不在本 note 已交付范围内。
|
||||
|
||||
## 问题
|
||||
|
||||
用户可编辑配置没有归属:`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径,TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch,两者都在启动时冻结。个人设置页(web GUI)需要一个跨 surface 的用户层,带 schema 校验、写路径与热传导——同类产品(Codex、Claude Code、Kimi、OpenCode、Pi)也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事:`fiber.update` 原地替换 entry config,构造期读过配置的插件毫无感知,也没有任何回调通知它。
|
||||
|
||||
## 决策
|
||||
|
||||
**两个面,一条判定。**`cordis.yml`(+ Include patches)仍是组合面:有哪些插件、接线、部署配置,归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集;判定是"个人配置页应该能改它吗?"值可同时存在于两个面而不歧义,因为分层就是契约:schema 默认值,然后注册方的组合 `base`(其 entry 配置子集),最后用户文档分节。
|
||||
|
||||
**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。`dsh-settings-local` 是文件 provider:`resolveSpec` 显式默认到 `<DSH_HOME>/settings.yaml` 的 YAML/JSON、chokidar 监听、跨进程写锁下以 `0600` tmp+rename 原子提交的读-改-写 persist、对被写 namespace 的叶子级 diff 修补(未触碰节点的注释得以保留)、按内容相等抑制自写([write-path integrity note](2026-07-30-settings-write-path-integrity.md))。
|
||||
|
||||
**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用,`this.ctx` 即注册方 context,注册挂在 `ctx.effect` 上:dispose 注册方即移除 namespace 及其观察者(HMR disposal 测试证明),而用户的分节继续留在存储中等待下一任 owner。
|
||||
|
||||
**静止时响亮报错,运行中保留最后可用值。**启动期与注册期校验直接抛错(非法存量分节使注册插件加载失败;存在但不可解析的文档使 provider 加载失败)。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。
|
||||
|
||||
**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册;不挂 provider 时仍只按 entry 配置解析,因此所有既有组合、demo、snapshot 原样工作,迁移按插件渐进。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **以 Include 写回为用户层**(cordis-webui 式的按插件配置页写 loader entry 文件):写回目标是按组合的文件,会把用户偏好绑死在某个 `cordis.yml` 上;用户层必须在模板升级中存活,并以同一文档服务 TUI 与 web。
|
||||
- **以 Loader reactive `fiber.update` 为传导通道**:构造期读取毫无感知;seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。
|
||||
- **领域化的 settings 服务**(按产品域的 getter):设计评审中的耦合反对成立;服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。
|
||||
- **现在就做多层优先级**(Codex/Claude Code 式 system/managed/project 层级):延后到真实第二层出现;resolve 步骤是分层未来唯一的扩展点。
|
||||
- **现在就上跨进程锁**(Pi 的 proper-lockfile):最初以"原子替换加 watcher 收敛,真实冲突出现再说"为由延后——评审发现收敛会丢失未观察到的同级 namespace,因此该延后已被 [write-path integrity note](2026-07-30-settings-write-path-integrity.md) 的手写写锁取代。
|
||||
|
||||
## 后果
|
||||
|
||||
按依赖顺序延后:web `settings.raw`/`settings.describe`/`settings.update` RPC 面(暴露前必须对 `role('secret')` 字段脱敏);首批消费者迁移(`ui-theme`、语言、api-gateway 默认路由)并退役 `PROFILE_MAPPINGS` 与 profile json;面向密钥的 `${env:VAR}` 值间接引用;provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地,而非本基础设施步骤。
|
||||
+6
@@ -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 .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md
|
||||
2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc
|
||||
2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: settings write-path integrity and observer lifecycle
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-settings-write-path-integrity.zh.md)
|
||||
|
||||
> Scope: the third review round over `packages/settings/` — write-path data integrity in `dsh-settings-local` (operation chain, read-modify-write, cross-process writer lock, diff-shaped YAML edits) and observer lifecycle in `dsh-settings` (watch disposal, async listener containment, the JSON-shape write boundary). This note reverses one deferral recorded in the [user-settings seam note](2026-07-28-user-settings-seam.md): the cross-process lockfile now ships.
|
||||
|
||||
## Problem
|
||||
|
||||
Review found the provider's write path could destroy state it never observed, and the seam's observer lifecycle leaked past disposal. Concretely: watcher reloads and document writes ran on two independent promise chains while every write rendered the whole next document from the cached text, so an external edit still inside the debounce window was overwritten — and the follow-up reload no-oped because the post-rename content matched the cache, erasing the edit without a trace. The initial `load()` raced the watcher's own setup, leaving a startup window whose changes never fire an event. Two processes sharing a harness home rendered from independent caches, last writer winning whole namespaces. On the seam side, a `watch()` disposer only removed the observer from its set — an invocation already chained onto the watcher tail still ran after disposal, and nothing drained started invocations at service dispose; the `settings/updated` manual fan-out caught only synchronous throws, so an async listener's rejection escaped as an unhandled rejection; and `structuredClone` admitted Dates, Maps, BigInts, and cycles that YAML/JSON storage silently distorts on the reload round-trip (a Date lands as a timestamp string, a BigInt as a plain number). YAML writes replaced the whole namespace node, deleting every comment inside the section a comment-preserving provider had promised to keep.
|
||||
|
||||
## Decision
|
||||
|
||||
**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active.
|
||||
|
||||
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste.
|
||||
|
||||
**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is.
|
||||
|
||||
**The write boundary admits JSON data only.** The call-time snapshot is a single `cloneJsonShaped` walk that detaches the patch and rejects any non-JSON value — Date, Map, BigInt, non-finite number, function, symbol, class instance, `undefined` array entry, circular reference — with its `$`-rooted path before anything persists. Object entries that are explicitly `undefined` still skip (the sparse-patch contract), now enforced at the boundary instead of inside `mergeLayers`.
|
||||
|
||||
**YAML edits are leaf-level diffs.** `renderYaml` diffs the stored section against the next one and applies only `setIn` for changed values and `deleteIn` for removed keys, recursing through maps. Comments, anchors, and formatting survive on every untouched node and on the key node of every changed pair; arrays and other non-map values replace wholesale when unequal (`deepEqualJson` is the shared predicate), taking comments inside them along.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer.
|
||||
- **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free.
|
||||
- **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded.
|
||||
- **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime.
|
||||
- **Keeping `structuredClone` and validating in the provider** — the seam is the durable boundary's owner (every provider stores JSON-shaped documents), and rejecting at call time gives the caller the offending path; a provider-side check would reject after merge, blaming the merged section instead of the caller's value.
|
||||
|
||||
## Consequences
|
||||
|
||||
`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: settings 写路径完整性与观察者生命周期
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-settings-write-path-integrity.md) | 中文
|
||||
|
||||
> 范围:对 `packages/settings/` 的第三轮评审——`dsh-settings-local` 的写路径数据完整性(操作链、读-改-写、跨进程写锁、diff 形态的 YAML 编辑)与 `dsh-settings` 的观察者生命周期(watch 的 dispose(资源释放)、异步监听器收容、JSON 形态写入边界)。本 note 推翻了[用户设置 seam note](2026-07-28-user-settings-seam.md)所记录的一项延后决定:跨进程锁文件现已交付。
|
||||
|
||||
## 问题
|
||||
|
||||
评审发现,提供方的写路径可能销毁它从未观察到的状态,而 seam 的观察者生命周期会泄漏到 dispose 之后。具体而言:watcher 重载与文档写入跑在两条相互独立的 promise 链上,而每次写入都从缓存文本渲染出完整的下一份文档,于是仍处于防抖窗口内的外部编辑会被覆盖——随后的重载又因 rename 后的内容与缓存一致而成为空操作,这次编辑就被无痕抹去。初始 `load()` 与 watcher 自身的建立过程存在竞态,留下一个启动窗口:落在这个窗口内的变更永远不会触发事件。共享同一 harness home 的两个进程各自从独立的缓存渲染,后写者以整个 namespace 为单位胜出。
|
||||
|
||||
在 seam 一侧,`watch()` 的释放器只把观察者从集合中移除——已经接到 watcher 链尾的调用在 dispose 之后照常运行,服务 dispose 时也没有任何环节排空已启动的调用;`settings/updated` 的手动扇出只捕获同步抛错,异步监听器的 rejection 会以 unhandled rejection 的形式逃逸;`structuredClone` 则放行 Date、Map、BigInt 与循环引用,而 YAML/JSON 存储会在重载往返中悄悄扭曲这些值(Date 会变成时间戳字符串,BigInt 会变成普通数字)。
|
||||
|
||||
YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删掉——而这个保注释的提供方承诺过要保住它们。
|
||||
|
||||
## 决策
|
||||
|
||||
**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
|
||||
|
||||
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。
|
||||
|
||||
**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。
|
||||
|
||||
**写入边界只放行 JSON 数据。**调用时刻的快照就是一次 `cloneJsonShaped` 遍历:它把 patch 从调用方分离出来,并在任何内容持久化之前拒绝一切非 JSON 值——Date、Map、BigInt、非有限数值、函数、symbol、类实例、值为 `undefined` 的数组元素、循环引用——拒绝时附带该值以 `$` 为根的路径。显式为 `undefined` 的对象条目仍会跳过(稀疏 patch 契约),这一契约如今在边界处强制执行,而不再放在 `mergeLayers` 内部。
|
||||
|
||||
**YAML 编辑是叶子级 diff。**`renderYaml` 对比已存储分节与下一份分节,只对变化的值应用 `setIn`、对移除的键应用 `deleteIn`,并沿 map 递归。注释、锚点与格式在每个未触碰节点上、以及每个被改键值对的键节点上全部保留;数组等非 map 值在不相等时整体替换(`deepEqualJson` 是共享的判定谓词),其内部注释随之一并被带走。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。
|
||||
- **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。
|
||||
- **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。
|
||||
- **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。
|
||||
- **保留 `structuredClone`、在提供方里做校验**——seam 才是持久化边界的所有者(每个提供方存储的都是 JSON 形态文档),而且在调用时刻拒绝能把违规值的路径给到调用方;提供方侧的检查要到合并之后才拒绝,归咎的是合并后的分节,而不是调用方传入的值。
|
||||
|
||||
## 后果
|
||||
|
||||
`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。
|
||||
|
||||
[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。
|
||||
@@ -31,6 +31,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
|
||||
cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime
|
||||
hooks/ Claude Code/Codex hook bridges + shared wire-protocol library
|
||||
session-persistence/ persistence seam + JSONL/SQLite backends
|
||||
settings/ user-settings seam + file-backed provider
|
||||
acp/ automation-only Agent Client Protocol server
|
||||
ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins
|
||||
examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load
|
||||
|
||||
@@ -42,6 +42,9 @@ flowchart LR
|
||||
pkg_tool_bash["tool-bash"]
|
||||
pkg_hooks_claude["hooks-claude"]
|
||||
pkg_hooks_codex["hooks-codex"]
|
||||
pkg_settings["settings"]
|
||||
svc_settings["ctx.settings<br/>User-settings seam"]
|
||||
pkg_settings_local["settings-local"]
|
||||
pkg_session_telemetry["session-telemetry"]
|
||||
svc_telemetry["ctx.telemetry<br/>Session telemetry seam"]
|
||||
pkg_session_telemetry_otel["session-telemetry-otel"]
|
||||
@@ -211,6 +214,8 @@ flowchart LR
|
||||
pkg_session_title --> svc_sessionTitle
|
||||
pkg_session_title_all_messages_llm --> svc_sessionTitle
|
||||
pkg_session_title_first_message_llm --> svc_sessionTitle
|
||||
pkg_settings --> svc_settings
|
||||
pkg_settings_local --> svc_settings
|
||||
pkg_skill --> svc_skills
|
||||
pkg_skill_local --> svc_skills
|
||||
pkg_spill --> svc_spillStore
|
||||
@@ -350,6 +355,7 @@ flowchart LR
|
||||
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
|
||||
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. |
|
||||
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
|
||||
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. |
|
||||
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
|
||||
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
|
||||
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
|
||||
|
||||
@@ -1258,6 +1258,24 @@ Depends on: [`SessionTitleLlmConfig`](../packages/session-title/session-title-ll
|
||||
|
||||
Source: [`packages/session-title/session-title-first-message-llm/src/index.ts:15`](../packages/session-title/session-title-first-message-llm/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-settings-local`
|
||||
|
||||
```ts config-catalog
|
||||
/** 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
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/settings/settings-local/src/index.ts:21`](../packages/settings/settings-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-skill`
|
||||
|
||||
```ts config-catalog
|
||||
@@ -2303,6 +2321,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
|
||||
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
|
||||
- `@deepseek-ai/dsh-settings` — abstract `Settings` ([`packages/settings/settings/src/index.ts`](../packages/settings/settings/src/index.ts))
|
||||
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
|
||||
|
||||
@@ -659,6 +659,35 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `settings/*`
|
||||
|
||||
### `settings/updated` — emit
|
||||
|
||||
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.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* 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.
|
||||
* @param source - whether the change entered through `update()` or the provider.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
|
||||
```
|
||||
|
||||
Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md)
|
||||
|
||||
Source: [`packages/settings/settings/src/index.ts:108`](../../packages/settings/settings/src/index.ts)
|
||||
|
||||
## `skills/*`
|
||||
|
||||
### `skills/change` — emit
|
||||
|
||||
@@ -1671,6 +1671,62 @@ Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](.
|
||||
|
||||
Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages/session-title/session-title/src/index.ts)
|
||||
|
||||
## `ctx.settings` — `Settings` (abstract seam)
|
||||
|
||||
Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Register a namespace schema and receive its owner scope. The registration
|
||||
* is an effect on the calling plugin's fiber: disposing that fiber removes
|
||||
* the namespace and its observers. An invalid stored section fails the
|
||||
* registration itself — the earliest point where the schema can judge it.
|
||||
* @param ns - unique namespace; duplicate registration fails loud.
|
||||
* @param schema - schemastery schema resolving this namespace's value.
|
||||
* @param options - composition `base` layer and effect timing.
|
||||
* @returns the owner scope for reads, observation, and updates.
|
||||
*/
|
||||
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>
|
||||
|
||||
/**
|
||||
* Describe every registered namespace for configuration surfaces.
|
||||
* @returns one descriptor per registered namespace, in registration order.
|
||||
*/
|
||||
describe(): SettingsDescriptor[]
|
||||
|
||||
/**
|
||||
* Read one registered namespace's resolved value.
|
||||
* @param ns - the namespace to read.
|
||||
* @returns the resolved value, or `undefined` while unregistered.
|
||||
*/
|
||||
get(ns: SettingsNamespace): unknown
|
||||
|
||||
/**
|
||||
* Merge a patch into one registered namespace's user layer, validate the
|
||||
* resolved candidate, persist through the provider, then commit and emit.
|
||||
* A validation failure rejects before anything is persisted. Writes to one
|
||||
* namespace are serialized: concurrent updates apply in call order, each
|
||||
* merging over the previous write's committed section.
|
||||
* @param ns - the registered namespace to update.
|
||||
* @param patch - plain-object patch over the user section.
|
||||
*/
|
||||
async update(ns: SettingsNamespace, patch: object): Promise<void>
|
||||
|
||||
/**
|
||||
* Replace one registered namespace's user section wholesale, validate,
|
||||
* persist, then commit and emit. Keys absent from `section` fall back to the
|
||||
* composition `base` and schema defaults — this is the removal/reset path a
|
||||
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
||||
* @param ns - the registered namespace to replace.
|
||||
* @param section - the complete next user section.
|
||||
*/
|
||||
async replace(ns: SettingsNamespace, section: object): Promise<void>
|
||||
```
|
||||
|
||||
Types: [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md)
|
||||
|
||||
Source: [`packages/settings/settings/src/index.ts:250`](../../packages/settings/settings/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand.
|
||||
|
||||
@@ -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 docs/core-data-structures/core.md
|
||||
core.md: 754d865b4157b88d9144952e39de9237b51e54ed
|
||||
core.zh.md: c03fcc3f15dd35cb5a321447755181394ad624e4
|
||||
core.md: 4c3f3705c27353dc8ad3e7f641f99ba4a4965427
|
||||
core.zh.md: 2e0f71bd4298dbf32f0c00dafdfdd6b76f7f4055
|
||||
@@ -24,6 +24,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [commands.md](commands.md) | the human-command seam: definitions, adapter discovery, direct invocation, results, and parsing views |
|
||||
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
|
||||
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
||||
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
|
||||
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
|
||||
| [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract |
|
||||
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
|
||||
|
||||
@@ -24,6 +24,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数
|
||||
| [commands.md](commands.md) | 人类命令 seam:定义、适配器发现、直接调用、结果与解析视图 |
|
||||
| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 |
|
||||
| [persistence.md](persistence.md) | 持久性 seam:`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` |
|
||||
| [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
|
||||
| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 |
|
||||
| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 |
|
||||
| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
|
||||
|
||||
@@ -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 docs/core-data-structures/settings.md
|
||||
settings.md: 381b36b3ff2f45a2090a2a2eac0f700bd00270c4
|
||||
settings.zh.md: bc6547db3b05c5a78f112462ae205d848f93da60
|
||||
@@ -0,0 +1,99 @@
|
||||
# User Settings
|
||||
|
||||
English | [中文](settings.zh.md)
|
||||
|
||||
The user-settings seam of [dsh-settings](../../packages/settings/settings) holds one user-owned document of per-namespace sections and resolves each registered namespace as schema defaults, then the registrant's composition `base`, then the user section. Providers such as [dsh-settings-local](../../packages/settings/settings-local) store the raw document and push external edits; consumer plugins register a schema and read or observe the resolved value. Composition config stays in `cordis.yml` — a namespace carries only the user-editable subset.
|
||||
|
||||
Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts)
|
||||
|
||||
## Identity
|
||||
|
||||
A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape.
|
||||
|
||||
```ts type-equiv
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer and the owner's effect timing.
|
||||
|
||||
```ts type-equiv
|
||||
/** Registration options beyond the namespace schema. */
|
||||
interface SettingsRegisterOptions<T> {
|
||||
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
||||
base?: Partial<T>
|
||||
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
||||
applies?: SettingsApplies
|
||||
}
|
||||
```
|
||||
|
||||
`applies` is a UI hint, not a mechanism: a `restart` owner simply never watches, so its value is read once at construction and configuration surfaces can badge the pending change.
|
||||
|
||||
```ts type-equiv
|
||||
/** When a namespace's changes take effect for its owner. */
|
||||
type SettingsApplies = 'live' | 'restart'
|
||||
```
|
||||
|
||||
## Owner scope
|
||||
|
||||
The scope is the owner-facing handle. `update` merges a sparse patch over the user section only (never into `base`); `replace` sets the section wholesale, which is the removal/reset path — keys absent from the replacement re-inherit `base` and schema defaults. Writes to one namespace are serialized in call order, and resolved values are deep-frozen snapshots.
|
||||
|
||||
```ts type-equiv
|
||||
/** Owner-facing handle for one registered namespace. */
|
||||
interface SettingsScope<T> {
|
||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||
get(): 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. 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; 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; JSON-shaped data only,
|
||||
* as for {@link update}.
|
||||
*/
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Descriptors
|
||||
|
||||
`describe()` serializes every registered namespace for configuration surfaces: the schemastery `toJSON()` envelope drives schema-rendered forms, and the resolved value fills them.
|
||||
|
||||
```ts type-equiv
|
||||
/** One registered namespace as surfaced to configuration UIs. */
|
||||
interface SettingsDescriptor {
|
||||
/** The registered namespace. */
|
||||
ns: SettingsNamespace
|
||||
/** Serialized schemastery schema (`schema.toJSON()`). */
|
||||
schema: unknown
|
||||
/** Current resolved value. */
|
||||
value: unknown
|
||||
/** Owner's declared effect timing. */
|
||||
applies: SettingsApplies
|
||||
}
|
||||
```
|
||||
|
||||
## Change commits
|
||||
|
||||
Every committed change — an in-process write or an externally observed provider edit — emits `settings/updated (ns, next, prev, source)` after the new value is authoritative, and never when the resolved value is deep-equal. The source tag separates the two entry paths.
|
||||
|
||||
```ts type-equiv
|
||||
/** Origin of one committed settings change. */
|
||||
type SettingsUpdateSource = 'update' | 'provider'
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# 用户设置
|
||||
|
||||
[English](settings.md) | 中文
|
||||
|
||||
[dsh-settings](../../packages/settings/settings) 的用户设置 seam 持有一份按 namespace 分节的用户文档,并把每个已注册 namespace 解析为:schema 默认值,然后注册方的组合 `base`,最后用户分节。[dsh-settings-local](../../packages/settings/settings-local) 这类 provider 存储原始文档并推送外部编辑;消费插件注册 schema 后读取或观察解析值。组合配置仍留在 `cordis.yml`——namespace 只承载用户可编辑子集。
|
||||
|
||||
Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts)
|
||||
|
||||
## 标识
|
||||
|
||||
namespace 命名用户文档中一个插件所有的分节。brand 使其不与其他跨边界 id 混用;构造时校验小写 kebab-case 形态。
|
||||
|
||||
```ts type-equiv
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
```
|
||||
|
||||
## 注册
|
||||
|
||||
注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层与 owner 的生效时机。
|
||||
|
||||
```ts type-equiv
|
||||
/** Registration options beyond the namespace schema. */
|
||||
interface SettingsRegisterOptions<T> {
|
||||
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
||||
base?: Partial<T>
|
||||
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
||||
applies?: SettingsApplies
|
||||
}
|
||||
```
|
||||
|
||||
`applies` 是 UI 提示而非机制:`restart` 的 owner 只是从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。
|
||||
|
||||
```ts type-equiv
|
||||
/** When a namespace's changes take effect for its owner. */
|
||||
type SettingsApplies = 'live' | 'restart'
|
||||
```
|
||||
|
||||
## Owner scope
|
||||
|
||||
scope 是面向 owner 的句柄。`update` 把稀疏 patch 只合并进用户分节(绝不进 `base`);`replace` 整体替换分节,是删除/重置路径——替换中缺席的键重新继承 `base` 与 schema 默认值。同一 namespace 的写入按调用顺序串行,解析值是深冻结快照。
|
||||
|
||||
```ts type-equiv
|
||||
/** Owner-facing handle for one registered namespace. */
|
||||
interface SettingsScope<T> {
|
||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||
get(): 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. 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; 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; JSON-shaped data only,
|
||||
* as for {@link update}.
|
||||
*/
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## 描述符
|
||||
|
||||
`describe()` 为配置界面序列化每个已注册 namespace:schemastery 的 `toJSON()` 信封驱动 schema 渲染的表单,解析值填充表单。
|
||||
|
||||
```ts type-equiv
|
||||
/** One registered namespace as surfaced to configuration UIs. */
|
||||
interface SettingsDescriptor {
|
||||
/** The registered namespace. */
|
||||
ns: SettingsNamespace
|
||||
/** Serialized schemastery schema (`schema.toJSON()`). */
|
||||
schema: unknown
|
||||
/** Current resolved value. */
|
||||
value: unknown
|
||||
/** Owner's declared effect timing. */
|
||||
applies: SettingsApplies
|
||||
}
|
||||
```
|
||||
|
||||
## 变更提交
|
||||
|
||||
每次提交的变更——进程内写入或 provider 观察到的外部编辑——在新值成为权威值之后发出 `settings/updated (ns, next, prev, source)`,解析值深相等时绝不发出。source 标记区分两条入口路径。
|
||||
|
||||
```ts type-equiv
|
||||
/** Origin of one committed settings change. */
|
||||
type SettingsUpdateSource = 'update' | 'provider'
|
||||
```
|
||||
@@ -36,6 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:108`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -227,6 +227,10 @@ flowchart TD
|
||||
pkg_session_projection["session-projection"]
|
||||
pkg_session_projection_cache["session-projection-cache"]
|
||||
end
|
||||
subgraph group_settings["packages/settings"]
|
||||
pkg_settings["settings"]
|
||||
pkg_settings_local["settings-local"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
pkg_storage["storage"]
|
||||
pkg_storage_domain["storage-domain"]
|
||||
@@ -319,6 +323,8 @@ flowchart TD
|
||||
pkg_telemetry --> pkg_brand
|
||||
pkg_telemetry --> pkg_invariants
|
||||
pkg_telemetry --> pkg_paths
|
||||
pkg_settings --> pkg_brand
|
||||
pkg_settings --> pkg_invariants
|
||||
pkg_storage_domain --> pkg_invariants
|
||||
pkg_storage_domain --> pkg_storage
|
||||
pkg_storage_json --> pkg_invariants
|
||||
@@ -411,6 +417,9 @@ flowchart TD
|
||||
pkg_lsp --> pkg_llm
|
||||
pkg_sandbox --> pkg_invariants
|
||||
pkg_sandbox --> pkg_llm
|
||||
pkg_settings_local --> pkg_invariants
|
||||
pkg_settings_local --> pkg_paths
|
||||
pkg_settings_local --> pkg_settings
|
||||
pkg_token_meter --> pkg_invariants
|
||||
pkg_token_meter --> pkg_llm
|
||||
pkg_token_meter --> pkg_session
|
||||
@@ -1034,6 +1043,7 @@ flowchart TD
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
@@ -1059,6 +1069,7 @@ flowchart TD
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: e195decd8486d5f2deba65dab83972c290b3fa95
|
||||
README.zh.md: 6eb5749ce62154fa54da4767afc6f529fd962f19
|
||||
README.md: 1df49875ed4f118e12786a757624465a86374550
|
||||
README.zh.md: 90dc54bbeabcc966293f327cd838d73df7c90607
|
||||
@@ -40,6 +40,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
|
||||
@@ -766,6 +766,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
summary: 'Abstract settings service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>',
|
||||
jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'describe(): SettingsDescriptor[]',
|
||||
jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(ns: SettingsNamespace): unknown',
|
||||
jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async update(ns: SettingsNamespace, patch: object): Promise<void>',
|
||||
jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async replace(ns: SettingsNamespace, section: object): Promise<void>',
|
||||
jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
summary: 'Registry of skill providers.',
|
||||
@@ -1333,6 +1359,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
|
||||
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
|
||||
},
|
||||
{
|
||||
name: 'settings/updated',
|
||||
mode: 'emit',
|
||||
signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
|
||||
jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * Listener failures are contained and logged — a sync throw and an async\n * rejection alike — except `INVARIANT`-coded failures, which rethrow\n * after every listener ran; that rethrow reaches the emitter only from\n * synchronous listeners, so invariant checks on this event must not be\n * async functions.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
|
||||
summary: 'Committed change to one registered namespace\'s resolved value.',
|
||||
},
|
||||
{
|
||||
name: 'skills/change',
|
||||
mode: 'emit',
|
||||
@@ -2417,6 +2450,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionTitleUserMessage',
|
||||
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsApplies',
|
||||
declaration: 'export type SettingsApplies = \'live\' | \'restart\';',
|
||||
},
|
||||
{
|
||||
name: 'SettingsDescriptor',
|
||||
declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n applies: SettingsApplies;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsNamespace',
|
||||
declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SettingsRegisterOptions',
|
||||
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SettingsScope',
|
||||
declaration: 'export interface SettingsScope<T> {\n get(): T;\n watch(callback: (next: T, prev: T) => void | Promise<void>): () => void;\n update(patch: object): Promise<void>;\n replace(section: object): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillCandidate',
|
||||
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 7a91355dd01805938944f0abce77765021288e6d
|
||||
README.zh.md: 2df40b67eb8ce6cfc693ed6bf3574815c0219ec0
|
||||
@@ -0,0 +1,12 @@
|
||||
# settings/ — user-settings capability family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` |
|
||||
| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) |
|
||||
|
||||
The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document.
|
||||
@@ -0,0 +1,12 @@
|
||||
# settings/ — 用户设置能力族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交;provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx key |
|
||||
|---|---|---|
|
||||
| `settings/` | 设置 seam:namespace 注册表、分层解析、提交事件 | `ctx.settings` |
|
||||
| `settings-local/` | 文件 provider(`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings`) |
|
||||
|
||||
接口位于 `settings/settings/`;provider 平级并列。网络配置中心 provider(例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`:settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。
|
||||
@@ -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-local/README.md
|
||||
README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257
|
||||
README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68
|
||||
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-settings-local
|
||||
|
||||
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, 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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-settings-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
文件 settings provider。一个 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 的获取期限(到期则写入拒绝)与 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
|
||||
|
||||
间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无直接失效;请求前缀的变更由消费插件拥有。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
|
||||
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
|
||||
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
|
||||
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-settings-local",
|
||||
"description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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-local
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { Settings, 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-local: 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 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({
|
||||
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 Settings.update}. */
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
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-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> {
|
||||
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)
|
||||
// Exclusive-create (`wx`) a random-suffix sibling: the open refuses to
|
||||
// follow any planted symlink at a guessable temp path, and the fresh inode
|
||||
// carries owner-only permissions that survive the rename — a document that
|
||||
// may hold personal values is never world-readable and never a symlink.
|
||||
const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp`
|
||||
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
|
||||
// and parent directory and preserves owner-only permissions on Windows.
|
||||
try {
|
||||
await writeFile(temp, output, { mode: 0o600, flag: 'wx' })
|
||||
await rename(temp, this.spec.filename)
|
||||
} catch (error) {
|
||||
await rm(temp, { force: true })
|
||||
throw error
|
||||
}
|
||||
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) {
|
||||
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
|
||||
// acquisition and release so a slow writer cannot remove a successor's lock.
|
||||
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> {
|
||||
// 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]()
|
||||
if (!this.spec.watch) return
|
||||
const watcher = chokidarWatch(this.spec.filename, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: this.spec.debounceMs,
|
||||
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
|
||||
},
|
||||
})
|
||||
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-local: watcher error on %s', this.spec.filename)
|
||||
this.ctx.logger.warn(error)
|
||||
})
|
||||
yield async () => {
|
||||
// Quiesce: stop accepting events, close the watcher, then wait out any
|
||||
// queued or in-flight operation so nothing publishes after disposal.
|
||||
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') {
|
||||
const document = parseDocument(text, { prettyErrors: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
|
||||
document.errors.map(error => error.message).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-local: ${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-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)) 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 SettingsLocal
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`.
|
||||
* @module @deepseek-ai/dsh-settings-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'settings-local-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 */
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import SettingsLocal 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-local'",
|
||||
' 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-local', SettingsLocal],
|
||||
['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-local 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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,401 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal, 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 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('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 ctx = await boot({ path: join(dir, 'settings.yaml'), 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)
|
||||
})
|
||||
|
||||
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 })
|
||||
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('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 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 writeFile(path, 'ui-theme: [unclosed\n')
|
||||
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')
|
||||
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)
|
||||
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 leaves no temp residue when the directory turns unwritable', 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)
|
||||
await chmod(dir, 0o500)
|
||||
cleanups.push(() => chmod(dir, 0o700))
|
||||
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
|
||||
await chmod(dir, 0o700)
|
||||
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)
|
||||
|
||||
await writeFile(path, 'ui-theme: [unclosed\n')
|
||||
// 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 writeFile(path, 'ui-theme:\n theme: dark\n')
|
||||
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' }])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from '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 { SettingsLocal } 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 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('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(SettingsLocal, { 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' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/README.md
|
||||
README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5
|
||||
README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d
|
||||
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-settings
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.
|
||||
|
||||
## Service API
|
||||
|
||||
- `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. 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. 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
|
||||
|
||||
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
|
||||
|
||||
## Events
|
||||
|
||||
`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
|
||||
|
||||
## 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 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-settings
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
抽象用户设置 seam(`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。
|
||||
|
||||
## 服务 API
|
||||
|
||||
- `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 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
||||
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
||||
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
|
||||
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
|
||||
|
||||
## Provider 契约
|
||||
|
||||
子类实现 `writable`、`load()`、`persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 init(watcher、连接)的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
||||
|
||||
## 事件
|
||||
|
||||
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
|
||||
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
|
||||
- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-settings",
|
||||
"description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* User-settings seam (`ctx.settings`). Providers store one raw document of
|
||||
* per-namespace sections; plugins register a namespace schema and read the
|
||||
* resolved value, which layers schema defaults, the registrant's composition
|
||||
* `base`, and the user document section, in that order.
|
||||
* @module @deepseek-ai/dsh-settings
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type z from 'schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
|
||||
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
|
||||
/**
|
||||
* Brand a raw string as a {@link SettingsNamespace}.
|
||||
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
||||
* @returns the branded namespace.
|
||||
*/
|
||||
export function settingsNamespace(value: string): SettingsNamespace {
|
||||
if (!NAMESPACE_PATTERN.test(value)) {
|
||||
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
|
||||
}
|
||||
return value as SettingsNamespace
|
||||
}
|
||||
|
||||
/** When a namespace's changes take effect for its owner. */
|
||||
export type SettingsApplies = 'live' | 'restart'
|
||||
|
||||
/** Origin of one committed settings change. */
|
||||
export type SettingsUpdateSource = 'update' | 'provider'
|
||||
|
||||
/** Registration options beyond the namespace schema. */
|
||||
export interface SettingsRegisterOptions<T> {
|
||||
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
||||
base?: Partial<T>
|
||||
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
||||
applies?: SettingsApplies
|
||||
}
|
||||
|
||||
/** One registered namespace as surfaced to configuration UIs. */
|
||||
export interface SettingsDescriptor {
|
||||
// TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the
|
||||
// public seam, provider contract, implementations, tests, and consumers.
|
||||
/** The registered namespace. */
|
||||
ns: SettingsNamespace
|
||||
/** Serialized schemastery schema (`schema.toJSON()`). */
|
||||
schema: unknown
|
||||
/** Current resolved value. */
|
||||
value: unknown
|
||||
/** Owner's declared effect timing. */
|
||||
applies: SettingsApplies
|
||||
}
|
||||
|
||||
/** Owner-facing handle for one registered namespace. */
|
||||
export interface SettingsScope<T> {
|
||||
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
||||
get(): 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. 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; 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; JSON-shaped data only,
|
||||
* as for {@link update}.
|
||||
*/
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* 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.
|
||||
* @param source - whether the change entered through `update()` or the provider.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality over JSON-shaped data (objects, arrays, primitives) — the
|
||||
* seam's single change-detection predicate, exported so the invariant
|
||||
* companion checks exactly the implementation's relation.
|
||||
* @param a - one JSON-shaped value.
|
||||
* @param b - the other JSON-shaped value.
|
||||
* @returns whether the two values are structurally equal.
|
||||
*/
|
||||
export function deepEqualJson(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||
return a.every((entry, index) => deepEqualJson(entry, b[index]))
|
||||
}
|
||||
const left = a as Record<string, unknown>
|
||||
const right = b as Record<string, unknown>
|
||||
const keys = Object.keys(left)
|
||||
if (keys.length !== Object.keys(right).length) return false
|
||||
return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
|
||||
}
|
||||
|
||||
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const proto: unknown = Object.getPrototypeOf(value)
|
||||
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)
|
||||
// TODO(settings-json-properties): Use property-safe construction here and
|
||||
// in mergeLayers so valid JSON keys such as "__proto__" remain own data.
|
||||
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. `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)) {
|
||||
merged[key] = key in merged ? mergeLayers(merged[key], value) : value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
|
||||
for (const entry of Object.values(value)) deepFreeze(entry)
|
||||
return Object.freeze(value)
|
||||
}
|
||||
|
||||
/** One registered watcher and its serialized invocation chain. */
|
||||
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. */
|
||||
interface SettingsRegistration {
|
||||
ns: SettingsNamespace
|
||||
schema: z<unknown>
|
||||
base: unknown
|
||||
applies: SettingsApplies
|
||||
resolved: unknown
|
||||
watchers: Set<SettingsWatcher>
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract settings service. Providers implement raw-document storage
|
||||
* (`load`/`persist`) and push external changes through {@link Settings.publish};
|
||||
* the base class owns namespace registration, resolution, validation, change
|
||||
* detection, and the `settings/updated` commit event.
|
||||
*/
|
||||
export abstract class Settings extends Service {
|
||||
private readonly registrations = new Map<SettingsNamespace, SettingsRegistration>()
|
||||
/** Latest published raw document; empty until the provider's first publish. */
|
||||
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
|
||||
|
||||
/** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
|
||||
private isStopped(): boolean {
|
||||
return this.stopped
|
||||
}
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the provider's document once and publish it before the service
|
||||
* becomes injectable, and register the write-drain teardown. Providers with
|
||||
* their own init (watchers, connections) delegate here first via
|
||||
* `yield* super[Service.init]()`; their disposers then run before the drain.
|
||||
*/
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
yield async () => {
|
||||
// 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(), ...this.pendingTails])
|
||||
}
|
||||
this.publish(await this.load())
|
||||
}
|
||||
|
||||
/** Whether {@link update} may persist through this provider. */
|
||||
abstract readonly writable: boolean
|
||||
|
||||
/**
|
||||
* Read the provider's current raw document (namespace to raw section).
|
||||
* @returns the detached raw document.
|
||||
*/
|
||||
protected abstract load(): Promise<Record<string, unknown>>
|
||||
|
||||
/**
|
||||
* Durably store one namespace's merged user section.
|
||||
* @param ns - the namespace being written.
|
||||
* @param section - the complete merged user section to store.
|
||||
*/
|
||||
protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>
|
||||
|
||||
/**
|
||||
* Register a namespace schema and receive its owner scope. The registration
|
||||
* is an effect on the calling plugin's fiber: disposing that fiber removes
|
||||
* the namespace and its observers. An invalid stored section fails the
|
||||
* registration itself — the earliest point where the schema can judge it.
|
||||
* @param ns - unique namespace; duplicate registration fails loud.
|
||||
* @param schema - schemastery schema resolving this namespace's value.
|
||||
* @param options - composition `base` layer and effect timing.
|
||||
* @returns the owner scope for reads, observation, and updates.
|
||||
*/
|
||||
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T> {
|
||||
if (this.registrations.has(ns)) {
|
||||
throw new Error(`settings namespace "${ns}" is already registered`)
|
||||
}
|
||||
const registration: SettingsRegistration = {
|
||||
ns,
|
||||
schema: schema as z<unknown>,
|
||||
base: options?.base,
|
||||
applies: options?.applies ?? 'live',
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
|
||||
watchers: new Set(),
|
||||
}
|
||||
this.ctx.effect(() => {
|
||||
this.registrations.set(ns, registration)
|
||||
// TODO(settings-registration-quiescence): Deactivate every watcher and await
|
||||
// its tail on disposal so callbacks cannot outlive the registrant fiber.
|
||||
return () => this.registrations.delete(ns)
|
||||
}, `settings.register(${JSON.stringify(String(ns))})`)
|
||||
return {
|
||||
get: () => registration.resolved as T,
|
||||
watch: (callback) => {
|
||||
const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true }
|
||||
registration.watchers.add(watcher)
|
||||
return () => {
|
||||
watcher.active = false
|
||||
registration.watchers.delete(watcher)
|
||||
}
|
||||
},
|
||||
update: patch => this.update(ns, patch),
|
||||
replace: section => this.replace(ns, section),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe every registered namespace for configuration surfaces.
|
||||
* @returns one descriptor per registered namespace, in registration order.
|
||||
*/
|
||||
describe(): SettingsDescriptor[] {
|
||||
return [...this.registrations.values()].map(registration => ({
|
||||
ns: registration.ns,
|
||||
schema: registration.schema.toJSON(),
|
||||
value: registration.resolved,
|
||||
applies: registration.applies,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one registered namespace's resolved value.
|
||||
* @param ns - the namespace to read.
|
||||
* @returns the resolved value, or `undefined` while unregistered.
|
||||
*/
|
||||
get(ns: SettingsNamespace): unknown {
|
||||
return this.registrations.get(ns)?.resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into one registered namespace's user layer, validate the
|
||||
* resolved candidate, persist through the provider, then commit and emit.
|
||||
* A validation failure rejects before anything is persisted. Writes to one
|
||||
* namespace are serialized: concurrent updates apply in call order, each
|
||||
* merging over the previous write's committed section.
|
||||
* @param ns - the registered namespace to update.
|
||||
* @param patch - plain-object patch over the user section.
|
||||
*/
|
||||
async update(ns: SettingsNamespace, patch: object): Promise<void> {
|
||||
return this.write(ns, patch, 'merge')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one registered namespace's user section wholesale, validate,
|
||||
* persist, then commit and emit. Keys absent from `section` fall back to the
|
||||
* composition `base` and schema defaults — this is the removal/reset path a
|
||||
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
||||
* @param ns - the registered namespace to replace.
|
||||
* @param section - the complete next user section.
|
||||
*/
|
||||
async replace(ns: SettingsNamespace, section: object): Promise<void> {
|
||||
return this.write(ns, section, 'replace')
|
||||
}
|
||||
|
||||
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
||||
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
|
||||
const verb = mode === 'merge' ? 'update' : 'replace'
|
||||
const registration = this.registrations.get(ns)
|
||||
if (registration === undefined) {
|
||||
throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
}
|
||||
if (this.isStopped()) {
|
||||
throw new Error(`settings service is disposed: "${ns}" cannot be written`)
|
||||
}
|
||||
if (!this.writable) {
|
||||
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
|
||||
}
|
||||
if (!isPlainObject(input)) {
|
||||
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. 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.
|
||||
const run = previous.catch(() => undefined).then(async () => {
|
||||
if (this.isStopped()) {
|
||||
throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`)
|
||||
}
|
||||
if (this.registrations.get(ns) !== registration) {
|
||||
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
|
||||
}
|
||||
const section = mode === 'merge'
|
||||
? mergeLayers(this.section(ns) ?? {}, snapshot) as Record<string, unknown>
|
||||
: snapshot
|
||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
||||
await this.persist(ns, section)
|
||||
// The write reached storage either way; the cache must say so. Commit
|
||||
// only when this registration is still the namespace owner — a fiber
|
||||
// disposed (or replaced) mid-persist must not receive the notification.
|
||||
this.document[ns] = section
|
||||
// TODO(settings-replacement-resync): Re-resolve any replacement registration
|
||||
// from this persisted section so an old in-flight write cannot leave it stale.
|
||||
if (this.registrations.get(ns) === registration && !this.isStopped()) {
|
||||
this.commit(registration, next, 'update')
|
||||
}
|
||||
})
|
||||
this.writeQueues.set(ns, run)
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider hook: commit a complete raw document observed in storage. Each
|
||||
* registered namespace re-resolves; an invalid section keeps that
|
||||
* namespace's last good value and warns, other namespaces still commit.
|
||||
* @param doc - the detached raw document (unregistered sections preserved).
|
||||
* @param source - change origin; defaults to `provider`.
|
||||
*/
|
||||
protected publish(doc: Record<string, unknown>, source: SettingsUpdateSource = 'provider'): void {
|
||||
this.document = doc
|
||||
for (const registration of this.registrations.values()) {
|
||||
let next: unknown
|
||||
try {
|
||||
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
|
||||
this.ctx.logger.warn(error)
|
||||
continue
|
||||
}
|
||||
this.commit(registration, next, source)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one namespace's raw user section, rejecting non-object sections. */
|
||||
private section(ns: SettingsNamespace): Record<string, unknown> | undefined {
|
||||
const section = this.document[ns]
|
||||
if (section === undefined) return undefined
|
||||
if (!isPlainObject(section)) {
|
||||
throw new TypeError(`settings section "${ns}" must be an object of keys`)
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
|
||||
private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
|
||||
// The merged candidate is untyped by construction; the schema call is the
|
||||
// runtime validation that admits it into T.
|
||||
return schema(mergeLayers(base, section) as never)
|
||||
}
|
||||
|
||||
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
|
||||
private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void {
|
||||
const prev = registration.resolved
|
||||
if (deepEqualJson(next, prev)) return
|
||||
registration.resolved = next
|
||||
for (const watcher of [...registration.watchers]) {
|
||||
// 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.
|
||||
// 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
|
||||
// harness-fatal by design and rethrow after every listener ran; any other
|
||||
// failure is contained so one broken observer cannot wedge the commit
|
||||
// path (and, through it, a provider's reload loop).
|
||||
let invariantFailure: unknown
|
||||
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 {
|
||||
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.warnListenerFailure(registration.ns, error)
|
||||
}
|
||||
}
|
||||
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||
}
|
||||
|
||||
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
||||
private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export default Settings
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
|
||||
* @module @deepseek-ai/dsh-settings/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { deepEqualJson } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'settings-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Install the commit-event contract: `settings/updated` fires only for a
|
||||
* currently registered namespace, only when the resolved value changed, and
|
||||
* only with the service's authoritative resolved value — all judged with the
|
||||
* seam's own equality predicate.
|
||||
*/
|
||||
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
|
||||
ctx.on('settings/updated', (ns, next, prev) => {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) {
|
||||
fail(`settings/updated for "${ns}" emitted without a live settings service`)
|
||||
}
|
||||
const current = settings.get(ns)
|
||||
if (current === undefined) {
|
||||
fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
|
||||
}
|
||||
if (!deepEqualJson(current, next)) {
|
||||
fail(`settings/updated for "${ns}" does not match the authoritative resolved value`)
|
||||
}
|
||||
if (deepEqualJson(next, prev)) {
|
||||
fail(`settings/updated for "${ns}" emitted without a resolved-value change`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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))
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SettingsInvariant from '../src/invariant.ts'
|
||||
import { settingsNamespace } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
async function setup(withProvider: boolean): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SettingsInvariant)
|
||||
if (withProvider) await ctx.plugin(MemorySettings)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('settings invariants', () => {
|
||||
it('fails a settings/updated emission without a live settings service', async () => {
|
||||
const ctx = await setup(false)
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
|
||||
}).toThrow(/without a live settings service/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission for an unregistered namespace', async () => {
|
||||
const ctx = await setup(true)
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
|
||||
}).toThrow(/unregistered/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission without a resolved-value change', async () => {
|
||||
const ctx = await setup(true)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
theme: z.string().default('dark'),
|
||||
}))
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
|
||||
}).toThrow(/without a resolved-value change/)
|
||||
})
|
||||
|
||||
it('fails a settings/updated emission whose value diverges from the authoritative state', async () => {
|
||||
const ctx = await setup(true)
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
|
||||
theme: z.string().default('dark'),
|
||||
}))
|
||||
// Fabricated next ≠ the service's current resolved value ({theme: 'dark'}).
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update')
|
||||
}).toThrow(/authoritative/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* In-memory settings provider fixture: the smallest real subclass of the seam,
|
||||
* used by the base-class behavior suite in place of a file- or network-backed
|
||||
* provider. Kept in `tests/` because production providers live in their own
|
||||
* packages.
|
||||
*/
|
||||
|
||||
import { Settings, type SettingsNamespace } from '../src/index.ts'
|
||||
|
||||
/** In-memory provider exposing the protected seam hooks to tests. */
|
||||
export class MemorySettings extends Settings {
|
||||
/** Raw document the provider "storage" currently holds. */
|
||||
doc: Record<string, unknown>
|
||||
/** Every persist() call observed, in order. */
|
||||
persisted: Array<{ ns: SettingsNamespace; section: Record<string, unknown> }> = []
|
||||
/** When false, update() must reject before reaching persist(). */
|
||||
writableFlag: boolean
|
||||
|
||||
/** Artificial persist latency so tests can interleave concurrent updates. */
|
||||
persistDelayMs: number
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
|
||||
doc?: Record<string, unknown>
|
||||
writable?: boolean
|
||||
persistDelayMs?: number
|
||||
}) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
this.writableFlag = options?.writable ?? true
|
||||
this.persistDelayMs = options?.persistDelayMs ?? 0
|
||||
}
|
||||
|
||||
get writable(): boolean {
|
||||
return this.writableFlag
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
if (this.persistDelayMs > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.persistDelayMs))
|
||||
}
|
||||
this.persisted.push({ ns, section: structuredClone(section) })
|
||||
this.doc[ns] = structuredClone(section)
|
||||
}
|
||||
|
||||
/** Simulate an external storage change reaching the provider. */
|
||||
pushExternal(doc: Record<string, unknown>): void {
|
||||
this.doc = structuredClone(doc)
|
||||
this.publish(structuredClone(doc))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
/** A provider implementing only the three primitives: the seam owns init. */
|
||||
class BareProvider extends Settings {
|
||||
doc: Record<string, unknown>
|
||||
|
||||
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown> }) {
|
||||
super(ctx)
|
||||
this.doc = structuredClone(options?.doc ?? {})
|
||||
}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc[ns] = structuredClone(section)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
interface NestedConfig {
|
||||
retry: { attempts: number; delayMs: number }
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const NestedSchema: z<NestedConfig> = z.object({
|
||||
retry: z.object({
|
||||
attempts: z.number().default(2),
|
||||
delayMs: z.number().default(100),
|
||||
}),
|
||||
tags: z.array(z.string()).default(['default']),
|
||||
})
|
||||
|
||||
async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(MemorySettings, options)
|
||||
await fiber
|
||||
const provider = ctx.get('settings') as MemorySettings
|
||||
return { ctx, provider, fiber }
|
||||
}
|
||||
|
||||
/** Record every settings/updated emission. */
|
||||
function recordUpdates(ctx: Context) {
|
||||
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
|
||||
ctx.on('settings/updated', (ns, next, prev, source) => {
|
||||
events.push({ ns, next, prev, source })
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
describe('settingsNamespace', () => {
|
||||
it('brands lowercase kebab-case names', () => {
|
||||
expect(settingsNamespace('ui-theme')).toBe('ui-theme')
|
||||
})
|
||||
|
||||
it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
|
||||
expect(() => settingsNamespace(value)).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration', () => {
|
||||
it('resolves schema defaults, then composition base, then the user layer', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
// theme: user layer wins; fontSize: base wins over the schema default.
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
|
||||
})
|
||||
|
||||
it('rejects a duplicate namespace loud', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
||||
.toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is invalid for the schema', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is not an object', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
|
||||
.toThrow(/must be an object/)
|
||||
})
|
||||
|
||||
it('describes registered namespaces with schema JSON, value, and applies', async () => {
|
||||
const { ctx } = await boot()
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
|
||||
const descriptors = ctx.settings.describe()
|
||||
expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
|
||||
['ui-theme', 'live'],
|
||||
['workspace', 'restart'],
|
||||
])
|
||||
expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
// schemastery's canonical wire form: a { uid, refs } envelope whose root ref
|
||||
// is the object schema — the shape schema-driven form UIs reconstruct from.
|
||||
const serialized = descriptors[0]!.schema as { uid: number; refs: Record<string, { type: string }> }
|
||||
expect(serialized.refs[String(serialized.uid)]?.type).toBe('object')
|
||||
})
|
||||
|
||||
it('reads undefined for an unregistered namespace', async () => {
|
||||
const { ctx } = await boot()
|
||||
expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hands out frozen resolved values', async () => {
|
||||
const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
const value = scope.get()
|
||||
expect(Object.isFrozen(value)).toBe(true)
|
||||
expect(Object.isFrozen(value.retry)).toBe(true)
|
||||
expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('removes the namespace and its observers when the registrant fiber disposes', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const seen: unknown[] = []
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch((next) => { seen.push(next) })
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
|
||||
expect(ctx.settings.describe()).toEqual([])
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(seen).toEqual([])
|
||||
|
||||
// The namespace is free again, and re-registration resolves the user layer
|
||||
// that kept living in storage while nobody owned the namespace.
|
||||
const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('persists the merged user section without baking in the base layer', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
await scope.update({ theme: 'dark' })
|
||||
expect(provider.persisted).toEqual([
|
||||
{ ns: 'ui-theme', section: { theme: 'dark' } },
|
||||
])
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
})
|
||||
|
||||
it('deep-merges nested objects and replaces arrays wholesale', async () => {
|
||||
const { ctx, provider } = await boot({
|
||||
doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
|
||||
expect(provider.persisted[0]!.section).toEqual({
|
||||
retry: { attempts: 7, delayMs: 300 },
|
||||
tags: ['c'],
|
||||
})
|
||||
expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] })
|
||||
})
|
||||
|
||||
it('commits, notifies watchers, and emits with source update', async () => {
|
||||
const { ctx } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
await scope.update({ theme: 'light' })
|
||||
expect(watcher).toHaveBeenCalledWith(
|
||||
{ theme: 'light', fontSize: 14 },
|
||||
{ theme: 'dark', fontSize: 14 },
|
||||
)
|
||||
expect(events).toEqual([{
|
||||
ns: 'ui-theme',
|
||||
next: { theme: 'light', fontSize: 14 },
|
||||
prev: { theme: 'dark', fontSize: 14 },
|
||||
source: 'update',
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects an invalid patch before persisting anything', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
|
||||
expect(provider.persisted).toEqual([])
|
||||
expect(events).toEqual([])
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
// The failed write must not poison the namespace queue for later writers.
|
||||
await scope.update({ fontSize: 18 })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await scope.update({ theme: undefined, fontSize: 18 })
|
||||
expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('rejects a non-object patch', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update([1])).rejects.toThrow(TypeError)
|
||||
await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
|
||||
await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/)
|
||||
})
|
||||
|
||||
it('accepts a null-prototype patch object', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
|
||||
patch.fontSize = 18
|
||||
await scope.update(patch)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
|
||||
it('rejects an unregistered namespace', async () => {
|
||||
const { ctx } = await boot()
|
||||
await expect(ctx.settings.update(settingsNamespace('missing'), {}))
|
||||
.rejects.toThrow(/not registered/)
|
||||
})
|
||||
|
||||
it('rejects on a read-only provider before reaching persist', async () => {
|
||||
const { ctx, provider } = await boot({ writable: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
|
||||
expect(provider.persisted).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deepEqualJson', () => {
|
||||
it.each([
|
||||
[{ a: [1, 2] }, { a: [1, 2] }, true],
|
||||
[{ a: [1, 2] }, { a: [1] }, false],
|
||||
[{ a: [1] }, { a: { 0: 1 } }, false],
|
||||
[{ a: 1 }, { b: 1 }, false],
|
||||
[{ a: 1 }, {}, false],
|
||||
[{ a: null }, { a: null }, true],
|
||||
[{ a: null }, { a: {} }, false],
|
||||
])('compares %j vs %j as %s', (a, b, equal) => {
|
||||
expect(deepEqualJson(a, b)).toBe(equal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review regressions', () => {
|
||||
it('propagates an invariant-coded listener failure instead of containing it', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
ctx.on('settings/updated', () => {
|
||||
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
|
||||
})
|
||||
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) })
|
||||
.toThrow(/forged relation/)
|
||||
})
|
||||
|
||||
it('serializes concurrent updates so neither patch is lost', async () => {
|
||||
const { ctx, provider } = await boot({ persistDelayMs: 10 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
await Promise.all([
|
||||
scope.update({ theme: 'light' }),
|
||||
scope.update({ fontSize: 20 }),
|
||||
])
|
||||
expect(provider.doc['ui-theme']).toEqual({ theme: 'light', fontSize: 20 })
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 20 })
|
||||
})
|
||||
|
||||
it('contains a throwing settings/updated listener and keeps later commits alive', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
ctx.on('settings/updated', () => {
|
||||
throw new Error('listener boom')
|
||||
})
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow()
|
||||
expect(scope.get().theme).toBe('light')
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'dark' } })
|
||||
expect(scope.get().theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('contains an async watcher rejection', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch(async () => {
|
||||
throw new Error('async watcher boom')
|
||||
})
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(scope.get().theme).toBe('light')
|
||||
// Give the rejected watcher promise a microtask turn; containment means
|
||||
// vitest observes no unhandled rejection out of this test.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
|
||||
it('loads the provider document through the base init without provider boilerplate', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 })
|
||||
})
|
||||
|
||||
it('replaces the user section wholesale so overrides can be removed', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
||||
base: { fontSize: 16 },
|
||||
})
|
||||
await scope.replace({ theme: 'light' })
|
||||
// fontSize override is gone: resolution falls back to the base layer.
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
|
||||
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
|
||||
await scope.replace({})
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
||||
expect(provider.doc['ui-theme']).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('second review regressions', () => {
|
||||
it('runs every settings/updated listener even when an earlier one throws', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
ctx.on('settings/updated', () => {
|
||||
throw new Error('first listener 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)
|
||||
})
|
||||
|
||||
it('rejects an update queued after the registrant fiber disposed', async () => {
|
||||
const { ctx } = await boot()
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
await fiber.dispose()
|
||||
await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/)
|
||||
})
|
||||
|
||||
it('does not notify a registrant disposed while its update was in flight', async () => {
|
||||
const { ctx, provider } = await boot({ persistDelayMs: 30 })
|
||||
const events = recordUpdates(ctx)
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const watcher = vi.fn()
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch(watcher)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
const pending = scope!.update({ theme: 'light' })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
await pending.catch(() => undefined)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
expect(events).toEqual([])
|
||||
// The persist was already in flight, so storage keeps the write — but no
|
||||
// commit reached the disposed registration.
|
||||
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
|
||||
})
|
||||
|
||||
it('drains in-flight writes at service dispose and rejects later ones', async () => {
|
||||
const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
|
||||
const service = ctx.settings
|
||||
const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const pending = scope.update({ theme: 'light' })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
// The teardown drained the in-flight write before completing…
|
||||
await pending.catch(() => undefined)
|
||||
const persistedAtDispose = provider.persisted.length
|
||||
expect(persistedAtDispose).toBe(1)
|
||||
// …and afterwards nothing writes and new writes reject.
|
||||
await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
|
||||
.rejects.toThrow(/disposed|not registered/)
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(provider.persisted.length).toBe(persistedAtDispose)
|
||||
})
|
||||
|
||||
it('serializes invocations of one async watcher in commit order', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const applied: number[] = []
|
||||
let firstCall = true
|
||||
scope.watch(async (next) => {
|
||||
// The first (stale) invocation is slow; unserialised it would finish
|
||||
// last and clobber the newer applied state.
|
||||
const delay = firstCall ? 30 : 0
|
||||
firstCall = false
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
applied.push(next.fontSize)
|
||||
})
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 1 } })
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 2 } })
|
||||
await vi.waitFor(() => {
|
||||
expect(applied).toHaveLength(2)
|
||||
})
|
||||
expect(applied).toEqual([1, 2])
|
||||
})
|
||||
|
||||
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.*function at \$\.theme/)
|
||||
})
|
||||
|
||||
it('rejects a write still queued when the service disposes', async () => {
|
||||
const { ctx, fiber } = await boot({ persistDelayMs: 20 })
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const first = scope.update({ theme: 'light' })
|
||||
const second = scope.update({ fontSize: 20 })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
await first
|
||||
await expect(second).rejects.toThrow(/disposed before the queued/)
|
||||
})
|
||||
|
||||
it('rejects a write still queued when the registrant disposes', async () => {
|
||||
const { ctx } = await boot({ persistDelayMs: 20 })
|
||||
let scope: SettingsScope<ThemeConfig> | undefined
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
const first = scope!.update({ theme: 'light' })
|
||||
const second = scope!.update({ fontSize: 20 })
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
await fiber.dispose()
|
||||
await first
|
||||
await expect(second).rejects.toThrow(/registration was disposed before the queued/)
|
||||
})
|
||||
|
||||
it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
|
||||
const { ctx } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const patch = { fontSize: 18 }
|
||||
const pending = scope.update(patch)
|
||||
patch.fontSize = 99
|
||||
await pending
|
||||
expect(scope.get().fontSize).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publish', () => {
|
||||
it('notifies watchers of an external change with source provider', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(watcher).toHaveBeenCalledWith(
|
||||
{ theme: 'light', fontSize: 14 },
|
||||
{ theme: 'dark', fontSize: 14 },
|
||||
)
|
||||
})
|
||||
expect(events[0]!.source).toBe('provider')
|
||||
})
|
||||
|
||||
it('stays silent when the resolved value is deep-equal', async () => {
|
||||
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
scope.watch(watcher)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the last good value for an invalid section while other namespaces commit', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
|
||||
provider.pushExternal({
|
||||
'ui-theme': { fontSize: 'broken' },
|
||||
workspace: { retry: { attempts: 9 } },
|
||||
})
|
||||
expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] })
|
||||
expect(events.map(event => event.ns)).toEqual(['workspace'])
|
||||
})
|
||||
|
||||
it('recovers from a bad section once storage turns valid again', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
||||
provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
|
||||
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
const watcher = vi.fn()
|
||||
const dispose = scope.watch(watcher)
|
||||
dispose()
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
expect(watcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('contains a throwing watcher without blocking the commit or other watchers', async () => {
|
||||
const { ctx, provider } = await boot()
|
||||
const events = recordUpdates(ctx)
|
||||
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
||||
scope.watch(() => { throw new Error('watcher boom') })
|
||||
const second = vi.fn()
|
||||
scope.watch(second)
|
||||
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
|
||||
await vi.waitFor(() => {
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(events).toHaveLength(1)
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+40
@@ -4268,6 +4268,46 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/settings/settings:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
|
||||
packages/settings/settings-local:
|
||||
dependencies:
|
||||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
yaml:
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-paths':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/paths
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../settings
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/skill/skill:
|
||||
dependencies:
|
||||
schemastery:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1755,
|
||||
"AGENTS.md": 1765,
|
||||
"docs/AGENTS.md": 1150,
|
||||
"docs/architecture.md": 1920,
|
||||
"docs/cordis-primer.md": 600,
|
||||
@@ -7,5 +7,5 @@
|
||||
"docs/testing.md": 1100,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 675,
|
||||
"packages/README.md": 900
|
||||
"packages/README.md": 905
|
||||
}
|
||||
@@ -189,6 +189,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ToolRegistry: 'tools.md',
|
||||
ToolRestriction: 'tools.md',
|
||||
ToolSchema: 'tools.md',
|
||||
SettingsNamespace: 'settings.md',
|
||||
SettingsRegisterOptions: 'settings.md',
|
||||
SettingsScope: 'settings.md',
|
||||
SettingsDescriptor: 'settings.md',
|
||||
SettingsUpdateSource: 'settings.md',
|
||||
AskUserQuestionAnswer: 'user-interaction.md',
|
||||
AskUserQuestionRequest: 'user-interaction.md',
|
||||
UserInteractionProvider: 'user-interaction.md',
|
||||
@@ -219,6 +224,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
/** Project types deliberately documented outside the core-data catalog. */
|
||||
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
|
||||
z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
|
||||
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
|
||||
@@ -162,6 +162,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
pkg: 'settings',
|
||||
title: 'User-settings seam',
|
||||
mode: 'seam',
|
||||
implementations: ['settings-local'],
|
||||
consumers: [],
|
||||
note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet.',
|
||||
},
|
||||
{
|
||||
key: 'telemetry',
|
||||
pkg: 'session-telemetry',
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('docsPages locale routes', () => {
|
||||
const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
|
||||
const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
|
||||
|
||||
expect(translated).toHaveLength(18)
|
||||
expect(translated).toHaveLength(19)
|
||||
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
|
||||
expect(fallbacks.map(page => page.source).sort()).toEqual([
|
||||
'docs/core-data-structures/commands.md',
|
||||
|
||||
@@ -1358,6 +1358,36 @@
|
||||
"doc": "docs/core-data-structures/subprocess.md",
|
||||
"symbol": "SubprocessCollectedOutputs",
|
||||
"source": "packages/subprocess/subprocess/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsNamespace",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsRegisterOptions",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsApplies",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsScope",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsDescriptor",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/settings.md",
|
||||
"symbol": "SettingsUpdateSource",
|
||||
"source": "packages/settings/settings/src/index.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -103,6 +103,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' },
|
||||
'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' },
|
||||
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"./packages/session-projection/*/src/invariant.ts",
|
||||
"./packages/session-title/*/src/invariant.ts",
|
||||
"./packages/session-query/*/src/invariant.ts",
|
||||
"./packages/settings/*/src/invariant.ts",
|
||||
"./packages/telemetry/*/src/invariant.ts",
|
||||
"./packages/acp/*/src/invariant.ts",
|
||||
"./packages/storage/*/src/invariant.ts",
|
||||
@@ -187,6 +188,7 @@
|
||||
"./packages/session-projection/*/src",
|
||||
"./packages/session-query/*/src",
|
||||
"./packages/session-title/*/src",
|
||||
"./packages/settings/*/src",
|
||||
"./packages/telemetry/*/src",
|
||||
"./packages/acp/*/src",
|
||||
"./packages/storage/*/src",
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
{ "path": "./packages/session-projection/session-projection-cache" },
|
||||
{ "path": "./packages/session-query/session-query" },
|
||||
{ "path": "./packages/session-query/session-query-sqlite" },
|
||||
{ "path": "./packages/settings/settings" },
|
||||
{ "path": "./packages/settings/settings-local" },
|
||||
{ "path": "./packages/session-query/tool-session-query" },
|
||||
{ "path": "./packages/storage/storage" },
|
||||
{ "path": "./packages/storage/storage-json" },
|
||||
|
||||
@@ -256,6 +256,7 @@ const coreDataReference = pairedPages(([
|
||||
['sandbox.md', '沙箱', 'Sandboxing', 18],
|
||||
['web.md', 'Web 访问', 'Web access', 19],
|
||||
['persistence.md', '会话持久化', 'Session persistence', 20],
|
||||
['settings.md', '用户设置', 'User settings', 21],
|
||||
] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({
|
||||
source: `docs/core-data-structures/${file}`,
|
||||
route: `reference/core-data-structures/${file}`,
|
||||
|
||||
Reference in New Issue
Block a user