Merge latest master into worktree/pr884-simplify
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings-local/README.md
|
||||
README.md: 39c2254caac720149d2fbf04d067e6154b82a671
|
||||
README.zh.md: 9fcc1319a35d56a965eb756737175dc89518c0e5
|
||||
README.md: 344300c33879918e836b6e208b172343cc472faa
|
||||
README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f
|
||||
|
||||
@@ -19,7 +19,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
|
||||
|
||||
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
|
||||
- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
|
||||
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff and a 2 s acquisition deadline. A contender never removes a lock it does not own; it rejects at the deadline instead. Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
|
||||
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
|
||||
- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
|
||||
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
|
||||
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
|
||||
@@ -38,7 +38,6 @@ No direct invalidation; the consuming plugin owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check.
|
||||
- **An abandoned writer lock requires operator recovery** — lock age cannot prove ownership, so writers fail closed after 2 s instead of deleting an old lock that may still protect a slow holder; remove `<file>.lock` only after establishing that no writer owns it.
|
||||
- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart.
|
||||
- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described.
|
||||
- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
|
||||
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。
|
||||
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避与 2 s 的获取期限。竞争者绝不移除不归自己所有的锁,而会在期限到达时拒绝写入。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
|
||||
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避与 2 s 的获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
|
||||
- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
|
||||
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
|
||||
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
|
||||
@@ -38,7 +38,6 @@
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace,但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
|
||||
- **遗留的写锁需要操作者恢复** — 锁的存续时间无法证明所有权,因此写入方会在 2 s 后以失败收口,不会删除一把可能仍在保护慢速持有者的旧锁;只有确认没有写入方拥有 `<file>.lock` 后才能将其移除。
|
||||
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
|
||||
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化,无注释(JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
|
||||
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
@@ -38,6 +39,7 @@
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { watch as chokidarWatch } from 'chokidar'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve } from 'node:path'
|
||||
import { Document, parseDocument } from 'yaml'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
|
||||
@@ -96,16 +96,6 @@ function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** Whether an exclusive create failed because the path already exists. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/** Writer-lock retry constants for the private settings document protocol. */
|
||||
const LOCK_RETRY_INITIAL_MS = 20
|
||||
const LOCK_RETRY_MAX_MS = 200
|
||||
const LOCK_TIMEOUT_MS = 2_000
|
||||
|
||||
/** File-backed settings provider (`settings.yaml`/`.json`). */
|
||||
export class SettingsLocal extends Settings {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -190,8 +180,11 @@ export class SettingsLocal extends Settings {
|
||||
}
|
||||
|
||||
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
// The writer lock's exclusive create needs the parent to exist before
|
||||
// writeFileAtomic gets its own chance to create it.
|
||||
// 0700: the harness home holds user-private documents.
|
||||
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
|
||||
await this.withWriterLock(async () => {
|
||||
await withFileLock(this.spec.filename, async () => {
|
||||
// Read-modify-write: fold in any on-disk state this process has not
|
||||
// observed yet — an external edit still inside the watcher debounce
|
||||
// window, a change the watcher missed, or another process's write — so
|
||||
@@ -202,45 +195,12 @@ export class SettingsLocal extends Settings {
|
||||
const output = this.spec.format === 'yaml'
|
||||
? this.renderYaml(ns, section)
|
||||
: this.renderJson(ns, section)
|
||||
const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp`
|
||||
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
|
||||
// and parent directory and preserves owner-only permissions on Windows.
|
||||
try {
|
||||
await writeFile(temp, output, { mode: 0o600, flag: 'wx' })
|
||||
await rename(temp, this.spec.filename)
|
||||
} catch (error) {
|
||||
await rm(temp, { force: true })
|
||||
throw error
|
||||
}
|
||||
// 0600: a document that may hold personal values is never world-readable.
|
||||
await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
|
||||
this.text = output
|
||||
})
|
||||
}
|
||||
|
||||
/** Hold the private cross-process writer lock around one read-render-rename cycle. */
|
||||
private async withWriterLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${this.spec.filename}.lock`
|
||||
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
||||
let delay = LOCK_RETRY_INITIAL_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
|
||||
break
|
||||
} catch (error) {
|
||||
if (!isEEXIST(error)) throw error
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`)
|
||||
}
|
||||
await new Promise(resolvePause => setTimeout(resolvePause, delay))
|
||||
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
|
||||
}
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
await rm(lockPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
|
||||
// The base init loads and publishes; a parse failure there is a boot
|
||||
// failure: an existing-but-invalid document must fail loud, never be
|
||||
|
||||
@@ -16,7 +16,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this provider's contracts are file round-trip,
|
||||
* watcher timing, and atomic replacement behavior — IO effects proven by package
|
||||
* watcher timing, and atomic-write behavior — IO effects proven by package
|
||||
* tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Writer-lock races that cannot be timed from outside: a contender whose lock
|
||||
// vanishes after the failed exclusive create and a temp-file write failing
|
||||
// mid-cycle.
|
||||
// The fs/promises seam is partially mocked to inject exactly one failure at a
|
||||
// chosen path suffix; everything else passes through to the real filesystem.
|
||||
// A temp-file write failure cannot be timed from outside. The fs/promises seam
|
||||
// injects it once so the test can prove that the writer lock still releases.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
@@ -13,22 +10,18 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal } from '../src/index.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** One-shot failure injections keyed by operation, matched on a path suffix. */
|
||||
failures: [] as Array<{ suffix: string; code: string }>,
|
||||
failTempWrite: false,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
const inject = (path: unknown): void => {
|
||||
const index = state.failures.findIndex(f => String(path).endsWith(f.suffix))
|
||||
if (index === -1) return
|
||||
const [failure] = state.failures.splice(index, 1)
|
||||
throw Object.assign(new Error(`${failure!.code}: injected writeFile failure`), { code: failure!.code })
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
writeFile: (async (path: unknown, ...rest: never[]) => {
|
||||
inject(path)
|
||||
if (state.failTempWrite && String(path).endsWith('.tmp')) {
|
||||
state.failTempWrite = false
|
||||
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
|
||||
}
|
||||
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
|
||||
}) as typeof actual.writeFile,
|
||||
}
|
||||
@@ -39,7 +32,7 @@ const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
state.failures.length = 0
|
||||
state.failTempWrite = false
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
})
|
||||
|
||||
@@ -57,25 +50,14 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('writer-lock races', () => {
|
||||
it('retries when the contending lock vanished after the failed create', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
// The exclusive create loses once, but no lock remains by the retry.
|
||||
state.failures.push({ suffix: '.lock', code: 'EEXIST' })
|
||||
await scope.update({ value: 3 })
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 3')
|
||||
})
|
||||
|
||||
describe('writer-lock failure cleanup', () => {
|
||||
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
|
||||
const dir = await tempDir()
|
||||
const path = join(dir, 'settings.yaml')
|
||||
await writeFile(path, 'alpha:\n value: 1\n')
|
||||
const ctx = await boot({ path, watch: false })
|
||||
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
|
||||
state.failures.push({ suffix: '.tmp', code: 'ENOSPC' })
|
||||
state.failTempWrite = true
|
||||
await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/)
|
||||
// The document is untouched and the writer lock was released on the way out.
|
||||
expect(await readFile(path, 'utf8')).toContain('value: 1')
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
|
||||
README.md: ec9f0e09c47015edd8495dac48beb610e0b5cdc5
|
||||
README.zh.md: 6d0a760f9b1bbef21881a03933d0fe5b9fc3cd0d
|
||||
README.md: 1f1ce07722bfb035746ad5733f90ddabe2d1553b
|
||||
README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565
|
||||
|
||||
@@ -7,10 +7,12 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
## Service API
|
||||
|
||||
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
|
||||
- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
|
||||
- `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.
|
||||
- `get(ns)` — resolved value, `undefined` while unregistered.
|
||||
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
|
||||
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
|
||||
- `replace(ns, section)` — sets the user section wholesale: the deliberate reset (`replace({})` re-inherits `base` and schema defaults).
|
||||
- `mutate(ns, ops)` — applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale deletes every secret the wire never returned, while an op names the one field it means.
|
||||
- Every write takes an optional `expectedRevision`. Each descriptor carries the namespace's `revision`, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects with `SettingsConflictError` (`code: 'SETTINGS_CONFLICT'`, both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot.
|
||||
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners.
|
||||
- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
|
||||
|
||||
@@ -20,7 +22,9 @@ Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push
|
||||
|
||||
## Events
|
||||
|
||||
`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value.
|
||||
`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value — it is the consumer-facing event, and a consumer only cares that its value moved.
|
||||
|
||||
`settings/document-updated (ns, revision)` fires whenever the RAW user section changes, whether or not the resolved value did. Configuration surfaces need this one: storing an override equal to the composition base leaves the resolved value alone but changes what the document says (the field is now overridden, not inherited) and moves the revision every open editor is holding. Listener containment matches `settings/updated`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -33,5 +37,5 @@ No direct invalidation; a consumer that folds a settings value into the request
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
|
||||
- **`redactSecrets` is not a proven wire boundary** — the walker follows `object`/`dict`/`array`, so a `role('secret')` reached only through a union, intersection, or transform is returned VERBATIM with an empty `secrets` list, and `schema.toJSON()` carries a secret field's `.default(...)` to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closed `describeForWire()` — one that refuses a schema it cannot prove safe, and sanitizes the serialized envelope and error text — is the real answer and is deferred.
|
||||
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).
|
||||
- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
## 服务 API
|
||||
|
||||
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
|
||||
- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
|
||||
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个 wire 面都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。
|
||||
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
||||
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
||||
- `replace(ns, section)` — 整体替换用户分节:merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
||||
- `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
||||
- `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。
|
||||
- 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先落地的那个写方。写队列只保证写入的先后次序,它本身分辨不出一个新写方与一个持有过期快照的写方。
|
||||
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会覆盖更新的结果),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
|
||||
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
|
||||
|
||||
@@ -20,7 +22,9 @@
|
||||
|
||||
## 事件
|
||||
|
||||
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。
|
||||
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发——它面向消费方,而消费方只关心自己的值有没有变。
|
||||
|
||||
`settings/document-updated (ns, revision)` 在**原始**用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 `base` 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision。监听器的收容方式与 `settings/updated` 相同。
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -33,5 +37,5 @@
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
|
||||
- **`redactSecrets` 并非一条可被证明的协议边界**:walker 只跟随 `object`/`dict`/`array`,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 会被**原样**返回,且 `secrets` 列表为空;而 `schema.toJSON()` 会把 secret 字段的 `.default(...)` 一并带给每个客户端。这两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。真正的答案是一个 fail-closed 的 `describeForWire()`——它拒绝自己无法证明安全的 schema,并对序列化信封与错误文本做净化——此项暂缓。
|
||||
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
|
||||
- **无 secret 字段脱敏** — `describe()` 原样返回解析值;wire 面(RPC/UI)在暴露前必须对 `role('secret')` 字段脱敏。
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type z from 'schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { redactSecrets } from './redact.ts'
|
||||
import type { RedactedSecret } from './redact.ts'
|
||||
|
||||
export { redactSecrets } from './redact.ts'
|
||||
export type { RedactedSecret, RedactedValue } from './redact.ts'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
@@ -51,8 +56,32 @@ export interface SettingsDescriptor {
|
||||
schema: unknown
|
||||
/** Current resolved value. */
|
||||
value: unknown
|
||||
/**
|
||||
* Monotonic revision of the raw user section this descriptor was read at.
|
||||
* Send it back as `expectedRevision` on a write to refuse a stale one.
|
||||
*/
|
||||
revision: number
|
||||
/** Registrant's composition `base` layer (detached), when one was declared. */
|
||||
base?: unknown
|
||||
/**
|
||||
* Raw user section from the stored document (detached), when one exists and
|
||||
* is well-formed; a field's presence here is what marks it user-overridden.
|
||||
*/
|
||||
user?: unknown
|
||||
/** Owner's declared effect timing. */
|
||||
applies: SettingsApplies
|
||||
/** Schema-declared secret positions; present only under `redactSecrets`. */
|
||||
secrets?: RedactedSecret[]
|
||||
}
|
||||
|
||||
/** Options for {@link Settings.describe}. */
|
||||
export interface SettingsDescribeOptions {
|
||||
/**
|
||||
* Strip `role('secret')` fields from `value`/`base`/`user` and enumerate
|
||||
* them in each descriptor's `secrets`. Every wire surface MUST pass this;
|
||||
* the verbatim default exists for same-process configuration UIs only.
|
||||
*/
|
||||
redactSecrets?: boolean
|
||||
}
|
||||
|
||||
/** Owner-facing handle for one registered namespace. */
|
||||
@@ -106,6 +135,19 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
|
||||
|
||||
/**
|
||||
* One registered namespace's RAW user section changed, whether or not the
|
||||
* resolved value did. `settings/updated` is the consumer-facing event and
|
||||
* stays deep-equal-gated; this one exists for configuration surfaces,
|
||||
* which must learn that a field went from inherited to overridden (same
|
||||
* resolved value, different meaning) and that their held revision is
|
||||
* stale. Listener containment matches `settings/updated`.
|
||||
* @param ns - the namespace whose stored section changed.
|
||||
* @param revision - the namespace's new revision.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/document-updated'(ns: SettingsNamespace, revision: number): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +173,32 @@ export function deepEqualJson(a: unknown, b: unknown): boolean {
|
||||
return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
|
||||
}
|
||||
|
||||
/**
|
||||
* A write refused because the namespace moved since the caller read it. The
|
||||
* seam's serialized write queue orders writes; it cannot tell a fresh writer
|
||||
* from one holding a stale snapshot, which is what this reports.
|
||||
*/
|
||||
export class SettingsConflictError extends Error {
|
||||
/** Stable machine code for wire layers mapping this to their own taxonomy. */
|
||||
readonly code = 'SETTINGS_CONFLICT'
|
||||
/** The revision the write expected. */
|
||||
readonly expected: number
|
||||
/** The revision the namespace actually stands at. */
|
||||
readonly actual: number
|
||||
|
||||
/**
|
||||
* @param ns - the namespace whose write was refused.
|
||||
* @param expected - the revision the caller sent.
|
||||
* @param actual - the revision now stored.
|
||||
*/
|
||||
constructor(ns: SettingsNamespace, expected: number, actual: number) {
|
||||
super(`settings namespace "${ns}" changed since it was read (expected revision ${String(expected)}, now ${String(actual)})`)
|
||||
this.name = 'SettingsConflictError'
|
||||
this.expected = expected
|
||||
this.actual = actual
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
@@ -138,6 +206,44 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/**
|
||||
* One path-addressed edit to a namespace's user section. Path mutation exists
|
||||
* for a caller holding an INCOMPLETE view of the section — a configuration UI
|
||||
* reads the redacted descriptor, which by construction never received the
|
||||
* `role('secret')` fields. Such a caller can name the field it means without
|
||||
* restating the section: a wholesale `replace` rebuilt from a redacted
|
||||
* document silently deletes every secret the wire never returned.
|
||||
*/
|
||||
export type SettingsPathOp =
|
||||
| { op: 'set'; path: readonly string[]; value: unknown }
|
||||
| { op: 'unset'; path: readonly string[] }
|
||||
|
||||
/** Apply one path op to a detached section, returning the next section. */
|
||||
function applyPathOp(section: Record<string, unknown>, op: SettingsPathOp): Record<string, unknown> {
|
||||
const [head, ...rest] = op.path
|
||||
// The empty path addresses the section itself.
|
||||
if (head === undefined) {
|
||||
if (op.op === 'unset') return {}
|
||||
if (!isPlainObject(op.value)) {
|
||||
throw new TypeError('settings mutate: setting the section root requires a plain object')
|
||||
}
|
||||
return { ...op.value }
|
||||
}
|
||||
if (rest.length === 0) {
|
||||
if (op.op === 'set') return { ...section, [head]: op.value }
|
||||
const { [head]: _removed, ...kept } = section
|
||||
return kept
|
||||
}
|
||||
const child = section[head]
|
||||
if (!isPlainObject(child)) {
|
||||
// Unsetting through an absent path is already satisfied; setting through
|
||||
// one creates the intermediate objects it needs.
|
||||
if (op.op === 'unset') return section
|
||||
return { ...section, [head]: applyPathOp({}, { ...op, path: rest }) }
|
||||
}
|
||||
return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) }
|
||||
}
|
||||
|
||||
/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */
|
||||
function describeRejected(value: unknown): string {
|
||||
if (value === undefined) return 'undefined'
|
||||
@@ -238,6 +344,15 @@ interface SettingsRegistration {
|
||||
base: unknown
|
||||
applies: SettingsApplies
|
||||
resolved: unknown
|
||||
/**
|
||||
* Monotonic counter over this namespace's RAW user section — bumped by any
|
||||
* change to what is stored, including one whose resolved value is
|
||||
* unchanged (adding an override equal to the composition base). Editors
|
||||
* carry it as `expectedRevision` to detect a concurrent write, and the
|
||||
* document event carries it so another tab learns a field went from
|
||||
* inherited to overridden.
|
||||
*/
|
||||
revision: number
|
||||
watchers: Set<SettingsWatcher>
|
||||
}
|
||||
|
||||
@@ -321,6 +436,7 @@ export abstract class Settings extends Service {
|
||||
base: options?.base,
|
||||
applies: options?.applies ?? 'live',
|
||||
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
|
||||
revision: 0,
|
||||
watchers: new Set(),
|
||||
}
|
||||
this.ctx.effect(() => {
|
||||
@@ -345,16 +461,45 @@ export abstract class Settings extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe every registered namespace for configuration surfaces.
|
||||
* Describe every registered namespace for configuration surfaces, including
|
||||
* the composition `base` and raw user layers so a form can mark which fields
|
||||
* the user overrode (presence in `user`) and what a reset returns to.
|
||||
* @param options - redaction switch; wire surfaces must redact.
|
||||
* @returns one descriptor per registered namespace, in registration order.
|
||||
*/
|
||||
describe(): SettingsDescriptor[] {
|
||||
return [...this.registrations.values()].map(registration => ({
|
||||
ns: registration.ns,
|
||||
schema: registration.schema.toJSON(),
|
||||
value: registration.resolved,
|
||||
applies: registration.applies,
|
||||
}))
|
||||
describe(options?: SettingsDescribeOptions): SettingsDescriptor[] {
|
||||
return [...this.registrations.values()].map((registration) => {
|
||||
let user: Record<string, unknown> | undefined
|
||||
try {
|
||||
user = this.section(registration.ns)
|
||||
} catch {
|
||||
// A malformed stored section already warned at publish and kept the
|
||||
// last good resolved value; only that malformed shape can throw here,
|
||||
// and describing it as "no user layer" keeps this read total.
|
||||
user = undefined
|
||||
}
|
||||
const base = registration.base === undefined ? undefined : structuredClone(registration.base)
|
||||
const detachedUser = user === undefined ? undefined : structuredClone(user)
|
||||
const descriptor: SettingsDescriptor = {
|
||||
ns: registration.ns,
|
||||
schema: registration.schema.toJSON(),
|
||||
value: registration.resolved,
|
||||
revision: registration.revision,
|
||||
...base === undefined ? {} : { base },
|
||||
...detachedUser === undefined ? {} : { user: detachedUser },
|
||||
applies: registration.applies,
|
||||
}
|
||||
if (options?.redactSecrets !== true) return descriptor
|
||||
const schema = registration.schema as z<never>
|
||||
const redacted = redactSecrets(schema, registration.resolved)
|
||||
return {
|
||||
...descriptor,
|
||||
value: redacted.value,
|
||||
...base === undefined ? {} : { base: redactSecrets(schema, base).value },
|
||||
...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value },
|
||||
secrets: redacted.secrets,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,9 +519,11 @@ export abstract class Settings extends Service {
|
||||
* merging over the previous write's committed section.
|
||||
* @param ns - the registered namespace to update.
|
||||
* @param patch - plain-object patch over the user section.
|
||||
* @param expectedRevision - the descriptor `revision` the caller read; a
|
||||
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
||||
*/
|
||||
async update(ns: SettingsNamespace, patch: object): Promise<void> {
|
||||
return this.write(ns, patch, 'merge')
|
||||
async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void> {
|
||||
return this.write(ns, patch, 'merge', expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,14 +533,46 @@ export abstract class Settings extends Service {
|
||||
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
||||
* @param ns - the registered namespace to replace.
|
||||
* @param section - the complete next user section.
|
||||
* @param expectedRevision - the descriptor `revision` the caller read; a
|
||||
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
||||
*/
|
||||
async replace(ns: SettingsNamespace, section: object): Promise<void> {
|
||||
return this.write(ns, section, 'replace')
|
||||
async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise<void> {
|
||||
return this.write(ns, section, 'replace', expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply path-addressed edits to one registered namespace's user section,
|
||||
* validate, persist, then commit and emit. The ops are applied to the
|
||||
* section as it stands when the write reaches the front of the queue, so a
|
||||
* caller never has to restate fields it did not touch — and, crucially,
|
||||
* cannot delete fields it never saw. This is the write path for any caller
|
||||
* holding a redacted view; `replace` remains the wholesale reset.
|
||||
* @param ns - the registered namespace to edit.
|
||||
* @param ops - ordered path edits; later ops observe earlier ones.
|
||||
* @param expectedRevision - the descriptor `revision` the caller read; a
|
||||
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
||||
*/
|
||||
async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void> {
|
||||
if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`)
|
||||
for (const op of ops) {
|
||||
if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
|
||||
throw new TypeError(`settings mutate for "${ns}" ops must be {op:'set'|'unset', path}`)
|
||||
}
|
||||
if (!Array.isArray(op['path']) || (op['path'] as unknown[]).some(part => typeof part !== 'string')) {
|
||||
throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`)
|
||||
}
|
||||
}
|
||||
return this.write(ns, ops, 'mutate', expectedRevision)
|
||||
}
|
||||
|
||||
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
||||
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
|
||||
const verb = mode === 'merge' ? 'update' : 'replace'
|
||||
private write(
|
||||
ns: SettingsNamespace,
|
||||
input: object,
|
||||
mode: 'merge' | 'replace' | 'mutate',
|
||||
expectedRevision?: number,
|
||||
): Promise<void> {
|
||||
const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate'
|
||||
const registration = this.registrations.get(ns)
|
||||
if (registration === undefined) {
|
||||
throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
@@ -404,13 +583,19 @@ export abstract class Settings extends Service {
|
||||
if (!this.writable) {
|
||||
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
|
||||
}
|
||||
if (!isPlainObject(input)) {
|
||||
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||
// A mutate's ops array is wrapped so one JSON-shape walk covers both
|
||||
// shapes; merge/replace carry the section itself.
|
||||
let payload: Record<string, unknown>
|
||||
if (mode === 'mutate') {
|
||||
payload = { ops: input }
|
||||
} else {
|
||||
if (!isPlainObject(input)) throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
|
||||
payload = input
|
||||
}
|
||||
// Snapshot at call time: the queue must never read a caller-owned object
|
||||
// the caller may keep mutating while the write waits its turn. The same
|
||||
// walk is the JSON-shape boundary check (see cloneJsonShaped).
|
||||
const snapshot = cloneJsonShaped(input, (label, path) =>
|
||||
const snapshot = cloneJsonShaped(payload, (label, path) =>
|
||||
new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`))
|
||||
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
|
||||
// Chain past a failed predecessor: one rejected write must not poison the
|
||||
@@ -422,9 +607,20 @@ export abstract class Settings extends Service {
|
||||
if (this.registrations.get(ns) !== registration) {
|
||||
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
|
||||
}
|
||||
// Every mode derives from the section as it stands NOW, at the front of
|
||||
// the queue — never from whatever the caller last saw.
|
||||
const current = this.section(ns) ?? {}
|
||||
// The revision check belongs HERE, not at call time: the queue orders
|
||||
// writes but cannot tell a fresh writer from one holding a snapshot
|
||||
// that a predecessor already superseded.
|
||||
if (expectedRevision !== undefined && expectedRevision !== registration.revision) {
|
||||
throw new SettingsConflictError(ns, expectedRevision, registration.revision)
|
||||
}
|
||||
const section = mode === 'merge'
|
||||
? mergeLayers(this.section(ns) ?? {}, snapshot) as Record<string, unknown>
|
||||
: snapshot
|
||||
? mergeLayers(current, snapshot) as Record<string, unknown>
|
||||
: mode === 'replace'
|
||||
? snapshot
|
||||
: (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
|
||||
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
|
||||
await this.persist(ns, section)
|
||||
// The write reached storage either way; the cache must say so. Commit
|
||||
@@ -434,6 +630,7 @@ export abstract class Settings extends Service {
|
||||
// TODO(settings-replacement-resync): Re-resolve any replacement registration
|
||||
// from this persisted section so an old in-flight write cannot leave it stale.
|
||||
if (this.registrations.get(ns) === registration && !this.isStopped()) {
|
||||
this.bumpRevision(registration, current, section)
|
||||
this.commit(registration, next, 'update')
|
||||
}
|
||||
})
|
||||
@@ -449,6 +646,19 @@ export abstract class Settings extends Service {
|
||||
* @param source - change origin; defaults to `provider`.
|
||||
*/
|
||||
protected publish(doc: Record<string, unknown>, source: SettingsUpdateSource = 'provider'): void {
|
||||
// Read every raw section BEFORE swapping the document, so the revision
|
||||
// bump below compares what was stored with what now is — an external edit
|
||||
// moves the revision exactly like an in-process write.
|
||||
const before = new Map<SettingsNamespace, unknown>()
|
||||
for (const registration of this.registrations.values()) {
|
||||
try {
|
||||
before.set(registration.ns, this.section(registration.ns))
|
||||
} catch {
|
||||
// A malformed stored section is not a readable "before"; treating it
|
||||
// as absent still bumps against any well-formed replacement.
|
||||
before.set(registration.ns, undefined)
|
||||
}
|
||||
}
|
||||
this.document = doc
|
||||
for (const registration of this.registrations.values()) {
|
||||
let next: unknown
|
||||
@@ -459,6 +669,7 @@ export abstract class Settings extends Service {
|
||||
this.ctx.logger.warn(error)
|
||||
continue
|
||||
}
|
||||
this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns))
|
||||
this.commit(registration, next, source)
|
||||
}
|
||||
}
|
||||
@@ -480,6 +691,42 @@ export abstract class Settings extends Service {
|
||||
return schema(mergeLayers(base, section) as never)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a namespace's revision when its RAW section changed, and announce
|
||||
* it. Deliberately independent of {@link commit}'s resolved-value equality:
|
||||
* storing an override equal to the composition base leaves the resolved
|
||||
* value alone but changes what the document says, which is exactly what a
|
||||
* configuration surface must re-read.
|
||||
*/
|
||||
private bumpRevision(registration: SettingsRegistration, before: unknown, after: unknown): void {
|
||||
if (deepEqualJson(before, after)) return
|
||||
registration.revision += 1
|
||||
this.emitDocumentUpdated(registration.ns, registration.revision)
|
||||
}
|
||||
|
||||
/** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
|
||||
private emitDocumentUpdated(ns: SettingsNamespace, revision: number): void {
|
||||
let invariantFailure: unknown
|
||||
const args = ['settings/document-updated', ns, revision]
|
||||
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
|
||||
try {
|
||||
const returned = listener(ns, revision)
|
||||
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
|
||||
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
|
||||
this.warnListenerFailure(ns, error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
|
||||
invariantFailure ??= error
|
||||
continue
|
||||
}
|
||||
this.warnListenerFailure(ns, error)
|
||||
}
|
||||
}
|
||||
if (invariantFailure !== undefined) throw invariantFailure as Error
|
||||
}
|
||||
|
||||
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
|
||||
private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void {
|
||||
const prev = registration.resolved
|
||||
@@ -546,14 +793,34 @@ export abstract class Settings extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value mirror of the `FiberState` members {@link isUnloading} compares
|
||||
* against: a const enum has no runtime object to import, and the value is
|
||||
* needed at runtime (same rationale as the CLI boot driver's mirror).
|
||||
*/
|
||||
const FIBER_DISPOSED = 4
|
||||
const FIBER_UNLOADING = 5
|
||||
|
||||
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
|
||||
function isUnloading(ctx: Context): boolean {
|
||||
const state: number = ctx.fiber.state
|
||||
return state === FIBER_UNLOADING || state === FIBER_DISPOSED
|
||||
}
|
||||
|
||||
/** Hooks a consumer hands to {@link installSettingsSection}. */
|
||||
export interface SettingsSectionHooks<T> {
|
||||
/**
|
||||
* Receive the active configuration source: the resolved settings scope
|
||||
* while one is attached, the composition entry otherwise.
|
||||
* while one is attached, the composition entry otherwise. Called before
|
||||
* the matching `onChange` at attach and at detach.
|
||||
* @param current - thunk returning the currently authoritative value.
|
||||
*/
|
||||
setSource(current: () => T): void
|
||||
/**
|
||||
* Re-judge anything derived from the source — registration-level facts,
|
||||
* memoized resolutions — after an attach, a detach, or a committed change.
|
||||
*/
|
||||
onChange(): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,10 +828,8 @@ export interface SettingsSectionHooks<T> {
|
||||
* service exists, register `ns` with the consumer's composition entry as the
|
||||
* `base` layer and point the source thunk at the resolved scope; when the
|
||||
* service goes away (disposal, provider reload), fall back to the entry so
|
||||
* the consumer keeps working exactly as composed. The returned source is live:
|
||||
* callers read committed changes from `scope.get()` without a change callback.
|
||||
* The registration rides the scoped fiber, so no settings service ever mounted
|
||||
* means none of this runs.
|
||||
* the consumer keeps working exactly as composed. The registration rides the
|
||||
* scoped fiber, so no settings service ever mounted means none of this runs.
|
||||
* @param ctx - consumer plugin context owning the wiring.
|
||||
* @param ns - the consumer-owned settings namespace.
|
||||
* @param schema - schema resolving the namespace (typically the plugin Config).
|
||||
@@ -582,7 +847,24 @@ export function installSettingsSection<T>(
|
||||
const scope = sctx.settings.register(ns, schema, { base: entry })
|
||||
hooks.setSource(() => scope.get())
|
||||
sctx.effect(() => () => {
|
||||
// This disposer runs for two different reasons. A settings provider
|
||||
// detaching leaves the consumer running, so it must fall back to its
|
||||
// composition entry and re-judge what it derived. The consumer's own
|
||||
// unload runs it too — and there `onChange` would re-register routes
|
||||
// and touch resources the teardown is releasing, so the fallback is
|
||||
// pointless and the notification actively harmful.
|
||||
if (isUnloading(ctx)) return
|
||||
hooks.setSource(() => entry)
|
||||
hooks.onChange()
|
||||
})
|
||||
hooks.onChange()
|
||||
scope.watch(() => {
|
||||
// A stored change landing while the consumer unloads reaches the watcher
|
||||
// before the registration is released, and `onChange` is exactly as
|
||||
// harmful here as in the disposer above: it re-registers routes against
|
||||
// a fiber whose resources are being let go.
|
||||
if (isUnloading(ctx)) return
|
||||
hooks.onChange()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
109
packages/settings/settings/src/redact.ts
Normal file
109
packages/settings/settings/src/redact.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Structural secret redaction for settings values. `role('secret')` fields are
|
||||
* removed from a value before it crosses a wire boundary; a sidecar records
|
||||
* each schema-declared secret position and whether it currently holds a value,
|
||||
* so a configuration surface can render a write-only input without ever
|
||||
* receiving the secret itself.
|
||||
* @module @deepseek-ai/dsh-settings/redact
|
||||
*/
|
||||
|
||||
import type z from 'schemastery'
|
||||
|
||||
/**
|
||||
* Minimal structural view of a live schemastery node. Only the relations the
|
||||
* redactor walks are named; everything else on the instance is ignored.
|
||||
*/
|
||||
interface SchemaNode {
|
||||
type?: string
|
||||
meta?: { role?: unknown }
|
||||
/** `object` properties, keyed by property name. */
|
||||
dict?: Record<string, SchemaNode>
|
||||
/** `dict`/`array` element schema. */
|
||||
inner?: SchemaNode
|
||||
}
|
||||
|
||||
/** One schema-declared secret position inside a redacted value. */
|
||||
export interface RedactedSecret {
|
||||
/** Path from the section root to the removed field (concrete dict keys and array indexes included). */
|
||||
path: string[]
|
||||
/** Whether the field held a value before redaction. */
|
||||
set: boolean
|
||||
}
|
||||
|
||||
/** A value with every `role('secret')` field removed, plus the removal record. */
|
||||
export interface RedactedValue {
|
||||
/** Detached copy of the input with secret fields absent. */
|
||||
value: unknown
|
||||
/**
|
||||
* Every reachable secret position: object properties always (even unset, so
|
||||
* a form knows the slot exists), dict entries and array items only where the
|
||||
* value has them.
|
||||
*/
|
||||
secrets: RedactedSecret[]
|
||||
}
|
||||
|
||||
/** Whether a value is a plain data object the walker may recurse into. */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function walk(node: SchemaNode | undefined, value: unknown, path: string[], secrets: RedactedSecret[]): unknown {
|
||||
if (node === undefined) return value
|
||||
if (node.meta?.role === 'secret') {
|
||||
secrets.push({ path, set: value !== undefined })
|
||||
return undefined
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'object': {
|
||||
const properties = node.dict ?? {}
|
||||
const source = isRecord(value) ? value : undefined
|
||||
const rebuilt: Record<string, unknown> = {}
|
||||
if (source !== undefined) {
|
||||
for (const [key, entry] of Object.entries(source)) {
|
||||
if (key in properties) continue
|
||||
rebuilt[key] = entry
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(properties)) {
|
||||
const stripped = walk(child, source?.[key], [...path, key], secrets)
|
||||
if (stripped !== undefined) rebuilt[key] = stripped
|
||||
}
|
||||
return source === undefined && Object.keys(rebuilt).length === 0 ? value : rebuilt
|
||||
}
|
||||
case 'dict': {
|
||||
if (!isRecord(value)) return value
|
||||
const rebuilt: Record<string, unknown> = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const stripped = walk(node.inner, entry, [...path, key], secrets)
|
||||
if (stripped !== undefined) rebuilt[key] = stripped
|
||||
}
|
||||
return rebuilt
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return value
|
||||
return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets))
|
||||
}
|
||||
default:
|
||||
// TODO(settings-wire-redaction): Fail closed instead — a secret reachable
|
||||
// only through a union, intersection, or transform is returned verbatim
|
||||
// here, with nothing recording that it was missed.
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every `role('secret')` field a schema declares from a value. The
|
||||
* walker follows `object`, `dict`, and `array` containers; a secret must be
|
||||
* declared directly on a field reachable through those containers (a secret
|
||||
* buried inside a union branch or transform is not reachable and must not be
|
||||
* modeled that way). The input is never mutated.
|
||||
* @param schema - live schemastery schema describing the value.
|
||||
* @param value - the value to strip; `undefined` yields an empty record with
|
||||
* object-property secret slots still enumerated.
|
||||
* @returns the stripped detached value and the ordered secret positions.
|
||||
*/
|
||||
export function redactSecrets(schema: z<never>, value: unknown): RedactedValue {
|
||||
const secrets: RedactedSecret[] = []
|
||||
const stripped = walk(schema, value, [], secrets)
|
||||
return { value: stripped, secrets }
|
||||
}
|
||||
168
packages/settings/settings/tests/redact.spec.ts
Normal file
168
packages/settings/settings/tests/redact.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { redactSecrets, settingsNamespace } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
const Profile = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref'),
|
||||
baseURL: z.string(),
|
||||
})
|
||||
|
||||
const Adapter: z<object> = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
providers: z.dict(Profile),
|
||||
fallbacks: z.array(Profile),
|
||||
nested: z.object({
|
||||
token: z.string().role('secret'),
|
||||
}),
|
||||
})
|
||||
|
||||
describe('redactSecrets', () => {
|
||||
it('strips secrets from object, dict, and array containers and records each position', () => {
|
||||
const { value, secrets } = redactSecrets(Adapter as z<never>, {
|
||||
apiKey: 'top-secret',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-live', apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://x' },
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
},
|
||||
fallbacks: [{ apiKey: 'fb', baseURL: 'https://y' }],
|
||||
nested: {},
|
||||
})
|
||||
expect(value).toEqual({
|
||||
providers: {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://x' },
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
},
|
||||
fallbacks: [{ baseURL: 'https://y' }],
|
||||
nested: {},
|
||||
})
|
||||
expect(secrets).toEqual([
|
||||
{ path: ['apiKey'], set: true },
|
||||
{ path: ['providers', 'openai', 'apiKey'], set: true },
|
||||
{ path: ['providers', 'anthropic', 'apiKey'], set: false },
|
||||
{ path: ['fallbacks', '0', 'apiKey'], set: true },
|
||||
{ path: ['nested', 'token'], set: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('enumerates unset object-property slots without inventing containers', () => {
|
||||
const { value, secrets } = redactSecrets(Adapter as z<never>, undefined)
|
||||
expect(value).toBeUndefined()
|
||||
expect(secrets).toEqual([
|
||||
{ path: ['apiKey'], set: false },
|
||||
{ path: ['nested', 'token'], set: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('never mutates the input and preserves keys outside the schema', () => {
|
||||
const input = Object.freeze({
|
||||
apiKey: 'frozen',
|
||||
extra: Object.freeze({ keep: true }),
|
||||
})
|
||||
const { value } = redactSecrets(Adapter as z<never>, input)
|
||||
expect(input.apiKey).toBe('frozen')
|
||||
expect(value).toEqual({ extra: { keep: true }, nested: undefined } as never)
|
||||
expect((value as { extra: unknown }).extra).toEqual({ keep: true })
|
||||
})
|
||||
|
||||
it('passes malformed container values through untouched', () => {
|
||||
const { value, secrets } = redactSecrets(Adapter as z<never>, {
|
||||
providers: 'not-a-dict',
|
||||
fallbacks: 'not-an-array',
|
||||
})
|
||||
expect(value).toEqual({ providers: 'not-a-dict', fallbacks: 'not-an-array' })
|
||||
expect(secrets).toEqual([
|
||||
{ path: ['apiKey'], set: false },
|
||||
{ path: ['nested', 'token'], set: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a secret-role container as one opaque secret leaf', () => {
|
||||
const Weird = z.object({ blob: z.object({ inner: z.string() }).role('secret') })
|
||||
const { value, secrets } = redactSecrets(Weird as z<never>, { blob: { inner: 'x' } })
|
||||
expect(value).toEqual({})
|
||||
expect(secrets).toEqual([{ path: ['blob'], set: true }])
|
||||
})
|
||||
|
||||
it('drops a dict entry whose entire value is the secret', () => {
|
||||
const Tokens = z.object({ tokens: z.dict(z.string().role('secret')) })
|
||||
const { value, secrets } = redactSecrets(Tokens as z<never>, { tokens: { a: 'x', b: 'y' } })
|
||||
expect(value).toEqual({ tokens: {} })
|
||||
expect(secrets).toEqual([
|
||||
{ path: ['tokens', 'a'], set: true },
|
||||
{ path: ['tokens', 'b'], set: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('tolerates structural nodes missing their relation maps', () => {
|
||||
expect(redactSecrets({ type: 'dict' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] })
|
||||
expect(redactSecrets({ type: 'object' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] })
|
||||
expect(redactSecrets({ type: 'array' } as never, ['v'])).toEqual({ value: ['v'], secrets: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('describe() layers and redaction', () => {
|
||||
const NS = settingsNamespace('adapter')
|
||||
|
||||
async function boot(doc?: Record<string, unknown>) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc })
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('exposes detached base and user layers beside the resolved value', async () => {
|
||||
const ctx = await boot({ adapter: { baseURL: 'https://user' } })
|
||||
const base = { apiKey: 'entry-key', baseURL: 'https://base' }
|
||||
ctx.settings.register(NS, Profile, { base })
|
||||
const [descriptor] = ctx.settings.describe()
|
||||
expect(descriptor?.base).toEqual(base)
|
||||
expect(descriptor?.base).not.toBe(base)
|
||||
expect(descriptor?.user).toEqual({ baseURL: 'https://user' })
|
||||
expect(descriptor?.value).toEqual({ apiKey: 'entry-key', baseURL: 'https://user' })
|
||||
;(descriptor?.user as Record<string, unknown>).baseURL = 'mutated'
|
||||
expect(ctx.settings.describe()[0]?.user).toEqual({ baseURL: 'https://user' })
|
||||
expect(descriptor?.secrets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits the layers when neither a base nor a user section exists', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.settings.register(NS, Profile)
|
||||
const [descriptor] = ctx.settings.describe()
|
||||
expect(descriptor).not.toHaveProperty('base')
|
||||
expect(descriptor).not.toHaveProperty('user')
|
||||
})
|
||||
|
||||
it('describes a section that became malformed after registration as having no user layer', async () => {
|
||||
const ctx = await boot({ adapter: { baseURL: 'https://user' } })
|
||||
const provider = ctx.get('settings') as MemorySettings
|
||||
ctx.settings.register(NS, Profile, { base: { baseURL: 'https://base' } })
|
||||
provider.pushExternal({ adapter: 5 })
|
||||
const [descriptor] = ctx.settings.describe()
|
||||
expect(descriptor).not.toHaveProperty('user')
|
||||
// The malformed publish kept the last good resolved value.
|
||||
expect(descriptor?.value).toEqual({ baseURL: 'https://user' })
|
||||
})
|
||||
|
||||
it('redacts a descriptor that has neither base nor user layer', async () => {
|
||||
const ctx = await boot()
|
||||
ctx.settings.register(NS, Profile)
|
||||
const [descriptor] = ctx.settings.describe({ redactSecrets: true })
|
||||
expect(descriptor).not.toHaveProperty('base')
|
||||
expect(descriptor).not.toHaveProperty('user')
|
||||
expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: false }])
|
||||
})
|
||||
|
||||
it('redacts every layer and enumerates secret slots under redactSecrets', async () => {
|
||||
const ctx = await boot({ adapter: { apiKey: 'user-key', baseURL: 'https://user' } })
|
||||
ctx.settings.register(NS, Profile, { base: { apiKey: 'entry-key' } })
|
||||
const [descriptor] = ctx.settings.describe({ redactSecrets: true })
|
||||
expect(descriptor?.value).toEqual({ baseURL: 'https://user' })
|
||||
expect(descriptor?.base).toEqual({})
|
||||
expect(descriptor?.user).toEqual({ baseURL: 'https://user' })
|
||||
expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
const [verbatim] = ctx.settings.describe()
|
||||
expect(verbatim?.value).toEqual({ apiKey: 'user-key', baseURL: 'https://user' })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { Settings, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
/** A provider implementing only the three primitives: the seam owns init. */
|
||||
@@ -662,23 +662,351 @@ describe('installSettingsSection', () => {
|
||||
const ctx = new Context()
|
||||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
let changes = 0
|
||||
installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes += 1
|
||||
},
|
||||
})
|
||||
// No settings service mounted: nothing ran, the entry stays authoritative.
|
||||
expect(current()).toEqual({ theme: 'entry' })
|
||||
expect(changes).toBe(0)
|
||||
|
||||
const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } })
|
||||
await fiber
|
||||
await vi.waitFor(() => {
|
||||
expect(current()).toEqual({ theme: 'user' })
|
||||
})
|
||||
expect(changes).toBe(1)
|
||||
|
||||
await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' })
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toBe(2)
|
||||
})
|
||||
expect(current()).toEqual({ theme: 'live' })
|
||||
|
||||
await fiber.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toBe(3)
|
||||
})
|
||||
expect(current()).toEqual({ theme: 'entry' })
|
||||
})
|
||||
|
||||
it('stays silent when the consumer itself unloads', async () => {
|
||||
const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
|
||||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
const changes: string[] = []
|
||||
const consumer = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes.push(current().theme)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await consumer
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
|
||||
// The consumer's own teardown must not re-derive anything: an onChange
|
||||
// here would re-register routes and touch resources being released.
|
||||
await consumer.dispose()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
|
||||
it('stays silent for a stored change that lands while the consumer unloads', async () => {
|
||||
// The watcher outlives the start of teardown by the width of the unload,
|
||||
// so a document change arriving in that window reaches it. Notifying then
|
||||
// is exactly as harmful as notifying from the disposer.
|
||||
const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
|
||||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
const changes: string[] = []
|
||||
const consumer = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes.push(current().theme)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await consumer
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
|
||||
const unloading = consumer.dispose()
|
||||
provider.pushExternal({ 'helper-ns': { theme: 'racing' } })
|
||||
await unloading
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutate (path-addressed writes)', () => {
|
||||
interface KeyedConfig {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
const KeyedSchema: z<KeyedConfig> = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
baseURL: z.string(),
|
||||
reasoning: z.string(),
|
||||
})
|
||||
|
||||
const KEYED = settingsNamespace('keyed')
|
||||
const NESTED = settingsNamespace('workspace')
|
||||
|
||||
async function mounted(doc: Record<string, unknown>) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc })
|
||||
ctx.settings.register(KEYED, KeyedSchema)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('removes one field without touching a secret the caller never saw', async () => {
|
||||
// The data-loss shape this exists to prevent: a configuration UI reads the
|
||||
// REDACTED descriptor (no apiKey), the user resets baseURL, and the client
|
||||
// rebuilds the section from what it holds. A wholesale replace of that
|
||||
// rebuild deletes the stored literal key; a path unset cannot.
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://user', reasoning: 'high' } })
|
||||
const redacted = ctx.settings.describe({ redactSecrets: true }).find(d => d.ns === KEYED)!
|
||||
expect(redacted.user).toEqual({ baseURL: 'https://user', reasoning: 'high' })
|
||||
|
||||
await ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['baseURL'] }])
|
||||
|
||||
const raw = ctx.settings.describe().find(d => d.ns === KEYED)!
|
||||
expect(raw.user).toEqual({ apiKey: 'sk-stored', reasoning: 'high' })
|
||||
})
|
||||
|
||||
it('applies set and unset in one write, in order', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://old' } })
|
||||
await ctx.settings.mutate(KEYED, [
|
||||
{ op: 'set', path: ['baseURL'], value: 'https://new' },
|
||||
{ op: 'set', path: ['reasoning'], value: 'low' },
|
||||
{ op: 'unset', path: ['reasoning'] },
|
||||
])
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user)
|
||||
.toEqual({ apiKey: 'sk-stored', baseURL: 'https://new' })
|
||||
})
|
||||
|
||||
it('reads the section as it stands at the front of the queue, not at call time', async () => {
|
||||
// Two concurrent writers: the mutate is issued against the pre-update
|
||||
// section but must observe the update that ran before it.
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } })
|
||||
const first = ctx.settings.update(KEYED, { baseURL: 'https://first', reasoning: 'high' })
|
||||
const second = ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['reasoning'] }])
|
||||
await Promise.all([first, second])
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user)
|
||||
.toEqual({ apiKey: 'sk-stored', baseURL: 'https://first' })
|
||||
})
|
||||
|
||||
it('creates intermediate objects for a nested set and leaves an absent unset alone', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc: {} })
|
||||
ctx.settings.register(NESTED, NestedSchema)
|
||||
await ctx.settings.mutate(NESTED, [{ op: 'set', path: ['retry', 'attempts'], value: 5 }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user).toEqual({ retry: { attempts: 5 } })
|
||||
await ctx.settings.mutate(NESTED, [{ op: 'unset', path: ['missing', 'deep'] }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user).toEqual({ retry: { attempts: 5 } })
|
||||
})
|
||||
|
||||
it('edits one leaf of an existing nested object without replacing its siblings', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc: { workspace: { retry: { attempts: 5, delayMs: 250 } } } })
|
||||
ctx.settings.register(NESTED, NestedSchema)
|
||||
await ctx.settings.mutate(NESTED, [{ op: 'set', path: ['retry', 'delayMs'], value: 900 }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === NESTED)!.user)
|
||||
.toEqual({ retry: { attempts: 5, delayMs: 900 } })
|
||||
})
|
||||
|
||||
it('addresses the section itself through the empty path', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored', baseURL: 'https://user' } })
|
||||
await ctx.settings.mutate(KEYED, [{ op: 'set', path: [], value: { reasoning: 'low' } }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ reasoning: 'low' })
|
||||
await ctx.settings.mutate(KEYED, [{ op: 'unset', path: [] }])
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({})
|
||||
})
|
||||
|
||||
it('refuses a non-object at the section root, leaving the stored section alone', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } })
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: [], value: 'a whole section' }]))
|
||||
.rejects.toThrow(/setting the section root requires a plain object/)
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' })
|
||||
})
|
||||
|
||||
it('rejects ops that are not an array at all', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } })
|
||||
await expect(ctx.settings.mutate(KEYED, { op: 'unset', path: ['apiKey'] } as never))
|
||||
.rejects.toThrow(/must be an array of path ops/)
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' })
|
||||
})
|
||||
|
||||
it('rejects a malformed op before anything is queued', async () => {
|
||||
const ctx = await mounted({ keyed: { apiKey: 'sk-stored' } })
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'delete' } as never]))
|
||||
.rejects.toThrow(/must be \{op:'set'\|'unset', path\}/)
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'unset', path: ['a', 1] as never }]))
|
||||
.rejects.toThrow(/op paths must be arrays of strings/)
|
||||
expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' })
|
||||
})
|
||||
|
||||
it('rejects a value the JSON-shape boundary refuses', async () => {
|
||||
const ctx = await mounted({ keyed: {} })
|
||||
await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: ['baseURL'], value: new Date() }]))
|
||||
.rejects.toThrow(/must be JSON-shaped data/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revision and conflict detection', () => {
|
||||
const REV = settingsNamespace('rev')
|
||||
const RevSchema: z<{ a: string; b: string }> = z.object({
|
||||
a: z.string().default('base-a'),
|
||||
b: z.string(),
|
||||
})
|
||||
|
||||
async function mounted(doc: Record<string, unknown> = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(BareProvider, { doc })
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('refuses a write whose expected revision is stale, leaving the winner in place', async () => {
|
||||
// Two editors open the same namespace, both holding revision 0. The first
|
||||
// to land wins; the second must be told rather than overwrite it.
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
const opened = ctx.settings.describe().find(d => d.ns === REV)!.revision
|
||||
|
||||
await ctx.settings.update(REV, { b: 'from-tab-B' }, opened)
|
||||
await expect(ctx.settings.update(REV, { a: 'from-tab-A' }, opened))
|
||||
.rejects.toThrow(/changed since it was read \(expected revision 0, now 1\)/)
|
||||
expect(ctx.settings.describe().find(d => d.ns === REV)!.user).toEqual({ b: 'from-tab-B' })
|
||||
})
|
||||
|
||||
it('carries the machine code and both revisions on the refusal', async () => {
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
await ctx.settings.update(REV, { b: 'first' })
|
||||
const error = await ctx.settings.update(REV, { b: 'second' }, 0).catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(SettingsConflictError)
|
||||
expect(error).toMatchObject({ code: 'SETTINGS_CONFLICT', expected: 0, actual: 1 })
|
||||
})
|
||||
|
||||
it('accepts a write that carries no expectation at all', async () => {
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
await ctx.settings.update(REV, { b: 'one' })
|
||||
await ctx.settings.update(REV, { b: 'two' })
|
||||
expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(2)
|
||||
})
|
||||
|
||||
it('announces a raw change whose resolved value is unchanged', async () => {
|
||||
// Storing an override equal to the schema default leaves `value` alone but
|
||||
// changes what the document says: the field is now overridden, not
|
||||
// inherited, and another tab has to learn that.
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
const documents: Array<[string, number]> = []
|
||||
const resolved: string[] = []
|
||||
ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) })
|
||||
ctx.on('settings/updated', (ns) => { resolved.push(String(ns)) })
|
||||
|
||||
await ctx.settings.update(REV, { a: 'base-a' })
|
||||
|
||||
expect(documents).toEqual([['rev', 1]])
|
||||
expect(resolved).toEqual([])
|
||||
expect(ctx.settings.describe().find(d => d.ns === REV)!.user).toEqual({ a: 'base-a' })
|
||||
})
|
||||
|
||||
it('does not move the revision when a write stores an identical section', async () => {
|
||||
const ctx = await mounted({ rev: { b: 'same' } })
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
const documents: unknown[] = []
|
||||
ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) })
|
||||
await ctx.settings.update(REV, { b: 'same' })
|
||||
expect(documents).toEqual([])
|
||||
expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(0)
|
||||
})
|
||||
|
||||
it('moves the revision for an external edit the provider publishes', async () => {
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
const documents: Array<[string, number]> = []
|
||||
ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) })
|
||||
;(ctx.settings as unknown as { publish(doc: Record<string, unknown>): void })
|
||||
.publish({ rev: { b: 'edited on disk' } })
|
||||
expect(documents).toEqual([['rev', 1]])
|
||||
// An editor that opened before the external edit is now refused.
|
||||
await expect(ctx.settings.update(REV, { b: 'stale' }, 0)).rejects.toThrow(SettingsConflictError)
|
||||
})
|
||||
|
||||
it('moves the revision past a stored section that was not an object', async () => {
|
||||
// A hand-edited file can leave a namespace holding a scalar. The resolved
|
||||
// value keeps its last good reading, and the repair that follows still has
|
||||
// to announce itself — an open editor is reading a document it cannot see.
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
const settings = ctx.settings as unknown as { publish(doc: Record<string, unknown>): void }
|
||||
settings.publish({ rev: 'not a section' })
|
||||
const documents: Array<[string, number]> = []
|
||||
ctx.on('settings/document-updated', (ns, revision) => { documents.push([String(ns), revision]) })
|
||||
settings.publish({ rev: { b: 'repaired by hand' } })
|
||||
expect(documents).toEqual([['rev', 1]])
|
||||
})
|
||||
|
||||
it('contains a throwing document listener and keeps the rest of the fan-out running', async () => {
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
const seen: number[] = []
|
||||
ctx.on('settings/document-updated', () => { throw new Error('document listener boom') })
|
||||
ctx.on('settings/document-updated', (_ns, revision) => { seen.push(revision) })
|
||||
await ctx.settings.update(REV, { b: 'one' })
|
||||
await ctx.settings.update(REV, { b: 'two' })
|
||||
expect(seen).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('contains an async document listener rejection', async () => {
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
// Same shape as the `settings/updated` case above: the unknown return type
|
||||
// keeps an async listener legal at this file's typed surface while the
|
||||
// runtime value stays the rejected promise the containment guard handles.
|
||||
const boom = (): unknown => Promise.reject(new Error('async document boom'))
|
||||
ctx.on('settings/document-updated', boom)
|
||||
await ctx.settings.update(REV, { b: 'one' })
|
||||
expect(ctx.settings.describe().find(d => d.ns === REV)!.revision).toBe(1)
|
||||
// Give the rejected listener promise a microtask turn; containment means
|
||||
// vitest observes no unhandled rejection out of this test.
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
})
|
||||
|
||||
it('propagates an invariant-coded document listener failure instead of containing it', async () => {
|
||||
const ctx = await mounted()
|
||||
ctx.settings.register(REV, RevSchema)
|
||||
ctx.on('settings/document-updated', () => {
|
||||
throw Object.assign(new Error('forged revision'), { code: 'INVARIANT' })
|
||||
})
|
||||
expect(() => {
|
||||
(ctx.settings as unknown as { publish(doc: Record<string, unknown>): void })
|
||||
.publish({ rev: { b: 'edited on disk' } })
|
||||
}).toThrow(/forged revision/)
|
||||
})
|
||||
})
|
||||
|
||||
25
packages/settings/settings/tsdown.config.ts
Normal file
25
packages/settings/settings/tsdown.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user