feat(config): add volatile schemas and watcher-driven updates (#4579)

* feat(cordis): add volatile config references without remounting

* refactor(cordis): use internal prefix for volatile updates

* fix(schemastery): preserve metadata-free serialized schemas

* refactor(config): move volatile updates into an opt-in Loader plugin

* refactor(config): reuse Loader context patch for volatile updates

* refactor(config): let the volatile watcher commit equal candidates

* refactor(config): separate comparison from volatile reconciliation

* refactor(loader): extract pure entry option diff

* refactor(loader): compare volatile config through schema metadata

* refactor(loader): make volatile comparison policy explicit

* refactor: name config reload and volatile HMR events

* refactor(loader): compare volatile config in one schema walk; declare hmr/volatile-update in volatile-hmr

Replace the mask/project pair in vendor/loader/src/config/diff.ts with one recursive schema-guided comparison. Absent objects now compare as their declared schema default at every object node, not only under volatile ancestors; both inputs resolve to the same validated config, so the one changed assertion (undefined vs {} under a non-volatile object schema) becomes unchanged. The isJsExpr special case is dropped: expression keys fall through to raw comparison.

Move the hmr/volatile-update Events declaration from vendored Cordis core to its producer, packages/boot/volatile-hmr, matching how dsh-hmr declares hmr/change and hmr/reload. Vendored cordis events.ts returns to upstream content; the inherited Cordis API catalog and the event matrix are regenerated, and the tutorial example imports the package types.

* fix(cosmokit): compare sparse array slots in deepEqual; extend volatile diff coverage

Array.prototype.every skipped holes, so deepEqual(Array(1), ['new']) was true in one direction. Compare every index with holes read as undefined.

config/diff.ts treats expression objects as opaque values even when a schema field shares the marker name, and looks up field schemas with Object.hasOwn so unknown keys named after Object prototype members compare raw. Tests cover absent-object defaults, sparse arrays in both directions, prototype-named keys, metadata-free serialized schema nodes, and a runtime remount check in volatile-hmr. READMEs and the architecture note describe the object-default rule.

* test(schemastery): build serialized schema fixtures without unknown casts

verify-no-unknown-casts rejects the two as-unknown assertions in scripts/volatile-config.spec.ts. Parse the serialized nodes from JSON text instead, matching scripts/loader-config-diff.spec.ts.

* docs(volatile-hmr): drop the scope-scan tag from hmr/volatile-update

verify-scoped-events rejects @dshScopeScan on an event that carries no scoped keys. Catalogs and the event matrix are regenerated.

* docs: regenerate module graph for the volatile-hmr dependency edge

verify-module-graph reports the merged docs stale: volatile-hmr now lists its app-boot dependency.

* test(volatile-hmr): watch the real YAML through an exact config watch

The Windows coverage lane lost the whole spec: its vitest worker exited without a report once this test started a chokidar directory watch (root: ['.']), the only real directory watch in the test suite. Register the include file through hmr.watchConfig instead, the pattern the HMR package's own real-file tests run on Windows, and emit loader/config-reload from the refresh callback as the root-watch path does. packages/boot/hmr/tests/coordination.spec.ts already covers that root-watch emission with a mocked watcher; volatile-hmr and hmr sources stay at 100% coverage.

* test(volatile-hmr): pass the Include path as a file URL

On Windows the plain absolute path made the include subtree fail to load, so the profile-patch test found no consumer entry. Other Include fixtures in this spec already pass file URLs.

* refactor(loader): commit volatile config in Entry.update and remove volatile-hmr

loader/config-reload was emitted from two places (app-boot profile reconciliation and the HMR root watch) and by hand in tests, because a separate plugin needed a "configuration applied" signal to run its own pass. Every update path already funnels through Entry.update, which is where the Loader decides to skip a remount for a volatile-only change. Entry._commitVolatile now parses the candidate through the fiber's internal/config hook and schema, commits the new values into the running references when the ordinary effective values still match, and emits loader/volatile-update to the owning fiber. A changed ordinary effective value falls back to the ordinary remount instead of leaving the instance stale; an invalid candidate is logged and keeps the running references, with the raw config retained for the next activation. Forced group updates take the same path.

Remove packages/boot/volatile-hmr, its event, the app-boot loader/config-reload declaration and emit, and the HMR emission. Loader runtime and real-file tests move to scripts/loader-volatile-update.spec.ts. Docs, catalogs, the event matrix, module graph and the architecture note follow.

* test: share the volatile schema fixture between the schema and Loader specs

The duplication gate flagged the identical Config fixture in scripts/volatile-config.spec.ts and scripts/loader-volatile-update.spec.ts.

* refactor(loader): reduce config/diff.ts to equalExceptVolatile

Entry.update now computes the strict raw option changes itself and only asks diff.ts whether two raw configs differ at most in volatile fields. The ignoreVolatile flag, the options object and the "config is the sole change" rule leave diff.ts; they were the Loader's decision and now sit next to the commit they gate. The raw-config deepEqual in the volatile branch is gone because the strict change list already answers it.

* refactor(loader): type the volatile comparison against Schemastery's Schema

config/diff.ts narrows the plugin's Standard Schema to Schemastery's own Schema type instead of a hand-written structural interface, which makes vendor/loader a type-only dependent of @deepseek-ai/schemastery. The rescope codemod treats the vendor identifier comparison like schemastery's own source and renames the import through an exact edit.

* fix(loader): address review findings on the volatile commit path

Entry.update only replaces the fiber's pending raw config for a volatile-only change, so a noSave direct update survives a no-op entry update. _commitVolatile logs at debug level when ordinary effective values changed and the ordinary update applies, dispatches loader/volatile-update with a Context receiver like Cordis' own filtered emits, and isolates a throwing listener. Schemastery reports unsupported volatile values as ValidationError with their path and validates a lazy inner once when it is built. The inherited-event catalog gains internal/config and corrected source lines; the Loader spec imports dsh-hmr by package name and covers the new behaviors. Docs record that class-instance ordinary fields always take the remount path and that .default() accepts the raw input type.
This commit is contained in:
Turtle
2026-09-21 00:04:06 +08:00
committed by GitHub
parent b73a71e89c
commit 3f7016a422
36 changed files with 1476 additions and 100 deletions
@@ -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-09-18-volatile-config-references.md
2026-09-18-volatile-config-references.md: afaa6affdc3d18da182ec069126033cb55393b72
2026-09-18-volatile-config-references.zh.md: 3d8800cb92dec4da80b698cb8b3c073331ed266f
@@ -0,0 +1,33 @@
# Agent Note: Volatile configuration references
Status: implemented
English | [中文](2026-09-18-volatile-config-references.zh.md)
## Problem
Replacing an entire plugin to change a value also disposes its services and effects. Separate settings subscriptions require each consumer to implement another source of live configuration.
## Decision
Schemastery declares live fields with `.volatile()`, preserving node types and form metadata while returning stable references to immutable snapshots. Cosmokit owns the shared protocol. General comparison treats two volatile references as equal. Loader calculates raw config differences using schema-declared volatile paths and retains raw config for subsequent activation. When the raw diff reports no ordinary change but the raw config differs, Loader parses and validates the candidate through the fiber's `internal/config` hook, compares ordinary effective values, commits references when those values still match, and notifies the owning fiber; every update path, including profile reconciliation, HMR Include refreshes and direct `entry.update()` calls, reaches this step through `Entry.update`. Fiber does not own this update policy, and HMR supplies file watching.
Only volatile changes preserve the instance. Ordinary-field changes use the existing remount lifecycle and do not update the old references. An instance-local `loader/volatile-update` event reports paths after the complete candidate is validated and all references are committed. It uses ordinary `emit`; listener completion is not a configuration commit condition. References can be retained, while values may only be captured for one operation. Missing optional values retain a readable reference.
Config schemas own the field types, defaults, roles, and live-update declaration needed by schema-driven settings forms. Existing settings consumers remain unchanged; automatic form generation and their migration are separate work. The [configuration guide](../../../../docs/cordis-tutorial/05-config.md) owns plugin-author usage.
## Alternatives considered
**Restart for every configuration change.** This releases resources that remain valid when only operation-time parameters change.
**Add another settings-specific live-value API.** It duplicates runtime update semantics and makes plugins depend on settings to consume ordinary configuration.
**Wrap values only during mounting.** Direct schema parsing would return a different kind of value from the plugin's config, requiring separate consumer types.
## Consequences
Schemas reject independent volatile references inside dynamic containers; entire objects and arrays can be volatile values. Immutable snapshots accept plain data. Simplification unwraps references, and shared symbols support ESM/CJS copies. Group/Include retain dispatch while volatile-only target updates bypass `internal/update`. Direct `fiber.update()` retains its semantics, including custom handling, saving and `noSave`. Loader commits live volatile updates itself; no separate plugin is required.
Schema metadata keeps comparison independent of parsing: the diff does not execute expressions, validators or config hooks. Object nodes compare missing or null values using their declared defaults, even without volatile descendants; this avoids remounts for equivalent object defaults. Ordinary expression edits retain raw comparison even when they resolve to equal values. Loader compares parsed ordinary values, including URLs by normalized address. If an ordinary expression produces a different value, Loader applies the ordinary remount instead of committing; strict comparison treats class instances other than URL, Date and RegExp by identity, so an ordinary transform that builds a fresh instance per parse always remounts. Listener failures during the notification are logged and do not fail the update. Loader validates volatile candidates before committing; invalid edits remain in raw config but leave running references unchanged until a later activation.
The schema and lifecycle regression suite covers direct updates, invalid candidates, instance isolation, dependency replacement, real file watching, and save/reload behavior. The HMR module suite covers replacement after live config updates. These tests do not claim settings migration or automatic form rendering.
@@ -0,0 +1,33 @@
# Agent Note: Volatile 配置引用
Status: implemented
[English](2026-09-18-volatile-config-references.md) | 中文
## Problem
为了改变一个值而替换整个插件,也会销毁插件的服务和副作用。独立的 settings 订阅要求每个消费者额外实现一套动态配置来源。
## Decision
Schemastery 使用 `.volatile()` 声明动态字段,保留节点类型和表单元数据,解析结果是具有不可变快照的稳定引用。共享协议位于 Cosmokit。通用比较将两个 volatile 引用视为相等。Loader 根据 schema 声明的 volatile 路径计算原始配置差异,并保留原始配置供后续激活使用。原始 diff 未报告普通字段变化但原始配置有差异时,Loader 经 fiber 的 `internal/config` 钩子解析并校验候选配置,比较普通字段的有效值,在这些值仍匹配时提交引用并通知所属实例;所有更新路径,包括 profile 重载、HMR 的 Include 刷新和直接调用 `entry.update()`,都经 `Entry.update` 到达这一步。Fiber 不承担这项更新策略,文件监视由 HMR 提供。
仅 volatile 变化保留实例。普通字段变化沿用现有重新挂载生命周期,不更新旧引用。实例局部的 `loader/volatile-update` 事件在完整候选配置通过校验且所有引用提交后报告路径。事件使用普通 `emit`;监听器完成不是配置提交条件。可以保存引用,而值只能在单次操作中保存。缺省可选值仍具有可读取的引用。
Config schema 负责提供基于 schema 的 settings 表单需要的字段类型、默认值、角色和动态更新声明。现有 settings 消费者保持不变;自动表单生成及其迁移是独立工作。[配置指南](../../../../docs/cordis-tutorial/05-config.zh.md)负责插件作者的使用说明。
## Alternatives considered
**每次配置变化都重启。** 当只有操作时读取的参数变化时,这会释放仍然有效的资源。
**增加另一套 settings 专用动态值 API。** 这会重复运行时更新语义,并使插件为了读取普通配置而依赖 settings。
**仅在挂载时包装值。** 直接解析 schema 与插件配置将返回不同种类的值,需要独立的消费者类型。
## Consequences
Schema 拒绝动态容器内部的独立 volatile 引用;整个对象和数组可以作为一个 volatile 值。不可变快照接受普通数据。配置简化解包引用,共享 symbol 支持 ESM/CJS 副本互操作。Group/Include 保留分发,目标插件的仅 volatile 更新不进入 `internal/update`。直接调用 `fiber.update()` 保留原语义,包括自定义处理、保存及 `noSave`。动态 volatile 更新由 Loader 自己提交,不需要额外插件。
Schema 元数据使比较独立于解析:diff 不执行表达式、校验器或配置钩子。对象节点的缺省值或 null 按声明的默认值比较,即使没有 volatile 后代字段;等价的对象默认值不触发重新挂载。普通表达式即使求值结果相等,修改时仍按原始值比较。Loader 比较解析后的普通值,其中 URL 按规范化地址比较。普通表达式产生不同值时,Loader 改为按普通流程重新挂载而不提交;严格比较对 URL、Date、RegExp 之外的类实例按身份比较,因此每次解析都产生新实例的普通 transform 字段总是走重新挂载。通知阶段监听器抛错只记录日志,不使更新失败。Loader 在提交前校验 volatile 候选;非法编辑保留在原始配置中,但在后续激活之前不改变运行中的引用。
Schema 和生命周期回归测试覆盖直接更新、无效候选、实例隔离、依赖替换、真实文件监视及保存后重新加载。HMR 模块测试覆盖动态配置更新后的代码替换。这些测试不代表 settings 迁移或自动表单渲染已经完成。
+15 -13
View File
@@ -21,16 +21,18 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
## Inherited events (cordis core + loader/hmr/timer)
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts))
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts))
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts))
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts))
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts))
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts))
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts))
- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))
- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts))
- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))
- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts))
- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts))
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:331`](../../vendor/cordis/src/events.ts))
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:333`](../../vendor/cordis/src/events.ts))
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts))
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:343`](../../vendor/cordis/src/events.ts))
- `internal/config` — Waterfall: a fiber config is being resolved before validation. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts))
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:345`](../../vendor/cordis/src/events.ts))
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:347`](../../vendor/cordis/src/events.ts))
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:349`](../../vendor/cordis/src/events.ts))
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:351`](../../vendor/cordis/src/events.ts))
- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))
- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts))
- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts))
- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:28`](../../vendor/loader/src/index.ts))
- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:41`](../../vendor/loader/src/index.ts))
- `loader/volatile-update` — Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only. ([`vendor/loader/src/index.ts:34`](../../vendor/loader/src/index.ts))
+2 -2
View File
@@ -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/cordis-tutorial/05-config.md
05-config.md: 17cccce2ec43be65477ce527800ee6a636ee5d96
05-config.zh.md: e19dcb3ab0082cdc6ba2457158491ba47223409d
05-config.md: f278e8d06ef867d2944c279d067c36970582167f
05-config.zh.md: 65e9ea73a5ce31776dc1603339fa7c1c0250f856
+27
View File
@@ -67,6 +67,33 @@ ValidationError: invalid config:
The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference.
<a id="volatile-fields"></a>
## Volatile fields
Use `.volatile()` for a field that the plugin reads during each operation. A change to that field updates its stable reference without remounting the plugin. Read it with `.get()`:
```ts
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import Schema from '@deepseek-ai/schemastery'
export const Config = Schema.object({
greeting: Schema.string().default('Hello').volatile(),
})
export function apply(ctx: Context, config: ReturnType<typeof Config>) {
ctx.on('loader/volatile-update', () => {
console.log(config.greeting.get())
})
}
```
A direct `Config(raw)` call also returns references. An optional field without a default still has a reference; `.get()` returns `undefined` when it is absent. Keep the reference, or capture its value for one operation, including across `await`. Do not retain that value for later operations. Object and array values are detached, recursively frozen snapshots of plain data; functions, class instances, and cycles are rejected.
Loader compares raw config while ignoring schema-declared volatile fields. A volatile-only change is parsed through the plugin’s `internal/config` hook and validated; when every ordinary effective value still matches, Loader commits the new values into the running references and emits one instance-local `loader/volatile-update` with the changed paths as arrays of keys, bypassing the target plugin’s `internal/update`. Equal values do not notify. An invalid candidate is logged and leaves the running references unchanged until the next activation. A changed ordinary effective value, including a changed expression result, and simultaneous ordinary-field changes retain the existing update flow, leaving old references unchanged and unnotified. Group/Include still dispatch child updates. Direct `fiber.update()`, code replacement, and dependency replacement retain their lifecycle; `noSave` still controls direct-update persistence. Notifications use ordinary `emit` without awaiting resource reconfiguration.
Declare volatile fields at fixed object paths, or mark an entire object or array volatile. Independent references inside arrays, dictionaries, union or intersect branches, lazy schemas, transforms, or another volatile value are rejected. Configuration files store ordinary values; `simplify()` unwraps references. Serialized schemas preserve their field types, defaults, roles, and volatile marker for schema-driven forms. Existing settings consumers continue to use their own settings interfaces.
## Computed config values
The loader used in this repo supports a `!!js` tag for config values that must be computed at load time:
+27
View File
@@ -67,6 +67,33 @@ ValidationError: invalid config:
插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。
<a id="volatile-fields"></a>
## Volatile 字段
对于插件在每次操作中读取的字段,可以使用 `.volatile()`。字段变化会更新稳定引用,无需重新挂载插件。通过 `.get()` 读取:
```ts
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import Schema from '@deepseek-ai/schemastery'
export const Config = Schema.object({
greeting: Schema.string().default('Hello').volatile(),
})
export function apply(ctx: Context, config: ReturnType<typeof Config>) {
ctx.on('loader/volatile-update', () => {
console.log(config.greeting.get())
})
}
```
直接调用 `Config(raw)` 也会返回引用。没有默认值的可选字段仍然具有引用;字段缺省时,`.get()` 返回 `undefined`。可以保存引用,也可以在单次操作中保存读取值,包括跨越 `await`,但不能将该值用于后续操作。对象和数组值是脱离输入、递归冻结的普通数据快照;函数、类实例和循环引用会被拒绝。
Loader 比较原始配置时忽略 schema 声明的 volatile 字段。仅 volatile 变化会经目标插件的 `internal/config` 钩子解析并校验;当所有普通字段的有效值仍然一致时,Loader 把新值提交到运行中的引用,并向所属实例发出一次 `loader/volatile-update`,参数是以键数组表示的变化路径,不进入目标插件的 `internal/update`。等值更新不通知。无效候选会记录日志,运行中的引用保持不变,直到下次激活。普通字段有效值变化(包括表达式求值结果变化)以及普通字段同时变化时沿用原更新流程,旧引用不更新也不接收通知。Group/Include 仍分发子条目更新。直接 `fiber.update()`、代码替换和依赖替换保留原有生命周期;`noSave` 仍控制直接更新的持久化。通知使用普通 `emit`,不等待资源重新配置完成。
在固定对象路径上声明 volatile 字段,或将整个对象、数组标记为 volatile。数组、字典、union 或 intersect 分支、lazy、transform 或另一个 volatile 值内部的独立引用会被拒绝。配置文件保存普通值;`simplify()` 会解包引用。序列化 schema 保留字段类型、默认值、角色和 volatile 标记,供基于 schema 的表单使用。现有 settings 消费者继续使用原有 settings 接口。
## 计算得到的配置值
本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值:
+2 -2
View File
@@ -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/boot/app-boot/README.md
README.md: ef98b3fd98829c14dc6614de321cc02837c8eb0c
README.zh.md: 2e0ce42936701ddd94d82dae5fcfee5d33e326e0
README.md: 4763d5e0e22560373320b1f6a478e3a71212c4e6
README.zh.md: 5fc90e3773ce152e8fca78c101753e746e986985
+1 -1
View File
@@ -73,7 +73,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi
<a id="startup-and-reload-failures"></a>
### Startup and reload failures
Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation.
Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update.
After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
+1 -1
View File
@@ -73,7 +73,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
<a id="startup-and-reload-failures"></a>
### 启动与重载失败
profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。
profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。
Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
+2 -2
View File
@@ -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/boot/hmr/README.md
README.md: 735b37c41e50c6a1f663cbfa0cde42f7250ec415
README.zh.md: b0d3196913cb11e434b397350c81ad1bb45dc0c6
README.md: 9e2c62c739c420ceae3db3fd5ade58d6eeadacec
README.zh.md: fa926d5998f7f4b784fe2f848b5acc3047b91b77
+1 -1
View File
@@ -54,7 +54,7 @@ Chokidar options, including polling, retain their existing meaning. Exact config
<details>
<summary>Implementation internals — click to expand</summary>
`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward.
`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place.
App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Configuration watchers start outside the active transaction context so later notifications can enter the queue. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
+1 -1
View File
@@ -54,7 +54,7 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
<details>
<summary>实现细节——点击展开</summary>
`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。
`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。
App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
+26
View File
@@ -7,6 +7,7 @@ import NodeModule, { createRequire } from 'node:module'
import { Context, type Plugin } from '@deepseek-ai/cordis'
import Loader, { type ModuleJob, type ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import z from '@deepseek-ai/schemastery'
import { expect, it, onTestFinished, vi } from 'vitest'
import Hmr from '../src/index.ts'
@@ -87,6 +88,31 @@ it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Load
expect(mounted.at(-1)).toBe('after:next')
})
it('replaces volatile references on code HMR after an in-place config update', async () => {
const { ctx, module, imports, reload } = await fixture()
const Config = z.object({ value: z.string().volatile() })
const configs: ReturnType<typeof Config>[] = []
const before = { Config, apply(_ctx: Context, config: ReturnType<typeof Config>) { configs.push(config) } }
const after = { Config, apply(_ctx: Context, config: ReturnType<typeof Config>) { configs.push(config) } }
const job = module('volatile.mjs', before)
const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url, config: { value: 'initial' } }))
await ctx.loader.await()
await entry.update({ config: { value: 'current' } })
await ctx.loader.await()
expect(configs).toHaveLength(1)
const old = configs[0]!.value
expect(old.get()).toBe('current')
imports.set(job.url, after)
reload.stashed.add(job.url)
await reload.partialReload()
expect(configs).toHaveLength(2)
expect(configs[1]!.value).not.toBe(old)
expect(configs[1]!.value.get()).toBe('current')
await entry.update({ config: { value: 'after-reload' } })
expect(configs[1]!.value.get()).toBe('after-reload')
expect(old.get()).toBe('current')
})
it('reactivates a source-replaced consumer after provider and consumer configuration changes', async () => {
const { ctx, module, imports, reload } = await fixture()
const mounted: string[] = []
+3
View File
@@ -12990,6 +12990,9 @@ importers:
'@deepseek-ai/cosmokit':
specifier: link:../cosmokit
version: link:../cosmokit
'@deepseek-ai/schemastery':
specifier: link:../schemastery
version: link:../schemastery
node-addon-require-builtin:
specifier: ^0.1.6
version: 0.1.6
+15 -13
View File
@@ -895,19 +895,21 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
],
}],
inheritedEvents: [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:331' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:333' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:341' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:343' },
{ name: 'internal/config', summary: 'Waterfall: a fiber config is being resolved before validation.', source: 'vendor/cordis/src/events.ts:339' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:345' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:347' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:349' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:351' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:27' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:28' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:41' },
{ name: 'loader/volatile-update', summary: 'Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only.', source: 'vendor/loader/src/index.ts:34' },
],
inheritedServices: [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
+109
View File
@@ -0,0 +1,109 @@
/** Raw config comparison reads schema metadata without resolving config or committing references. */
import { expect, it, vi } from 'vitest'
import z from '@deepseek-ai/schemastery'
import { equalExceptVolatile as equal } from '../vendor/loader/src/config/diff.ts'
it('ignores volatile fields in frozen raw inputs without running schema callbacks', () => {
const validate = vi.fn((value: string) => value.trim())
const schema = z.object({ title: z.transform(z.string(), validate).volatile(), fixed: z.string() })
Object.freeze(schema.meta)
Object.freeze(schema.dict)
const previous = Object.freeze({ title: 'old', fixed: 'same' })
const next = Object.freeze({ title: { __jsExpr: 'throw new Error("must not run")' }, fixed: 'same' })
expect(equal(previous, next, schema)).toBe(true)
expect(equal(previous, next, schema)).toBe(true)
expect(validate).not.toHaveBeenCalled()
expect(previous.title).toBe('old')
expect(next.title).toEqual({ __jsExpr: 'throw new Error("must not run")' })
})
it('ignores absent, nested and whole-object/array/union volatile fields', () => {
const schema = z.object({
nested: z.object({ value: z.string().volatile() }),
block: z.object({ title: z.string() }).volatile(),
list: z.array(z.string()).volatile(),
choice: z.union([z.string(), z.number()]).volatile(),
})
const next = { nested: { value: 'new' }, block: { title: 'new' }, list: ['new'], choice: 1 }
expect(equal({}, next, schema)).toBe(true)
expect(equal(next, {}, schema)).toBe(true)
expect(equal('old', 'new', z.string().volatile())).toBe(true)
const restored = new z<unknown>(JSON.parse(JSON.stringify(schema)) as z<unknown>)
expect(equal({}, next, restored)).toBe(true)
})
it('preserves ordinary and unknown fields, object defaults and parent expressions', () => {
const schema = z.object({ nested: z.object({ live: z.string().volatile(), fixed: z.string() }).default({ fixed: 'default' }) })
expect(equal({}, { nested: { live: 'new', fixed: 'default' } }, schema)).toBe(true)
expect(equal({}, { nested: { live: 'new' } }, schema)).toBe(false)
expect(equal({}, { unknown: 'new' }, schema)).toBe(false)
expect(equal({ nested: { __jsExpr: 'source.a' } }, { nested: { __jsExpr: 'source.b' } }, schema)).toBe(false)
expect(equal({ nested: 'invalid' }, { nested: { live: 'new' } }, schema)).toBe(false)
expect(equal({ nested: new Date(0) }, { nested: new Date(1) }, schema)).toBe(false)
expect(equal({ nested: [] }, { nested: ['new'] }, schema)).toBe(false)
})
it('keeps ordinary raw changes even if schema validation would normalize them to equal values', () => {
const normalize = vi.fn(() => 'same')
const schema = z.object({ fixed: z.transform(z.string(), normalize), live: z.string().volatile() })
expect(equal({ fixed: 'first' }, { fixed: 'second' }, schema)).toBe(false)
expect(normalize).not.toHaveBeenCalled()
const other = { '~standard': { version: 1 as const, vendor: 'other', validate: vi.fn() } }
expect(equal('first', 'second', other)).toBe(false)
expect(other['~standard'].validate).not.toHaveBeenCalled()
expect(equal('first', 'second', undefined)).toBe(false)
})
it('compares raw values strictly without a schema', () => {
expect(equal([undefined], [null], undefined)).toBe(false)
expect(equal(undefined, null, undefined)).toBe(false)
expect(equal({ fixed: 'same' }, { fixed: 'same' }, undefined)).toBe(true)
expect(equal({ list: Array(1) }, { list: ['new'] }, undefined)).toBe(false)
})
it('compares recursive schemas conservatively and visits shared schema nodes at each path', () => {
const recursive = z.object({ value: z.string() }).extra('default', undefined)
recursive.set('child', recursive)
expect(equal({ value: 'old' }, { value: 'new' }, recursive)).toBe(false)
const shared = z.object({ value: z.string().volatile() })
const schema = z.object({ first: shared, second: shared })
expect(equal({}, { first: { value: 'one' }, second: { value: 'two' } }, schema)).toBe(true)
})
it('equates absent objects with defaults while preserving ordinary field differences', () => {
const schema = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
for (const absent of [undefined, null]) {
expect(equal(absent, {}, schema)).toBe(true)
expect(equal({}, absent, schema)).toBe(true)
expect(equal({ nested: absent }, { nested: { fixed: 'default' } }, schema)).toBe(true)
}
expect(equal({}, { nested: { fixed: 'changed' } }, schema)).toBe(false)
expect(equal({}, { nested: {} }, schema)).toBe(false)
})
it('preserves parent expression edits even when their marker matches a volatile field', () => {
const schema = z.object({ __jsExpr: z.string().volatile() })
expect(equal({ __jsExpr: 'first' }, { __jsExpr: 'second' }, schema)).toBe(false)
})
it('detects ordinary sparse array edits in both directions', () => {
const schema = z.object({ list: z.array(z.string()), live: z.string().volatile() })
const before = { list: Array(1), live: 'before' }
const after = { list: ['new'], live: 'after' }
expect(equal(before, after, schema)).toBe(false)
expect(equal(after, before, schema)).toBe(false)
})
it('compares unknown fields whose names match Object prototype properties', () => {
const schema = z.object({ live: z.string().volatile() })
for (const key of ['constructor', 'toString', '__proto__']) {
const previous = { [key]: 'first', live: 'old' }
expect(equal(previous, { [key]: 'second', live: 'new' }, schema)).toBe(false)
expect(equal(previous, { [key]: 'first', live: 'new' }, schema)).toBe(true)
}
})
it('compares metadata-free serialized schema nodes', () => {
const schema = new z<unknown>(JSON.parse('{"type":"object","dict":{"nested":{"type":"object","dict":{"enabled":{"type":"boolean"}}}}}') as z<unknown>)
expect(equal({ nested: { enabled: true } }, { nested: { enabled: false } }, schema)).toBe(false)
})
+634
View File
@@ -0,0 +1,634 @@
/** Loader commits volatile config values into running fibers without remounting; ordinary changes keep the update lifecycle. */
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis'
import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import z from '@deepseek-ai/schemastery'
import { Config } from './volatile-config.fixture.ts'
import Hmr from '@deepseek-ai/dsh-hmr'
import { boot, reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot'
function context() {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
return ctx
}
async function runtimeContext() {
const ctx = context()
await ctx.plugin(Loader)
return ctx
}
async function mountEntry(ctx: Context, plugin: Plugin, config: unknown = {}): Promise<Fiber> {
const name = `consumer-${Object.keys(ctx.loader.builtins).length}`
ctx.loader.builtins[name] = plugin
const id = await ctx.loader.create({ name: `cordis:${name}`, config })
const fiber = ctx.loader.resolve(id).fiber!
await fiber.await()
return fiber
}
async function update(fiber: Fiber, config: unknown): Promise<void> {
await fiber.entry!.update({ config })
await fiber.await()
}
describe('volatile Loader updates', () => {
it('updates a whole-config reference and reports the root path', async () => {
const ctx = await runtimeContext()
const Config = z.object({ title: z.string() }).volatile()
const events = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', events)
} }, { title: 'first' })
const reference = fiber.config as ReturnType<typeof Config>
await update(fiber, { title: 'second' })
expect(fiber.config).toBe(reference)
expect(reference.get()).toEqual({ title: 'second' })
expect(events).toHaveBeenCalledExactlyOnceWith([[]])
})
it('commits all references before one local event without remounting', async () => {
const ctx = await runtimeContext()
const configs: ReturnType<typeof Config>[] = []
const observed: unknown[] = []
const mount = vi.fn(function (owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', paths => observed.push({ paths, title: config.title.get(), count: config.nested.count.get() }))
})
const first = await mountEntry(ctx, { Config, apply: mount }, {})
await mountEntry(ctx, { Config, apply: mount }, {})
const initial = configs[0]!
const title = initial.title
await update(first, { title: 'hello' })
expect(observed).toEqual([])
await update(first, { title: 'next', nested: { count: 2 } })
await first.await()
expect(mount).toHaveBeenCalledTimes(2)
expect(first.config).toBe(initial)
expect(initial.title).toBe(title)
expect(configs[1]!.title.get()).toBe('hello')
expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }])
await update(first, { title: 'next', nested: { count: 2 } })
expect(observed).toHaveLength(1)
await update(first, { optional: 'added' })
expect(initial.optional.get()).toBe('added')
await update(first, {})
expect(initial.optional.get()).toBeUndefined()
})
it('keeps references on invalid updates and replaces them on mixed updates', async () => {
const ctx = await runtimeContext()
const notifications = vi.fn()
const disposed = vi.fn()
const configs: ReturnType<typeof Config>[] = []
const plugin = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', notifications)
owner.effect(() => disposed)
} }
const fiber = await mountEntry(ctx, plugin, {})
const before = configs[0]!
await update(fiber, { title: 'invalid candidate', nested: { count: -1 } })
expect(before.title.get()).toBe('hello')
expect(notifications).not.toHaveBeenCalled()
await update(fiber, { title: 'new', fixed: 'restart' })
await fiber.await()
expect(configs).toHaveLength(2)
expect(disposed).toHaveBeenCalledOnce()
expect(notifications).not.toHaveBeenCalled()
expect(before.title.get()).toBe('hello')
expect(configs[1]!.title.get()).toBe('new')
expect(configs[1]!.title).not.toBe(before.title)
})
it('bypasses custom update hooks for volatile changes and retains them for mixed changes', async () => {
const ctx = await runtimeContext()
const hooks: string[] = []
const globalHook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
ctx.on('internal/update', globalHook, { global: true })
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('internal/update', (config: ReturnType<typeof Config>) => {
hooks.push(config.title.get())
})
} }, {})
const initial = fiber.config as ReturnType<typeof Config>
await update(fiber, { title: 'accepted' })
expect(fiber.config).toBe(initial)
expect(initial.title.get()).toBe('accepted')
await update(fiber, { title: 'accepted' })
expect(hooks).toEqual([])
expect(globalHook).not.toHaveBeenCalled()
await update(fiber, { title: 'custom', fixed: 'changed' })
expect(hooks).toEqual(['custom'])
expect(initial.title.get()).toBe('accepted')
})
it('compares ordinary dates and arrays and retains the existing nonvolatile restart behavior', async () => {
const ctx = await runtimeContext()
const schema = z.object({ live: z.string().volatile(), date: z.date(), list: z.array(z.string()) })
const apply = vi.fn(function () {})
const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', date: '2026-01-01', list: ['a'] })
await update(fiber, { live: 'b', date: '2026-01-01', list: ['a'] })
await fiber.await()
expect(apply).toHaveBeenCalledOnce()
await update(fiber, { live: 'c', date: '2026-01-02', list: ['a'] })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(2)
await update(fiber, { live: 'd', date: '2026-01-02', list: ['b'] })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(3)
const ordinary = await mountEntry(ctx, { Config: z.object({ value: z.string() }), apply() { apply() } }, { value: 'same' })
ordinary.update({ value: 'same' })
await ordinary.await()
expect(apply).toHaveBeenCalledTimes(5)
})
it('resolves pending updates and creates fresh references after dependency replacement', async () => {
const ctx = await runtimeContext()
const configs: ReturnType<typeof Config>[] = []
const consumer = await mountEntry(ctx, { Config, inject: ['volatile-test-provider'], apply(_ctx: Context, config: ReturnType<typeof Config>) {
configs.push(config)
} }, {})
await update(consumer, { title: 'pending' })
const provider = await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } })
await consumer.await()
expect(configs[0]!.title.get()).toBe('pending')
await update(consumer, { title: 'live' })
await provider.dispose()
const before = configs[0]!.title
await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } })
await consumer.await()
expect(configs).toHaveLength(2)
expect(configs[1]!.title.get()).toBe('live')
expect(configs[1]!.title).not.toBe(before)
})
it('remounts for changed class instances and cyclic ordinary config', async () => {
const ctx = await runtimeContext()
const schema = z.object({
live: z.string().volatile(),
url: z.transform(z.string(), value => new URL(value)),
data: z.transform(z.string(), () => { const data: Record<string, unknown> = {}; data.self = data; return data }),
})
const apply = vi.fn(function () {})
const oldUrl = 'https://a.example/'
const newUrl = 'https://b.example/'
const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl })
const before = fiber.config as ReturnType<typeof schema>
await update(fiber, { live: 'b', url: newUrl })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(2)
expect((fiber.config as ReturnType<typeof schema>).url.href).toBe(newUrl)
expect(before.live.get()).toBe('a')
await update(fiber, { live: 'c', url: newUrl, data: 'first' })
await update(fiber, { live: 'd', url: newUrl, data: 'second' })
expect(apply).toHaveBeenCalledTimes(4)
})
it('distinguishes undefined from null inside volatile arrays', async () => {
const ctx = await runtimeContext()
const schema = z.object({ values: z.array(z.any()).volatile() })
const events = vi.fn()
const fiber = await mountEntry(ctx, { Config: schema, apply(owner: Context) {
owner.on('loader/volatile-update', events)
} }, { values: [undefined] })
await update(fiber, { values: [null] })
expect((fiber.config as ReturnType<typeof schema>).values.get()).toEqual([null])
expect(events).toHaveBeenCalledExactlyOnceWith([['values']])
})
})
it('reloads real YAML and persists plain values independently of custom update hooks', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-'))
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
const configs: ReturnType<typeof Config>[] = []
ctx.loader.builtins.include = Include
ctx.loader.builtins.group = Group
const updates = vi.fn()
const consumer = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('internal/update', updates)
} }
ctx.loader.builtins['volatile-test'] = consumer
const filename = join(root, 'cordis.yml')
await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: initial\n')
const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } })
await ctx.loader.await()
const include = ctx.loader.resolve(rootId).subtree as Include
const group = include.store.group!.fiber!
const groupUid = group.uid
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')!
expect(configs[0]!.title.get()).toBe('initial')
await ctx.plugin(Timer)
// An exact-file config watch mirrors the HMR root-watch Include path, which packages/boot/hmr covers with a mocked watcher.
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
const stopWatching = await ctx.hmr.watchConfig(filename, async () => {
await include.refresh()
await ctx.loader.await()
})
await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: file-update\n')
// The config watcher stabilizes writes for two seconds before publishing a change.
await vi.waitFor(() => { expect(configs[0]!.title.get()).toBe('file-update') }, { timeout: 10_000 })
await stopWatching()
expect(configs).toHaveLength(1)
expect(include.store.group!.fiber).toBe(group)
expect(group.uid).toBe(groupUid)
expect(configs[0]!.title.get()).toBe('file-update')
entry.fiber!.update({ title: 'program-update', group: { list: ['item'] } })
expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
entry.fiber!.update({ title: 'ephemeral' }, true)
expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
await ctx.fiber.dispose()
const saved = await readFile(filename, 'utf8')
expect(saved).toContain('program-update')
expect(saved).not.toContain('get:')
expect(saved).not.toContain('ephemeral')
expect(updates).toHaveBeenCalledTimes(2)
const restored = context()
restored.baseUrl = ctx.baseUrl
await restored.plugin(Loader)
restored.loader.builtins.include = Include
restored.loader.builtins.group = Group
restored.loader.builtins['volatile-test'] = consumer
await restored.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } })
await restored.loader.await()
expect(configs).toHaveLength(2)
expect(configs[1]!.title.get()).toBe('program-update')
expect(configs[1]!.group.get()).toEqual({ list: ['item'] })
await restored.fiber.dispose()
})
it('updates volatile children through Group and Include custom updates and saves ordinary config', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-tree-'))
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
ctx.loader.builtins.group = Group
const configs: ReturnType<typeof Config>[] = []
const notifications = vi.fn()
const childUpdates = vi.fn()
ctx.loader.builtins['volatile-test'] = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', notifications)
owner.on('internal/update', childUpdates)
} }
const child = (title: string) => ({ id: 'consumer', name: 'cordis:volatile-test', config: { title } })
const filename = join(root, 'tree.json')
const includePath = pathToFileURL(filename).href
await writeFile(filename, JSON.stringify([{ id: 'group', name: 'cordis:group', group: true, config: [child('initial')] }]))
const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: includePath } })
await ctx.loader.await()
const includeEntry = ctx.loader.resolve(rootId)
const include = includeEntry.subtree as Include
const group = include.store.group!
const consumer = include.store.consumer!
const originalInclude = includeEntry.fiber
const originalGroup = group.fiber
const originalConsumer = consumer.fiber
const initial = configs[0]!
group.fiber!.update([child('group-update')])
await ctx.loader.await()
expect(initial.title.get()).toBe('group-update')
expect(group.options.config).toEqual([child('group-update')])
includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] })
await ctx.loader.await()
expect(initial.title.get()).toBe('include-update')
expect(includeEntry.fiber).toBe(originalInclude)
expect(group.fiber).toBe(originalGroup)
expect(consumer.fiber).toBe(originalConsumer)
expect(consumer.fiber!.config).toBe(initial)
expect(configs).toHaveLength(1)
expect(notifications).toHaveBeenCalledTimes(2)
expect(childUpdates).not.toHaveBeenCalled()
const ordinary = await ctx.loader.create({ name: 'cordis:volatile-test', config: {} })
const entry = ctx.loader.resolve(ordinary)
entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
expect(childUpdates).toHaveBeenCalledOnce()
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect((entry.fiber!.config as ReturnType<typeof Config>).title.get()).toBe('hello')
entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect(() => { entry.fiber!.update({ nested: { count: -1 } }) }).toThrow()
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
})
it('commits volatile edits in place and retains direct Fiber updates', async () => {
const ctx = await runtimeContext()
const notifications = vi.fn()
const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) })
const fiber = await mountEntry(ctx, { Config, apply }, {})
const original = fiber.config as ReturnType<typeof Config>
await ctx.loader.create({ name: 'cordis:disabled', disabled: true })
await update(fiber, { title: 'enabled' })
expect(apply).toHaveBeenCalledOnce()
expect(fiber.config).toBe(original)
expect(original.title.get()).toBe('enabled')
expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']])
await update(fiber, { title: 'invalid', nested: { count: -1 } })
expect(original.title.get()).toBe('enabled')
await update(fiber, { title: 'mixed', fixed: 'changed' })
expect(apply).toHaveBeenCalledTimes(2)
expect(original.title.get()).toBe('enabled')
const current = fiber.config as ReturnType<typeof Config>
expect(current.title.get()).toBe('mixed')
const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
fiber.ctx.on('internal/update', hook)
fiber.update({ title: 'direct' })
await fiber.await()
expect(hook).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledTimes(3)
expect(current.title.get()).toBe('mixed')
expect(notifications).toHaveBeenCalledOnce()
})
it('parses a volatile-only update through the config hook without patching the entry context', async () => {
const ctx = await runtimeContext()
const trace: string[] = []
const trim = vi.fn((value: string) => { trace.push('validate'); return value.trim() })
const Config = z.object({ block: z.object({ title: z.transform(z.string(), trim, true) }).volatile() })
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', () => { trace.push('notify') })
} }, { block: { title: 'initial' } })
const current = fiber.config as ReturnType<typeof Config>
const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next())
fiber.ctx.on('internal/config', resolveHook)
const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() })
ctx.on('loader/patch-context', contextPatch)
trim.mockClear()
trace.length = 0
const raw = { block: { title: ' changed ' } }
await fiber.entry!.update({ config: raw })
expect(contextPatch).not.toHaveBeenCalled()
expect(trim).toHaveBeenCalledExactlyOnceWith(' changed ')
expect(resolveHook).toHaveBeenCalledOnce()
expect(trace).toEqual(['validate', 'notify'])
expect(current.block.get()).toEqual({ title: 'changed' })
expect(fiber._config).toBe(raw)
})
it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => {
const ctx = await runtimeContext()
const configs: ReturnType<typeof Config>[] = []
const notifications = vi.fn()
ctx.loader.builtins.provider = { apply(owner: Context) { owner.provide('volatile-isolated-service', {}) } }
ctx.loader.builtins.consumer = {
inject: ['volatile-isolated-service'], Config,
apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', notifications)
},
}
const provider = (realm: string) => ctx.loader.create({
name: 'cordis:provider', isolate: { 'volatile-isolated-service': realm },
})
await provider('a')
if (available) await provider('b')
const id = await ctx.loader.create({
name: 'cordis:consumer', isolate: { 'volatile-isolated-service': 'a' }, config: { title: 'old' },
})
await ctx.loader.await()
const original = configs[0]!
await ctx.loader.resolve(id).update({ isolate: { 'volatile-isolated-service': 'b' }, config: { title: 'new' } })
await ctx.loader.await()
expect(configs).toHaveLength(available ? 2 : 1)
expect(original.title.get()).toBe('old')
expect(notifications).not.toHaveBeenCalled()
if (!available) {
await provider('b')
await ctx.loader.await()
}
expect(configs).toHaveLength(2)
expect(configs[1]!.title.get()).toBe('new')
})
it.each(['group', 'include'] as const)('applies profile patches to a %s subtree without HMR and keeps mixed updates on the normal lifecycle', async (kind) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-profile-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const configs: ReturnType<typeof Config>[] = []
const events = vi.fn()
const dispose = vi.fn()
const consumer = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', events)
owner.effect(() => dispose)
} }
const child = (title: string, fixed = 'fixed') => ({ id: 'consumer', name: 'cordis:consumer', config: { title, fixed } })
const nested = join(root, 'nested.json')
await writeFile(nested, JSON.stringify([child('initial')]))
const carrierConfig = (title: string, fixed = 'fixed') => kind === 'group'
? [child(title, fixed)]
: { path: pathToFileURL(nested).href, patches: [{ id: 'consumer', config: { title, fixed } }] }
const filename = join(root, 'cordis.yml')
await writeFile(filename, JSON.stringify([{ id: 'parent', name: `cordis:${kind}`, group: kind === 'group', config: carrierConfig('initial') }]))
const ctx = await boot('volatile-test', filename, undefined, async (ctx) => {
ctx.loader.builtins.consumer = consumer
})
onTestFinished(() => ctx.fiber.dispose())
expect(ctx.get('hmr')).toBeUndefined()
const parent = [...ctx.loader.entries()].find(entry => entry.options.id === 'parent')!
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')!
const parentFiber = parent.fiber
const childFiber = entry.fiber
const original = configs[0]!
const reference = original.title
const patch = (title: string, fixed = 'fixed') => [{ id: 'parent', config: carrierConfig(title, fixed) }]
await reconcileProfilePatches(ctx, patch('first'), 'volatile-test')
await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
expect(parent.fiber).toBe(parentFiber)
expect(entry.fiber).toBe(childFiber)
expect(entry.fiber!.config).toBe(original)
expect(original.title).toBe(reference)
expect(reference.get()).toBe('second')
expect(configs).toHaveLength(1)
expect(dispose).not.toHaveBeenCalled()
expect(events).toHaveBeenCalledTimes(2)
expect(events).toHaveBeenLastCalledWith([['title']])
await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
expect(events).toHaveBeenCalledTimes(2)
await reconcileProfilePatches(ctx, patch('mixed', 'changed'), 'volatile-test')
expect(parent.fiber).toBe(parentFiber)
expect(configs).toHaveLength(2)
expect(dispose).toHaveBeenCalledOnce()
expect(reference.get()).toBe('second')
expect(configs[1]!.title.get()).toBe('mixed')
expect(events).toHaveBeenCalledTimes(2)
})
it('logs an invalid candidate and keeps its references while other entries commit', async () => {
const ctx = await runtimeContext()
const events = vi.fn()
const plugin = { Config, apply(owner: Context) { owner.on('loader/volatile-update', events) } }
const invalid = await mountEntry(ctx, plugin)
const healthy = await mountEntry(ctx, plugin)
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warning.mockRestore() })
await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } })
await healthy.entry!.update({ config: { title: 'healthy' } })
expect(warning).toHaveBeenCalledTimes(2)
expect((invalid.config as ReturnType<typeof Config>).title.get()).toBe('hello')
expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } })
expect((healthy.config as ReturnType<typeof Config>).title.get()).toBe('healthy')
expect(events).toHaveBeenCalledExactlyOnceWith([['title']])
})
it.each([false, true])('retains raw expression edits for dependency restoration (volatile: %s)', async (volatile) => {
const ctx = await runtimeContext()
const schema = z.object({ value: volatile ? z.string().volatile() : z.string() })
const values: unknown[] = []
const provide = (a: string, b: string) => ctx.plugin({ apply(owner: Context) { owner.provide('volatile-expression-source', { a, b }) } })
const provider = await provide('same', 'same')
const fiber = await mountEntry(ctx, { Config: schema, inject: ['volatile-expression-source'], apply(_owner: Context, config: ReturnType<typeof schema>) {
values.push(typeof config.value === 'string' ? config.value : config.value.get())
} }, { value: { __jsExpr: "ctx.get('volatile-expression-source').a" } })
const raw = { value: { __jsExpr: "ctx.get('volatile-expression-source').b" } }
await fiber.entry!.update({ config: raw })
await fiber.await()
expect(values).toEqual(volatile ? ['same'] : ['same', 'same'])
expect(fiber._config).toBe(raw)
await provider.dispose()
await provide('old-route', 'new-route')
await fiber.await()
expect(values).toEqual(volatile ? ['same', 'new-route'] : ['same', 'same', 'new-route'])
})
it('remounts when changed expressions resolve to ordinary values different from the live config', async () => {
const ctx = await runtimeContext()
const source = { a: 'initial', b: 'initial' }
ctx.provide('volatile-expression-source', source)
const configs: ReturnType<typeof Config>[] = []
const events = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', events)
} }, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').a" }, title: 'old' })
source.a = source.b = 'changed'
await update(fiber, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').b" }, title: 'new' })
expect(configs).toHaveLength(2)
expect(configs[0]!.title.get()).toBe('old')
expect(configs[1]!.fixed).toBe('changed')
expect(configs[1]!.title.get()).toBe('new')
expect(events).not.toHaveBeenCalled()
})
it('compares ordinary URL transforms by value and remounts when an expression result changes', async () => {
const ctx = await runtimeContext()
const source = { value: 'initial' }
ctx.provide('volatile-transform-source', source)
const schema = z.object({
url: z.transform(z.string(), value => new URL(value), true),
fixed: z.string(),
live: z.string().volatile(),
})
const events = vi.fn()
const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" }
const raw = { url: 'https://example.com/', fixed, live: 'old' }
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
const current = fiber.config as ReturnType<typeof schema>
await update(fiber, { ...raw, live: 'new' })
expect(mount).toHaveBeenCalledOnce()
expect(fiber.config).toBe(current)
expect(current.url.href).toBe(raw.url)
expect(current.fixed).toBe('initial')
expect(current.live.get()).toBe('new')
expect(events).toHaveBeenCalledExactlyOnceWith([['live']])
source.value = 'changed'
await update(fiber, { ...raw, live: 'remounted' })
expect(mount).toHaveBeenCalledTimes(2)
expect(current.fixed).toBe('initial')
expect(current.live.get()).toBe('new')
expect(events).toHaveBeenCalledOnce()
const replaced = fiber.config as ReturnType<typeof schema>
expect(replaced.fixed).toBe('changed')
expect(replaced.live.get()).toBe('remounted')
})
it('remounts when an ordinary parent expression removes a live reference path', async () => {
const ctx = await runtimeContext()
const source: { block: { value: string } | undefined } = { block: { value: 'old' } }
ctx.provide('volatile-path-source', source)
const schema = z.object({ block: z.object({ value: z.string().volatile() }).extra('default', undefined), live: z.string().volatile() })
const events = vi.fn()
const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' }
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
const current = fiber.config as ReturnType<typeof schema>
source.block = undefined
await update(fiber, { ...raw, live: 'new' })
expect(mount).toHaveBeenCalledTimes(2)
expect(current.block.value.get()).toBe('old')
expect(events).not.toHaveBeenCalled()
expect((fiber.config as ReturnType<typeof schema>).block).toBeUndefined()
})
it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => {
const ctx = await runtimeContext()
const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
const mount = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply: mount }, {})
const config: unknown = fiber.config
await update(fiber, { nested: { fixed: 'default' } })
expect(fiber.config).toBe(config)
expect(mount).toHaveBeenCalledTimes(1)
await update(fiber, { nested: { fixed: 'changed' } })
expect(mount).toHaveBeenCalledTimes(2)
expect(fiber.config).toEqual({ nested: { fixed: 'changed' } })
})
it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => {
const ctx = await runtimeContext()
const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' })
fiber.update({ title: 'ephemeral' }, true)
await fiber.await()
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
expect(fiber.entry!.options.config).toEqual({ title: 'saved' })
await fiber.entry!.update({ config: { title: 'saved' } })
expect(fiber._config).toEqual({ title: 'ephemeral' })
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
})
it('isolates a failing notification listener and dispatches with a Context receiver', async () => {
const ctx = await runtimeContext()
const receivers: unknown[] = []
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', function (this: Context) { receivers.push(this.fiber); throw new Error('listener boom') })
} }, {})
const other = await mountEntry(ctx, { Config, apply() {} }, {})
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warning.mockRestore() })
await update(fiber, { title: 'committed' })
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('committed')
expect(receivers).toEqual([fiber])
expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' }))
await update(other, { title: 'unaffected' })
expect((other.config as ReturnType<typeof Config>).title.get()).toBe('unaffected')
})
it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => {
const ctx = await runtimeContext()
class Handle { constructor(public readonly name: string) {} }
const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() })
const mount = vi.fn()
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' })
const before = fiber.config as ReturnType<typeof schema>
await update(fiber, { handle: 'h', live: 'new' })
expect(mount).toHaveBeenCalledTimes(2)
expect(before.live.get()).toBe('old')
expect((fiber.config as ReturnType<typeof schema>).live.get()).toBe('new')
})
+9
View File
@@ -78,6 +78,8 @@ interface GenericSkip {
const GENERIC_SKIPS: readonly GenericSkip[] = [
// `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers.
{ file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] },
// Narrows a Standard Schema by the same upstream `vendor:` identifier.
{ file: 'vendor/loader/src/config/diff.ts', upstream: ['schemastery'] },
// Asserts the vendored-manifest table, which gains an upstream-name column.
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
// `cordis` is also an agent-preset id — the directory name under
@@ -172,6 +174,13 @@ const POSTCONDITIONS: readonly PostCondition[] = [
* quote a neighbouring line the generic pass would rewrite.
*/
const EXACT_EDITS: readonly ExactEdit[] = [
{
id: 'loader-diff-schemastery-import',
file: 'vendor/loader/src/config/diff.ts',
find: "import type Schema from 'schemastery'",
replace: "import type Schema from '@deepseek-ai/schemastery'",
expect: 1,
},
{
id: 'cordis-walk-merge-head',
file: 'scripts/cordis-walk.ts',
+11
View File
@@ -0,0 +1,11 @@
/** Shared volatile schema fixture for the schema and Loader volatile specs. */
import z from '@deepseek-ai/schemastery'
/** Mixed ordinary, volatile, nested and whole-object volatile fields. */
export const Config = z.object({
fixed: z.string().default('fixed'),
title: z.string().default('hello').volatile(),
optional: z.string().volatile(),
nested: z.object({ count: z.number().min(0).volatile().default(1) }).default({}),
group: z.object({ list: z.array(z.string()) }).default({ list: [] }).volatile(),
})
+151
View File
@@ -0,0 +1,151 @@
/** Schemastery and Cordis config reference behavior through direct and Loader updates. */
import { describe, expect, expectTypeOf, it } from 'vitest'
import { type Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Config } from './volatile-config.fixture.ts'
import { createVolatile, deepEqual, volatileEntries } from '@deepseek-ai/cosmokit'
import { redactSecrets } from '../packages/settings/settings/src/redact.ts'
describe('volatile schemas', () => {
it('parses references with defaults and absent values while preserving schema metadata', () => {
const parsed = Config({})
expect(parsed.title.get()).toBe('hello')
expect(parsed.optional.get()).toBeUndefined()
expect(parsed.nested.count.get()).toBe(1)
expectTypeOf(parsed.title).toEqualTypeOf<Volatile<string>>()
expectTypeOf(parsed.optional).toEqualTypeOf<Volatile<string | undefined>>()
expectTypeOf(parsed.nested.count).toEqualTypeOf<Volatile<number>>()
expectTypeOf(z.string().volatile().description('Live text').default('text')().get()).toEqualTypeOf<string>()
expectTypeOf(z.string().required().volatile()('text').get()).toEqualTypeOf<string>()
expect(Config.dict?.title?.type).toBe('string')
expect(Config.dict?.title?.meta.default).toBe('hello')
expect(Config.simplify(parsed)).toBeNull()
const reconstructed = new z<unknown>(JSON.parse(JSON.stringify(Config.toJSON())) as z<unknown>)
expect((reconstructed({ title: 'changed' }) as ReturnType<typeof Config>).title.get()).toBe('changed')
expect(reconstructed.simplify(reconstructed({ title: 'changed' }))).toEqual({ title: 'changed' })
expect(z.string().volatile().required()('present').get()).toBe('present')
expect(() => z.string().volatile().required()()).toThrow('missing required value')
expect(() => Config({ nested: { count: -1 } })).toThrow()
expect(Config({ nested: { count: -1 } }, { autofix: true }).nested.count.get()).toBe(1)
})
it('copies and freezes data snapshots without freezing the input', () => {
const input = { group: { list: ['a'] } }
const parsed = Config(input)
input.group.list.push('b')
expect(parsed.group.get()).toEqual({ list: ['a'] })
expect(Object.isFrozen(parsed.group.get())).toBe(true)
expect(Object.isFrozen(parsed.group.get().list)).toBe(true)
expect(() => (parsed.group.get().list as string[]).push('c')).toThrow()
expect(Object.keys(parsed.group)).toEqual(['get'])
expect(() => z.any().volatile()(new Date())).toThrow('plain objects')
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(() => z.any().volatile()(cyclic)).toThrow('cycles')
})
it.each([
['array', () => z.object({ items: z.array(z.string().volatile()) }), 'items'],
['dict', () => z.object({ items: z.dict(z.string().volatile()) }), 'items'],
['union', () => z.union([z.object({ title: z.string().volatile() }), z.string()]), 'title'],
['tuple', () => z.object({ items: z.tuple([z.string().volatile()]) }), 'items'],
['intersect', () => z.intersect([z.object({ title: z.string().volatile() })]), 'title'],
['transform', () => z.transform(z.object({ title: z.string().volatile() }), value => value), 'title'],
['nested', () => z.object({ block: z.object({ title: z.string().volatile() }).volatile() }), 'block'],
])('rejects %s placements even when input omits the field', (_name, schema, path) => {
expect(() => schema()({})).toThrow(path)
})
it('rejects repeat wrapping and supports an entire array or union as a value', () => {
expect(() => z.string().volatile().volatile()).toThrow('already wrapped')
expect(z.array(z.string()).volatile()(['a']).get()).toEqual(['a'])
expect(z.union([z.string(), z.number()]).volatile()(1).get()).toBe(1)
const schema = z.string().volatile()
expect(schema('first')).not.toBe(schema('first'))
})
it('retains ordinary fields and secret traversal in serialized schemas', () => {
const schema = z.object({ block: z.object({ apiKey: z.string().role('secret'), title: z.string() }).volatile() })
const restored = new z<unknown>(JSON.parse(JSON.stringify(schema.toJSON())) as z<unknown>)
expect(restored.dict?.block?.type).toBe('object')
expect(restored.dict?.block?.dict?.apiKey?.meta.role).toBe('secret')
expect(redactSecrets(restored as z<never>, { block: { apiKey: 'secret', title: 'public' } }).value)
.toEqual({ block: { title: 'public' } })
})
it('accepts metadata-free serialized nodes and checks their nested volatile declarations', () => {
const schema = new z<{ enabled: boolean }>(JSON.parse('{"type":"object","dict":{"enabled":{"type":"boolean"}}}') as z<{ enabled: boolean }>)
expect(schema({ enabled: true })).toEqual({ enabled: true })
const dynamic = new z<unknown>(JSON.parse(
'{"type":"object","dict":{"items":{"type":"array","inner":{"type":"string","meta":{"volatile":true}}}}}',
) as z<unknown>)
expect(() => dynamic({ items: [] })).toThrow('items')
})
it('resolves recursive lazy schemas only for the supplied data', () => {
interface Tree { name: string; children?: Tree[] }
function tree(): z<Tree> {
return z.object({ name: z.string(), children: z.array(z.lazy(tree)).default([]) })
}
expect(tree()({ name: 'root' })).toEqual({ name: 'root', children: [] })
expect(tree()({ name: 'root', children: [{ name: 'leaf' }] }))
.toEqual({ name: 'root', children: [{ name: 'leaf', children: [] }] })
const schema = z.object({ lazy: z.lazy(() => z.object({ live: z.string().volatile() })) })
expect(() => schema({ lazy: { live: 'value' } })).toThrow('lazy')
})
})
describe('volatile comparison semantics', () => {
it('ignores reference contents while retaining ordinary differences', () => {
const first = Config({ title: 'a' })
const second = Config({ title: 'b' })
expect(deepEqual(first, second, true)).toBe(true)
expect(deepEqual(first, Config({ fixed: 'changed' }), true)).toBe(false)
expect(deepEqual(first.title, 'a', true)).toBe(false)
expect(deepEqual('a', first.title, true)).toBe(false)
expect(deepEqual([undefined], [null], true)).toBe(false)
expect(deepEqual([undefined], [null])).toBe(true)
expect(deepEqual(new Date(0), new Date(0), true)).toBe(true)
expect(deepEqual(/a/g, /a/i, true)).toBe(false)
expect(deepEqual(new URL('https://a.example'), new URL('https://b.example'), true)).toBe(false)
expect(deepEqual(new URL('https://EXAMPLE.com:443/'), new URL('https://example.com'), true)).toBe(true)
expect(deepEqual(new URL('https://example.com'), {}, true)).toBe(false)
expect(deepEqual({}, new URL('https://example.com'), true)).toBe(false)
expect(deepEqual(new Uint8Array([1]).buffer, new Uint8Array([2]).buffer, true)).toBe(false)
const firstCycle: Record<string, unknown> = {}; firstCycle.self = firstCycle
const secondCycle: Record<string, unknown> = {}; secondCycle.self = secondCycle
expect(deepEqual(firstCycle, secondCycle, true)).toBe(false)
})
})
it('enumerates references through shared config containers without descending into snapshots or cycles', () => {
const ref = createVolatile({ nested: 'value' })
const shared = Object.assign(Object.create(null), { live: ref }) as Record<string, unknown>
const config: Record<string, unknown> = { list: [shared, shared], opaque: new URL('https://example.com'), plain: 'value' }
config.self = config
expect(volatileEntries(config)).toEqual([
{ path: ['list', '0', 'live'], ref },
{ path: ['list', '1', 'live'], ref },
])
expect(volatileEntries(ref)).toEqual([{ path: [], ref }])
})
it('compares sparse array slots as undefined in both directions', () => {
for (const strict of [true, false]) {
expect(deepEqual(Array(1), [undefined], strict)).toBe(true)
expect(deepEqual([undefined], Array(1), strict)).toBe(true)
expect(deepEqual(Array(1), ['new'], strict)).toBe(false)
expect(deepEqual(['new'], Array(1), strict)).toBe(false)
expect(deepEqual(Array(1), [], strict)).toBe(false)
expect(deepEqual([], Array(1), strict)).toBe(false)
}
expect(deepEqual(Array(1), [null], true)).toBe(false)
expect(deepEqual([null], Array(1), true)).toBe(false)
})
it('reports unsupported volatile values as validation issues with their path', () => {
const schema = z.object({ block: z.object({ when: z.date().volatile() }) })
expect(() => schema({ block: { when: new Date(0) } })).toThrow(z.ValidationError)
expect(() => schema({ block: { when: new Date(0) } })).toThrow('$.block.when')
expect(schema({ block: {} }).block.when.get()).toBeUndefined()
})
+2
View File
@@ -54,6 +54,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
21. **`cordis/src/logger.ts` exporter disposal**: each disposer retains its registration id, so removing an earlier exporter cannot delete a later console or telemetry exporter. Covered by startup collector cleanup in `packages/boot/app-boot/tests/app-boot.spec.ts` and disabled-feedback output in `packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts`.
22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial<S>` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through the fiber's `internal/config` hook and `resolveConfig`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite.
## Sync procedure
To update a vendored package from upstream:
+4
View File
@@ -99,3 +99,7 @@ yarn lint
The monorepo uses Yakumo to build and test all packages. Most examples in the
docs use public APIs from `cordis`; loader examples additionally use
`@cordisjs/plugin-loader` and `@cordisjs/plugin-include`.
## Volatile configuration
Schemas may return `Volatile<T>` references, whose values are read through `.get()`. Cordis exports the consumer types; Loader commits volatile-only changes into the running references without restarting and notifies the owning fiber through `loader/volatile-update`. Direct `fiber.update()` retains its existing update waterfall and default restart.
+2
View File
@@ -12,3 +12,5 @@ export * from './registry.ts'
export * from './service.ts'
/** Shared internal helpers used by context, services, and plugin fibers. */
export * from './utils.ts'
/** Readonly references returned by volatile config schemas. */
export type { Volatile, VolatileSnapshot } from '@deepseek-ai/cosmokit'
+6
View File
@@ -22,3 +22,9 @@ import cosmokit from 'cosmokit'
```ts
import cosmokit from 'npm:cosmokit@latest'
```
## Config references
`Volatile<T>` exposes `get()` for immutable data snapshots. `createVolatile()` copies and freezes validated data; framework-owned `updateVolatile()` transfers a candidate snapshot into an existing reference. Framework-owned `volatileEntries()` enumerates references and their paths through plain config containers, stopping at snapshots and opaque objects. The shared symbol protocol recognizes references across ESM/CJS copies. Plugin consumers import the type from Cordis and retain the reference rather than caching values between operations.
`deepEqual()` treats two volatile references as equal regardless of their snapshots. Strict comparisons distinguish null from undefined recursively, compare URLs by normalized `href` and other opaque objects by identity, and treat distinct cyclic structures as unequal. Array comparison visits every index and treats holes as undefined. Snapshot-change detection must compare `.get()` results separately.
+2
View File
@@ -8,3 +8,5 @@ export * from './misc.ts'
export * from './string.ts'
/** Time constants, parsing, and formatting helpers. */
export * from './time.ts'
/** Immutable config references shared by schema validators and runtimes. */
export * from './volatile.ts'
+48 -22
View File
@@ -1,4 +1,5 @@
import { isNullable } from './misc.ts'
import { isVolatile } from './volatile.ts'
type GlobalConstructorNames = keyof {
[K in keyof typeof globalThis as typeof globalThis[K] extends abstract new (...args: any) => any ? K : never]: K
@@ -114,29 +115,54 @@ export function clone(source: any, refs = new Map<any, any>()) {
return result
}
/** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
/**
* Compare values recursively, treating two volatile references as equal regardless of value.
* Strict comparison distinguishes null/undefined, treats opaque objects by identity,
* compares URLs by normalized href, treats array holes as undefined, and considers distinct cyclic structures unequal.
* @param a - first value.
* @param b - second value.
* @param strict - whether to require strict data equality outside volatile references.
* @returns whether the values compare equal.
*/
export function deepEqual(a: any, b: any, strict?: boolean): boolean {
if (a === b) return true
if (!strict && isNullable(a) && isNullable(b)) return true
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return false
if (!a || !b) return false
const ancestors = new Set<object>()
function compare(a: any, b: any): boolean {
if (a === b) return true
if (isVolatile(a) || isVolatile(b)) return isVolatile(a) && isVolatile(b)
if (!strict && isNullable(a) && isNullable(b)) return true
if (typeof a !== typeof b || typeof a !== 'object' || !a || !b) return false
if (ancestors.has(a)) return false
function check<T>(test: (x: any) => x is T, then: (a: T, b: T) => boolean) {
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : undefined
function check<T>(test: (x: any) => x is T, then: (a: T, b: T) => boolean) {
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : undefined
}
ancestors.add(a)
try {
return check(Array.isArray, (a, b) => {
if (a.length !== b.length) return false
for (let index = 0; index < a.length; index++) {
if (!compare(a[index], b[index])) return false
}
return true
})
?? check(is('Date'), (a, b) => a.valueOf() === b.valueOf())
?? check(is('URL'), (a, b) => a.href === b.href)
?? check(is('RegExp'), (a, b) => a.source === b.source && a.flags === b.flags)
?? check(isArrayBufferLike, (a, b) => {
if (a.byteLength !== b.byteLength) return false
const viewA = new Uint8Array(a)
const viewB = new Uint8Array(b)
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) return false
}
return true
})
?? ((!strict || [a, b].every(value => Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null))
&& Object.keys({ ...a, ...b }).every(key => compare(a[key], b[key])))
} finally {
ancestors.delete(a)
}
}
return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index])))
?? check(is('Date'), (a, b) => a.valueOf() === b.valueOf())
?? check(is('RegExp'), (a, b) => a.source === b.source && a.flags === b.flags)
?? check(isArrayBufferLike, (a, b) => {
if (a.byteLength !== b.byteLength) return false
const viewA = new Uint8Array(a)
const viewB = new Uint8Array(b)
for (let i = 0; i < viewA.length; i++) {
if (viewA[i] !== viewB[i]) return false
}
return true
})
?? Object.keys({ ...a, ...b }).every(key => deepEqual(a[key], b[key], strict))
return compare(a, b)
}
+86
View File
@@ -0,0 +1,86 @@
/** Shared config references used by schema validators and plugin runtimes. */
const write = Symbol.for('cosmokit.volatile.write')
/** Recursively readonly data returned by a volatile config reference. */
export type VolatileSnapshot<T> = T extends object ? { readonly [K in keyof T]: VolatileSnapshot<T[K]> } : T
/** A stable reference; keep the reference, or capture its value for one operation only. */
export interface Volatile<T> {
/** @returns the current immutable snapshot, including undefined for an absent value. */
get(): VolatileSnapshot<T>
}
interface WritableVolatile extends Volatile<unknown> {
[write](value: unknown): void
}
function snapshot(value: unknown, ancestors = new Set<object>()): unknown {
if (typeof value === 'function') throw new TypeError('volatile config cannot contain functions')
if (value === null || typeof value !== 'object') return value
if (ancestors.has(value)) throw new TypeError('volatile config cannot contain cycles')
ancestors.add(value)
try {
if (Array.isArray(value)) return Object.freeze(value.map(item => snapshot(item, ancestors)))
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
throw new TypeError('volatile config objects must be plain objects or arrays')
}
return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item, ancestors)])))
} finally {
ancestors.delete(value)
}
}
/**
* Create a detached reference containing an immutable copy of the supplied data.
* @param value - validated config data; class instances and functions are unsupported.
* @returns a reference whose value is updated only by its owning runtime.
*/
export function createVolatile<T>(value: T): Volatile<T> {
let current = snapshot(value)
return Object.freeze({
get: () => current,
[write]: (value: unknown) => { current = value },
}) as Volatile<T>
}
/**
* Identify references across ESM/CJS copies of the shared library.
* @param value - a parsed config value.
* @returns whether the value implements the shared reference protocol.
*/
export function isVolatile(value: unknown): value is Volatile<unknown> {
return typeof value === 'object' && value !== null && write in value
}
/**
* Collect config references without descending into their snapshots or opaque objects.
* @internal
* @param value - parsed config; cyclic ordinary fields are visited once per path.
* @returns references and their object-key paths, including an empty path for a root reference.
*/
export function volatileEntries(value: unknown): { path: string[]; ref: Volatile<unknown> }[] {
const ancestors = new Set<object>()
function visit(value: unknown, path: string[]): { path: string[]; ref: Volatile<unknown> }[] {
if (isVolatile(value)) return [{ path, ref: value }]
if (!value || typeof value !== 'object' || ancestors.has(value)) return []
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return []
ancestors.add(value)
try {
return Object.entries(value).flatMap(([key, child]) => visit(child, [...path, key]))
} finally {
ancestors.delete(value)
}
}
return visit(value, [])
}
/**
* Commit an already validated immutable snapshot from another reference.
* @internal
* @param target - the owning plugin's stable reference.
* @param source - a newly parsed candidate reference.
*/
export function updateVolatile(target: Volatile<unknown>, source: Volatile<unknown>): void {
;(target as WritableVolatile)[write](source.get())
}
+4
View File
@@ -46,3 +46,7 @@ root.loader.update(id, { config: { enabled: false } })
| `loader.locate(fiber?)` | Return the loader entry id that owns a fiber. |
For file-backed trees, use `@cordisjs/plugin-include`.
## Volatile configuration
`Entry.update` compares raw entry options strictly; when only `config` changed on an active fiber in an unchanged context, the pure `equalExceptVolatile` function in `config/diff.ts` compares the raw configs using Schemastery metadata. It ignores volatile fields at fixed object paths without executing expressions, validators or `internal/config` hooks. Object nodes use their declared defaults for null or absent values, including objects without volatile fields; equivalent defaults skip updates. Other ordinary values, parent expressions and unknown fields retain raw comparison. Recursive schema backedges use raw comparison. Entry passes the actual plugin schema; a non-Schemastery schema compares raw. Context changes, other option changes and pending fibers use ordinary updates. A volatile-only change retains the raw Fiber config for later activation, then `_commitVolatile` parses it through the fiber's `internal/config` hook and schema, commits the new values into the running references when every ordinary effective value still matches, and emits `loader/volatile-update` to the owning fiber with the changed paths. A changed ordinary effective value uses the ordinary remount with a debug log naming the entry; because strict comparison treats class instances other than URL, Date and RegExp by identity, an ordinary field whose transform builds a fresh instance on every parse always takes that remount. An invalid candidate is logged and leaves the running references unchanged. A throwing `loader/volatile-update` listener is logged and does not fail the update; the values are already committed. Group/Include retain child dispatch, and programmatic Fiber updates preserve saving and `noSave`; `simplify` is called with its schema receiver so saved config contains plain values.
+2 -1
View File
@@ -39,6 +39,7 @@
}
},
"dependencies": {
"@deepseek-ai/cosmokit": "workspace:^"
"@deepseek-ai/cosmokit": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
}
}
+45
View File
@@ -0,0 +1,45 @@
/** Compare raw plugin configs using schema metadata without executing config hooks or validators. */
import type { Plugin } from '@deepseek-ai/cordis'
import { deepEqual } from '@deepseek-ai/cosmokit'
import type Schema from '@deepseek-ai/schemastery'
import { isJsExpr } from './utils.ts'
function isSchemastery(schema: Plugin.Runtime['Config']): schema is Schema {
return schema?.['~standard'].vendor === 'schemastery'
}
function isRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || isJsExpr(value)) return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function equal(a: unknown, b: unknown, schema: Schema | undefined, ancestors: Set<Schema>): boolean {
if (schema?.meta?.volatile) return true
if (schema?.type !== 'object' || !schema.dict || ancestors.has(schema)) return deepEqual(a, b, true)
const left = a ?? schema.meta?.default
const right = b ?? schema.meta?.default
if (!isRecord(left) || !isRecord(right)) return deepEqual(left, right, true)
const { dict } = schema
ancestors.add(schema)
try {
return Object.keys({ ...left, ...right }).every(key =>
equal(left[key], right[key], Object.hasOwn(dict, key) ? dict[key] : undefined, ancestors),
)
} finally {
ancestors.delete(schema)
}
}
/**
* Compare two raw configs, treating schema-declared volatile fields at fixed object paths as equal and absent objects as their schema default.
* Schema backedges, expressions, unknown fields and opaque values keep strict raw equality; an absent or non-Schemastery schema compares everything raw.
* @param previous - previous raw config.
* @param next - next raw config.
* @param schema - the plugin's config schema.
* @returns Whether the configs differ at most in volatile fields, without evaluating expressions, validating config or modifying inputs.
* @internal
*/
export function equalExceptVolatile(previous: unknown, next: unknown, schema: Plugin.Runtime['Config']): boolean {
return isSchemastery(schema) ? equal(previous, next, schema, new Set()) : deepEqual(previous, next, true)
}
+53 -7
View File
@@ -1,9 +1,10 @@
import { Context, Fiber, Inject } from '@deepseek-ai/cordis'
import { deepEqual, isNullable } from '@deepseek-ai/cosmokit'
import { Context, Fiber, FiberState, Inject, resolveConfig } from '@deepseek-ai/cordis'
import { deepEqual, isNullable, updateVolatile, volatileEntries, type Volatile } from '@deepseek-ai/cosmokit'
import { Loader } from '../index.ts'
import { EntryGroup } from './group.ts'
import { EntryTree } from './tree.ts'
import { evaluate, isJsExpr } from './utils.ts'
import { equalExceptVolatile } from './diff.ts'
/** Serialized plugin entry options stored in loader config files. */
export interface EntryOptions {
@@ -137,17 +138,62 @@ export class Entry {
// step 3: check if options are changed
if (this.fiber?.uid) {
const diff = Object
.keys({ ...this.options, ...legacy })
.filter(key => !deepEqual(this.options[key], legacy[key]))
if (!diff.length && !force) return
const changes = Object.keys({ ...this.options, ...legacy })
.filter(key => !deepEqual(this.options[key], legacy[key], key === 'config'))
// Only an active fiber in an unchanged context takes volatile-only config changes without a remount.
const volatileOnly = changes.length === 1 && changes[0] === 'config'
&& this.fiber.state === FiberState.ACTIVE && Object.getPrototypeOf(this.ctx) === this.parent.ctx
&& equalExceptVolatile(legacy.config, this.options.config, this.fiber.runtime?.Config)
if (volatileOnly) this.fiber._config = this.options.config
const pending = volatileOnly && this._commitVolatile() ? [] : changes
if (!pending.length && !force) return
this.context.emit('loader/partial-dispose', this, legacy, true)
this._patchContext(diff)
this._patchContext(pending)
} else {
await this.init()
}
}
/**
* Parse a volatile-only raw config change and commit its values into the running fiber's references.
* An invalid candidate is logged and leaves the running references unchanged; the raw config stays retained for the next activation.
* @returns `false` when an ordinary effective value changed, so the caller applies the ordinary update lifecycle.
*/
private _commitVolatile(): boolean {
const fiber = this.fiber!
const refs = volatileEntries(fiber.config)
if (!refs.length) return true
const raw = this.options.config
let candidate: unknown
try {
candidate = resolveConfig(fiber.runtime!, fiber.ctx.waterfall(fiber, 'internal/config', raw, () => raw))
} catch (error) {
this.ctx.logger.warn('volatile config update failed for %C', this.options.id)
this.ctx.logger.warn(error)
return true
}
if (!deepEqual(fiber.config, candidate, true)) {
this.ctx.logger.debug('ordinary config values of %C changed with its volatile values; applying the ordinary update', this.options.id)
return false
}
const paths = refs.flatMap(({ path, ref }) => {
const source = path.reduce<unknown>((value, key) => Reflect.get(value as object, key), candidate) as Volatile<unknown>
if (deepEqual(ref.get(), source.get(), true)) return []
updateVolatile(ref, source)
return [path]
})
if (!paths.length) return true
const self: Context = Object.create(fiber.ctx)
self[Context.filter] = (owner: Context) => owner.fiber === fiber
try {
fiber.ctx.emit(self, 'loader/volatile-update', paths)
} catch (error) {
// A listener failure must not fail the entry update; every value is already committed.
this.ctx.logger.warn(error)
}
return true
}
getOuterStack = () => {
let entry: Entry | undefined = this
const result: string[] = []
+13 -1
View File
@@ -26,6 +26,18 @@ declare module '@deepseek-ai/cordis' {
'loader/config-update'(): void
'loader/entry-init'(entry: Entry): void
'loader/partial-dispose'(entry: Entry, legacy: Partial<EntryOptions>, active: boolean): void
/**
* Volatile config values were committed into the running fiber without a remount; dispatched to the owning fiber only.
* @param paths - changed config paths as key arrays; every value is committed before dispatch.
* @mode emit
*/
'loader/volatile-update'(paths: readonly (readonly string[])[]): void
/**
* Refresh entry context before applying config.
* @param entry - entry containing the new raw config and optional current fiber.
* @param next - continue context refresh and Loader's config update.
* @mode waterfall
*/
'loader/patch-context'(entry: Entry, next: () => void): void
}
@@ -103,7 +115,7 @@ export class Loader extends EntryTree {
ctx.on('internal/update', function (config, noSave, next) {
if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next()
const unparse = this.runtime?.Config?.['simplify']
this.entry.options.config = unparse ? unparse(config) : config
this.entry.options.config = unparse ? unparse.call(this.runtime!.Config, config) : config
this.entry.parent.tree.write()
return next()
}, { global: true, prepend: true })
+3
View File
@@ -19,6 +19,9 @@
},
{
"path": "../cordis"
},
{
"path": "../schemastery"
}
]
}
+4
View File
@@ -387,3 +387,7 @@ const schema2 = new Schema(JSON.parse(JSON.stringify(schema1)))
Schemastery also exposes the Standard Schema `~standard` property, so compatible
tools can validate values without depending on Schemastery-specific APIs.
## Volatile configuration
`.volatile()` preserves a field's schema type and metadata while parsing its value into a stable reference read with `.get()`. Defaults remain ordinary data; absent optional fields return references containing `undefined`. `simplify()` unwraps references for persistence. See the [configuration guide](../../docs/cordis-tutorial/05-config.md#volatile-fields) for supported placements and Cordis update semantics.
+93 -33
View File
@@ -1,4 +1,5 @@
import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from '@deepseek-ai/cosmokit'
import { createVolatile, isVolatile, type Volatile } from '@deepseek-ai/cosmokit'
import type { StandardSchemaV1 } from '@standard-schema/spec'
const kSchema = Symbol.for('schemastery')
@@ -9,7 +10,7 @@ declare global {
/** Convert primitive constructors, constants, and existing schemas into a schema type. */
export type From<X> =
| X extends string | number | boolean ? Schema<X>
: X extends Schema ? X
: X extends Schema<any, any, SchemaMode> ? X
: X extends typeof String ? Schema<string>
: X extends typeof Number ? Schema<number>
: X extends typeof Boolean ? Schema<boolean>
@@ -17,8 +18,8 @@ declare global {
: X extends Constructor<infer S> ? Schema<S>
: never
type TypeS1<X> = X extends Schema<infer S, unknown> ? S : never
type Inverse<X> = X extends Schema<any, infer Y> ? (arg: Y) => void : never
type TypeS1<X> = X extends Schema<infer S, infer _T, infer _M> ? S : never
type Inverse<X> = X extends Schema<infer _S, infer T, infer M> ? (arg: SchemaOutput<T, M>) => void : never
/** Input type accepted by a schema-like value. */
export type TypeS<X> = TypeS1<From<X>>
@@ -28,7 +29,7 @@ declare global {
export type Resolve = (data: any, schema: Schema, options: Options, strict?: boolean) => [any, any?]
/** Input type accepted by one schema in an intersection. */
export type IntersectS<X> = From<X> extends Schema<infer S, unknown> ? S : never
export type IntersectS<X> = From<X> extends Schema<infer S, infer _T, infer _M> ? S : never
/** Output type returned by one schema in an intersection. */
export type IntersectT<X> = Inverse<From<X>> extends ((arg: infer T) => void) ? T : never
@@ -85,8 +86,8 @@ declare global {
dict<X, Y extends Schema<any, string> = Schema<string>>(inner: X, sKey?: Y): Schema<Dict<TypeS<X>, TypeS<Y>>, Dict<TypeT<X>, TypeT<Y>>>
/** Accept tuple arrays where each index matches the corresponding schema. */
tuple<const X extends readonly any[]>(list: X): Schema<TupleS<X>, TupleT<X>>
/** Accept plain objects whose declared properties match the schema dictionary. */
object<X extends Dict>(dict: X): Schema<ObjectS<X>, ObjectT<X>>
/** Accept plain objects; infer fields from the dictionary, not the enclosing schema's output type. */
object<X extends Dict>(dict: X): Schema<ObjectS<NoInfer<X>>, ObjectT<NoInfer<X>>>
/** Accept values matching at least one schema in `list`. */
union<const X>(list: readonly X[]): Schema<TypeS<X>, TypeT<X>>
/** Accept values matching every schema in `list`, merging object outputs. */
@@ -94,7 +95,7 @@ declare global {
/** Validate with `inner`, then convert the result with `callback`. */
transform<X, T>(inner: X, callback: (value: TypeS<X>, options: Schemastery.Options) => T, preserve?: boolean): Schema<TypeS<X>, T>
/** Defer construction of a recursive schema until validation or serialization. */
lazy<X extends Schema>(callback: () => X): X
lazy<X extends Schema<any, any, SchemaMode>>(callback: () => X): X
ValidationError: typeof ValidationError
}
@@ -112,6 +113,8 @@ declare global {
export interface Meta<T = any> {
default?: T extends {} ? Partial<T> : T
required?: boolean
/** Parse this node as a stable config reference; its type and UI metadata remain unchanged. */
volatile?: boolean
disabled?: boolean
collapse?: boolean
badges?: { text: string; type: string }[]
@@ -130,9 +133,9 @@ declare global {
}
/** Callable schema instance that validates input and returns normalized output. */
interface Schemastery<S = any, T = S> {
(data?: S | null, options?: Schemastery.Options): T
new (data?: S | null, options?: Schemastery.Options): T
interface Schemastery<S = any, T = S, Mode extends SchemaMode = 'plain'> {
(data?: S | null, options?: Schemastery.Options): SchemaOutput<T, Mode>
new (data?: S | null, options?: Schemastery.Options): SchemaOutput<T, Mode>
[kSchema]: true
uid: number
meta: Schemastery.Meta<T>
@@ -152,49 +155,54 @@ declare global {
/** Format this schema as a compact TypeScript-like type string. */
toString(inline?: boolean): string
/** Serialize this schema, preserving shared and recursive references. */
toJSON(): Schema<S, T>
toJSON(): Schema<S, T, Mode>
/** Mark nullable input as invalid unless a default supplies a fallback. */
required(value?: boolean): Schema<S, T>
required<R extends boolean = true>(value?: R): Schema<S, T, SetRequired<Mode, R>>
/**
* Parse this config field as a stable reference containing immutable data.
* @returns a schema whose output supports get(), including when the field is absent.
*/
volatile(): Schema<NoInfer<S>, NoInfer<T>, Mode extends 'defined' | 'volatile-defined' ? 'volatile-defined' : 'volatile'>
/** Hide this schema node from UI renderers. */
hidden(value?: boolean): Schema<S, T>
hidden(value?: boolean): Schema<S, T, Mode>
/** Return the default value instead of throwing when validation fails. */
loose(value?: boolean): Schema<S, T>
loose(value?: boolean): Schema<S, T, Mode>
/** Attach a renderer role and optional role-specific metadata. */
role(text: string, extra?: any): Schema<S, T>
role(text: string, extra?: any): Schema<S, T, Mode>
/** Attach an external documentation link. */
link(link: string): Schema<S, T>
link(link: string): Schema<S, T, Mode>
/** Set the fallback value used for nullable input. */
default(value: T): Schema<S, T>
default(value: T | NoInfer<Partial<S>>): Schema<S, T, SetRequired<Mode, true>>
/** Attach an auxiliary comment for documentation or form UIs. */
comment(text: string): Schema<S, T>
comment(text: string): Schema<S, T, Mode>
/** Attach a localized or plain description for documentation or form UIs. */
description(text: string): Schema<S, T>
description(text: string): Schema<S, T, Mode>
/** Mark this schema node as disabled for form UIs. */
disabled(value?: boolean): Schema<S, T>
disabled(value?: boolean): Schema<S, T, Mode>
/** Request collapsed rendering for nested form UIs. */
collapse(value?: boolean): Schema<S, T>
collapse(value?: boolean): Schema<S, T, Mode>
/** Add a deprecated badge to this schema node. */
deprecated(): Schema<S, T>
deprecated(): Schema<S, T, Mode>
/** Add an experimental badge to this schema node. */
experimental(): Schema<S, T>
experimental(): Schema<S, T, Mode>
/** Require strings to match a regular expression. */
pattern(regexp: RegExp): Schema<S, T>
pattern(regexp: RegExp): Schema<S, T, Mode>
/** Set an inclusive maximum for numbers or collection lengths. */
max(value: number): Schema<S, T>
max(value: number): Schema<S, T, Mode>
/** Set an inclusive minimum for numbers or collection lengths. */
min(value: number): Schema<S, T>
min(value: number): Schema<S, T, Mode>
/** Set the numeric increment constraint. */
step(value: number): Schema<S, T>
step(value: number): Schema<S, T, Mode>
/** Add or replace an object property schema. */
set(key: string, value: Schema): Schema<S, T>
set(key: string, value: Schema): Schema<S, T, Mode>
/** Append a tuple, union, or intersection member schema. */
push(value: Schema): Schema<S, T>
push(value: Schema): Schema<S, T, Mode>
/** Remove values equal to schema defaults from normalized output. */
simplify(value?: any): any
/** Return a schema clone with descriptions merged from locale messages. */
i18n(messages: Dict): Schema<S, T>
i18n(messages: Dict): Schema<S, T, Mode>
/** Attach arbitrary metadata consumed by form renderers and downstream tools. */
extra<K extends keyof Schemastery.Meta>(key: K, value: Schemastery.Meta[K]): Schema<S, T>
extra<K extends keyof Schemastery.Meta>(key: K, value: Schemastery.Meta[K]): Schema<S, T, Mode>
}
}
@@ -234,7 +242,14 @@ Object.defineProperty(ValidationError.prototype, kValidationError, {
value: true,
})
type Schema<S = any, T = S> = Schemastery<S, T>
type SchemaMode = 'plain' | 'defined' | 'volatile' | 'volatile-defined'
type SchemaOutput<T, M extends SchemaMode> = M extends 'volatile' ? Volatile<T | undefined>
: M extends 'volatile-defined' ? Volatile<T> : T
type SetRequired<M extends SchemaMode, R extends boolean> = M extends 'volatile' | 'volatile-defined'
? R extends true ? 'volatile-defined' : 'volatile'
: R extends true ? 'defined' : 'plain'
type Schema<S = any, T = S, Mode extends SchemaMode = 'plain'> = Schemastery<S, T, Mode>
const Schema = function (options: Schema) {
const schema = function (data: any, options: Schemastery.Options = {}) {
@@ -405,6 +420,7 @@ Schema.prototype.pattern = function pattern(regexp) {
}
Schema.prototype.simplify = function simplify(this: Schema, value) {
if (isVolatile(value)) value = value.get()
if (deepEqual(value, this.meta.default, this.type === 'dict')) return null
if (isNullable(value)) return value
if (this.type === 'object' || this.type === 'dict') {
@@ -461,7 +477,36 @@ for (const key of ['default', 'link', 'comment', 'description', 'max', 'min', 's
})
}
Schema.prototype.volatile = function volatile() {
if (this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
return this.extra('volatile', true)
}
const resolvers: Dict<Schemastery.Resolve> = {}
const checkedVolatile = Symbol('checked-volatile-schema')
function validateVolatileSchema(schema: Schema, path: (keyof any)[] = [], blocked = false, seen = new Map<Schema, Set<boolean>>()) {
const states = seen.get(schema) ?? new Set<boolean>()
if (states.has(blocked)) return
states.add(blocked)
seen.set(schema, states)
if (schema.meta?.volatile && blocked) {
throw new ValidationError('volatile fields require a fixed object path without an enclosing volatile field', { path })
}
const nested = blocked || !!schema.meta?.volatile
if (schema.dict) {
for (const [key, child] of Object.entries(schema.dict)) validateVolatileSchema(child, [...path, key], nested, seen)
}
if (schema.sKey) validateVolatileSchema(schema.sKey, [...path, '<key>'], true, seen)
if (schema.inner && (schema.type !== 'lazy' || schema.inner[kSchema])) {
validateVolatileSchema(schema.inner, [...path, '*'], true, seen)
}
if (schema.list) {
for (let index = 0; index < schema.list.length; index++) {
validateVolatileSchema(schema.list[index]!, [...path, String(index)], true, seen)
}
}
}
Schema.extend = function extend(type, resolve) {
resolvers[type] = resolve
@@ -469,6 +514,20 @@ Schema.extend = function extend(type, resolve) {
Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
if (!schema) return [data]
if (!(options as Schemastery.Options & { [checkedVolatile]?: boolean })[checkedVolatile]) {
validateVolatileSchema(schema, options.path)
options = { ...options, [checkedVolatile]: true } as Schemastery.Options
}
if (schema.meta?.volatile) {
const inner = Schema(schema)
inner.meta = { ...schema.meta, volatile: false }
const [value, adapted] = Schema.resolve(data, inner, options, strict)
try {
return [createVolatile(value), adapted]
} catch (error) {
throw new ValidationError(error instanceof Error ? error.message : String(error), options)
}
}
if (options.ignore?.(data, schema)) return [data]
if (isNullable(data) && schema.type !== 'lazy') {
@@ -582,6 +641,7 @@ Schema.extend('lazy', (data, schema, options, strict) => {
if (!schema.inner![kSchema]) {
schema.inner = schema.builder!()
schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
validateVolatileSchema(schema.inner!, options.path, true)
}
return Schema.resolve(data, schema.inner!, options, strict)
})
@@ -706,7 +766,7 @@ function property(data: any, key: keyof any, schema: Schema, options: Schemaster
} catch (e) {
if (!options?.autofix) throw e
delete data[key]
return schema.meta.default
return schema.meta.volatile ? createVolatile(schema.meta.default) : schema.meta.default
}
}