feat(settings): detect stale writers with a revision, and announce raw changes
The remaining P1 from the #939 review, plus the P2 it shares a mechanism with. Nothing carried a version, so two tabs editing one namespace silently overwrote each other — reproduced as tab B's `reasoning` lost to tab A's older draft. The seam's per-namespace write queue orders writes; it cannot tell a fresh writer from one replaying a snapshot a predecessor superseded. Each namespace now carries a monotonic `revision` over its RAW section. A write may send `expectedRevision`, checked at the FRONT of the queue (not at call time, which would race the very predecessor it guards against); a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, carrying both revisions. The editor captures the revision it opened at and, on conflict, asks the user to reopen rather than replaying its snapshot. The same counter fixes the missing broadcast. `settings/updated` is gated on the resolved value — correct for consumers, wrong for configuration surfaces: storing an override equal to the composition base leaves the resolved value alone while changing what the document says (the field is now overridden, not inherited) and moving every open editor's revision. `settings/document-updated (ns, revision)` fires on any raw-section change, in-process or external, and `host/settings-changed` now rides it. That event also closes the stale model picker: editing a provider's `models` changes no route, so `llm/adapters-updated` never fired and an open picker kept serving the old catalog. A change to an exposed provider namespace now emits `host/models-changed` too — that namespace holds the catalog. Docs: both sides of the five touched README pairs, a type-equiv block for `SettingsPathOp`, and an Agent Note recording what the plane exposes and who may overwrite what. The deferred wire-redaction gaps (secrets behind union/intersection/transform, `.default(...)` in the served envelope, schema text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are recorded as TODO(settings-wire-redaction) and in Known Limitations rather than half-fixed.
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/README.md
|
||||
README.md: 293b56e53719bb35b2efb63af1f45dbc082b0738
|
||||
README.zh.md: 77807116253eaba7c48102dc4fc61a8a244f2931
|
||||
README.md: 1f1ce07722bfb035746ad5733f90ddabe2d1553b
|
||||
README.zh.md: 0d96a0deda3b9d8f6260a1f223eb86cb87781565
|
||||
|
||||
@@ -10,7 +10,9 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document
|
||||
- `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,4 +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).
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
- `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,4 +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 冲突按后写胜出解决)。
|
||||
|
||||
@@ -56,6 +56,11 @@ 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
|
||||
/**
|
||||
@@ -130,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,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
|
||||
@@ -300,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>
|
||||
}
|
||||
|
||||
@@ -383,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(() => {
|
||||
@@ -430,6 +484,7 @@ export abstract class Settings extends Service {
|
||||
ns: registration.ns,
|
||||
schema: registration.schema.toJSON(),
|
||||
value: registration.resolved,
|
||||
revision: registration.revision,
|
||||
...base === undefined ? {} : { base },
|
||||
...detachedUser === undefined ? {} : { user: detachedUser },
|
||||
applies: registration.applies,
|
||||
@@ -464,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,9 +533,11 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,8 +549,10 @@ export abstract class Settings extends Service {
|
||||
* 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[]): Promise<void> {
|
||||
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')) {
|
||||
@@ -501,11 +562,16 @@ export abstract class Settings extends Service {
|
||||
throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`)
|
||||
}
|
||||
}
|
||||
return this.write(ns, ops, 'mutate')
|
||||
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' | 'mutate'): Promise<void> {
|
||||
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) {
|
||||
@@ -544,6 +610,12 @@ export abstract class Settings extends Service {
|
||||
// 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(current, snapshot) as Record<string, unknown>
|
||||
: mode === 'replace'
|
||||
@@ -558,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')
|
||||
}
|
||||
})
|
||||
@@ -573,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
|
||||
@@ -583,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)
|
||||
}
|
||||
}
|
||||
@@ -604,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
|
||||
|
||||
@@ -84,6 +84,9 @@ function walk(node: SchemaNode | undefined, value: unknown, path: string[], secr
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
@@ -811,3 +811,87 @@ describe('mutate (path-addressed writes)', () => {
|
||||
.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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user