@deepseek-ai/dsh-settings 0.0.1-rc.1 → 0.0.1-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +2 -0
- package/README.zh.md +14 -12
- package/lib/index.js +2 -2
- package/lib/types/index.d.ts +6 -39
- package/lib/types/index.js +688 -0
- package/lib/types/invariant.js +41 -0
- package/lib/types/redact.js +78 -0
- package/lib/types/types.d.ts +46 -0
- package/lib/types/types.js +10 -0
- package/package.json +12 -7
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: b1c32c7f4cd05b05802400b33dfdd40f7ed20e61
|
|
6
|
+
README.zh.md: ebb5d2cebb347301c32ea3f2c3448dd33b57872e
|
package/README.md
CHANGED
|
@@ -28,6 +28,8 @@ Subclasses implement `writable`, `load()`, and `persist(ns, section)`, optionall
|
|
|
28
28
|
|
|
29
29
|
`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`.
|
|
30
30
|
|
|
31
|
+
Both declarations live in the client-safe `./types` subpath export, together with the `SettingsNamespace` and `SettingsUpdateSource` types their signatures name; the package root re-exports those types. A consumer outside the Host compilation face therefore reads the very signature the Host emits instead of restating it.
|
|
32
|
+
|
|
31
33
|
## Model Experience
|
|
32
34
|
|
|
33
35
|
Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
|
package/README.zh.md
CHANGED
|
@@ -2,42 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 中文
|
|
4
4
|
|
|
5
|
-
用户设置 Service Definition(`ctx.settings`)。一个提供方持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry
|
|
5
|
+
用户设置 Service Definition(`ctx.settings`)。一个提供方持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值:schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载提供方时消费方行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。
|
|
6
6
|
|
|
7
7
|
## 服务 API
|
|
8
8
|
|
|
9
9
|
- `documentPath` — 提供方拥有用户可编辑文件时,该字段是文件的绝对路径;非文件提供方保留 `undefined`。Host 配置适配器据此派生可用性,而浏览器协议只暴露一个布尔能力,绝不暴露文件系统目标。
|
|
10
10
|
- `prepareDocument()` — 让文档做好供原生编辑器打开的准备后返回该路径。基类实现返回 `documentPath`;文件提供方可先创建缺失的文档。
|
|
11
|
-
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose
|
|
12
|
-
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 封装、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets`
|
|
11
|
+
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose(资源释放)该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
|
|
12
|
+
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 封装、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` slot 列表(`{ path, set }`);每个协议接口都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。
|
|
13
13
|
- `get(ns)` — 解析值;未注册时为 `undefined`。
|
|
14
|
-
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 只能包含与 JSON 兼容的数据:Date、Map、BigInt
|
|
14
|
+
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 只能包含与 JSON 兼容的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前被拒绝,并给出以 `$` 为根的路径(YAML/JSON 存储在重载时会静默改变这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
|
|
15
15
|
- `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。
|
|
16
16
|
- `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。
|
|
17
|
-
- 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision
|
|
18
|
-
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)
|
|
19
|
-
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose
|
|
17
|
+
- 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先完成写入的写入方。写队列只保证写入的先后次序,它本身分辨不出持有新鲜快照的写入方与持有陈旧快照的写入方。
|
|
18
|
+
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`:同一回调的调用异步、逐次、按提交顺序执行(慢的旧调用绝不会晚于较新的调用生效),异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐监听器扇出,一个抛错的 listener 不会饿死其余 listener;异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
|
|
19
|
+
- 服务卸载先拒绝新写入与观察者调用的启动,再排干全部排队写入与已启动的观察者调用后才完成;registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不会提交,也不会通知任何人。
|
|
20
20
|
|
|
21
21
|
## 提供方约定
|
|
22
22
|
|
|
23
|
-
子类实现 `writable`、`load()`、`persist(ns, section)`,可选择为一个本地用户可编辑文件重写 `documentPath` 与 `prepareDocument()`,并通过受保护的 `publish(doc)`
|
|
23
|
+
子类实现 `writable`、`load()`、`persist(ns, section)`,可选择为一个本地用户可编辑文件重写 `documentPath` 与 `prepareDocument()`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类服务 init 在服务可注入前加载并发布一次文档;拥有自有 init(watcher、连接)的提供方会先通过 `yield* super[Service.init]()` 委托给基类。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
|
|
24
24
|
|
|
25
25
|
## 事件
|
|
26
26
|
|
|
27
27
|
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source` 为 `update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发——它面向消费方,而消费方只关心自己的值有没有变。
|
|
28
28
|
|
|
29
|
-
`settings/document-updated (ns, revision)` 在**原始**用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 `base` 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision
|
|
29
|
+
`settings/document-updated (ns, revision)` 在**原始**用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 `base` 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision。监听器的异常隔离方式与 `settings/updated` 相同。
|
|
30
|
+
|
|
31
|
+
两条声明都住在 client-safe 的 `./types` 子路径出口,与其签名点名的 `SettingsNamespace`、`SettingsUpdateSource` 类型同处一处;包根继续 re-export 这些类型。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
|
|
30
32
|
|
|
31
33
|
## 模型体验
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
间接生效:消费方插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费方自己的接口文档说明。
|
|
34
36
|
|
|
35
37
|
#### KV Cache 影响
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
无直接失效;把设置值纳入请求前缀的消费方负责该变更。
|
|
38
40
|
|
|
39
41
|
## 已知限制与暂缓事项
|
|
40
42
|
|
|
41
43
|
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;它尚未记录每个解析值由哪一层提供。
|
|
42
|
-
- **`redactSecrets` 并非一条可被证明的协议边界**:walker 只跟随 `object`/`dict`/`array`,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 会被**原样**返回,且 `secrets` 列表为空;而 `schema.toJSON()` 会把 secret 字段的 `.default(...)` 一并带给每个客户端。这两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。真正的答案是一个 fail-closed 的 `describeForWire()`——它拒绝自己无法证明安全的 schema
|
|
44
|
+
- **`redactSecrets` 并非一条可被证明的协议边界**:walker 只跟随 `object`/`dict`/`array`,因此只能经由 union、intersection 或 transform 抵达的 `role('secret')` 会被**原样**返回,且 `secrets` 列表为空;而 `schema.toJSON()` 会把 secret 字段的 `.default(...)` 一并带给每个客户端。这两种情况都不会被拒绝;机密无法经由被遍历的容器抵达的 schema,绝不可注册到暴露于协议的 namespace 上。真正的答案是一个 fail-closed 的 `describeForWire()`——它拒绝自己无法证明安全的 schema,并对序列化封装与错误文本做净化——此项暂缓。
|
|
43
45
|
- **跨进程并发由提供方定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按提供方行为收敛(本地文件提供方在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
|
package/lib/index.js
CHANGED
|
@@ -251,7 +251,7 @@ function deepFreeze(value) {
|
|
|
251
251
|
* the base class owns namespace registration, resolution, validation, change
|
|
252
252
|
* detection, and the `settings/updated` commit event.
|
|
253
253
|
*/
|
|
254
|
-
var
|
|
254
|
+
var SettingsProvider = class extends Service {
|
|
255
255
|
registrations = /* @__PURE__ */ new Map();
|
|
256
256
|
/** Latest published raw document; empty until the provider's first publish. */
|
|
257
257
|
document = {};
|
|
@@ -635,4 +635,4 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
|
635
635
|
});
|
|
636
636
|
}
|
|
637
637
|
//#endregion
|
|
638
|
-
export {
|
|
638
|
+
export { SettingsConflictError, SettingsProvider, SettingsProvider as default, deepEqualJson, installSettingsSection, redactSecrets, settingsNamespace };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Context, Service } from '@deepseek-ai/cordis';
|
|
9
9
|
import type z from '@deepseek-ai/schemastery';
|
|
10
|
-
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
11
10
|
import type { RedactedSecret } from './redact.ts';
|
|
11
|
+
import type { SettingsNamespace, SettingsUpdateSource } from './types.ts';
|
|
12
12
|
export { redactSecrets } from './redact.ts';
|
|
13
13
|
export type { RedactedSecret, RedactedValue } from './redact.ts';
|
|
14
|
-
|
|
15
|
-
export type SettingsNamespace = Branded<'SettingsNamespace'>;
|
|
14
|
+
export type { SettingsNamespace, SettingsUpdateSource } from './types.ts';
|
|
16
15
|
/**
|
|
17
16
|
* Brand a raw string as a {@link SettingsNamespace}.
|
|
18
17
|
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
|
@@ -21,8 +20,6 @@ export type SettingsNamespace = Branded<'SettingsNamespace'>;
|
|
|
21
20
|
export declare function settingsNamespace(value: string): SettingsNamespace;
|
|
22
21
|
/** When a namespace's changes take effect for its owner. */
|
|
23
22
|
export type SettingsApplies = 'live' | 'restart';
|
|
24
|
-
/** Origin of one committed settings change. */
|
|
25
|
-
export type SettingsUpdateSource = 'update' | 'provider';
|
|
26
23
|
/** Registration options beyond the namespace schema. */
|
|
27
24
|
export interface SettingsRegisterOptions<T> {
|
|
28
25
|
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
|
@@ -75,7 +72,7 @@ export interface SettingsDescriptor {
|
|
|
75
72
|
/** Schema-declared secret positions; present only under `redactSecrets`. */
|
|
76
73
|
secrets?: RedactedSecret[];
|
|
77
74
|
}
|
|
78
|
-
/** Options for {@link
|
|
75
|
+
/** Options for {@link SettingsProvider.describe}. */
|
|
79
76
|
export interface SettingsDescribeOptions {
|
|
80
77
|
/**
|
|
81
78
|
* Strip `role('secret')` fields from `value`/`base`/`user` and enumerate
|
|
@@ -114,37 +111,7 @@ export interface SettingsScope<T> {
|
|
|
114
111
|
}
|
|
115
112
|
declare module '@deepseek-ai/cordis' {
|
|
116
113
|
interface Context {
|
|
117
|
-
settings:
|
|
118
|
-
}
|
|
119
|
-
interface Events {
|
|
120
|
-
/**
|
|
121
|
-
* Committed change to one registered namespace's resolved value. Emitted
|
|
122
|
-
* after the provider persisted (for `update`) or published (`provider`)
|
|
123
|
-
* the change; never emitted when the resolved value is deep-equal.
|
|
124
|
-
* Listener failures are contained and logged — a sync throw and an async
|
|
125
|
-
* rejection alike — except `INVARIANT`-coded failures, which rethrow
|
|
126
|
-
* after every listener ran; that rethrow reaches the emitter only from
|
|
127
|
-
* synchronous listeners, so invariant checks on this event must not be
|
|
128
|
-
* async functions.
|
|
129
|
-
* @param ns - the namespace whose resolved value changed.
|
|
130
|
-
* @param next - the new resolved value.
|
|
131
|
-
* @param prev - the previous resolved value.
|
|
132
|
-
* @param source - whether the change entered through `update()` or the provider.
|
|
133
|
-
* @mode emit
|
|
134
|
-
*/
|
|
135
|
-
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void;
|
|
136
|
-
/**
|
|
137
|
-
* One registered namespace's RAW user section changed, whether or not the
|
|
138
|
-
* resolved value did. `settings/updated` is the consumer-facing event and
|
|
139
|
-
* stays deep-equal-gated; this one exists for configuration surfaces,
|
|
140
|
-
* which must learn that a field went from inherited to overridden (same
|
|
141
|
-
* resolved value, different meaning) and that their held revision is
|
|
142
|
-
* stale. Listener containment matches `settings/updated`.
|
|
143
|
-
* @param ns - the namespace whose stored section changed.
|
|
144
|
-
* @param revision - the namespace's new revision.
|
|
145
|
-
* @mode emit
|
|
146
|
-
*/
|
|
147
|
-
'settings/document-updated'(ns: SettingsNamespace, revision: number): void;
|
|
114
|
+
settings: SettingsProvider;
|
|
148
115
|
}
|
|
149
116
|
}
|
|
150
117
|
/**
|
|
@@ -197,7 +164,7 @@ export type SettingsPathOp = {
|
|
|
197
164
|
* the base class owns namespace registration, resolution, validation, change
|
|
198
165
|
* detection, and the `settings/updated` commit event.
|
|
199
166
|
*/
|
|
200
|
-
export declare abstract class
|
|
167
|
+
export declare abstract class SettingsProvider extends Service {
|
|
201
168
|
private readonly registrations;
|
|
202
169
|
/** Latest published raw document; empty until the provider's first publish. */
|
|
203
170
|
private document;
|
|
@@ -372,5 +339,5 @@ export interface SettingsSectionHooks<T> {
|
|
|
372
339
|
* @param hooks - source sink and change notification.
|
|
373
340
|
*/
|
|
374
341
|
export declare function installSettingsSection<T>(ctx: Context, ns: SettingsNamespace, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>): void;
|
|
375
|
-
export default
|
|
342
|
+
export default SettingsProvider;
|
|
376
343
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
|
|
3
|
+
* per-namespace sections; plugins register a namespace schema and read the
|
|
4
|
+
* resolved value, which layers schema defaults, the registrant's composition
|
|
5
|
+
* `base`, and the user document section, in that order.
|
|
6
|
+
* @module @deepseek-ai/dsh-settings
|
|
7
|
+
*/
|
|
8
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
9
|
+
import { redactSecrets } from "./redact.js";
|
|
10
|
+
export { redactSecrets } from "./redact.js";
|
|
11
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
12
|
+
/**
|
|
13
|
+
* Brand a raw string as a {@link SettingsNamespace}.
|
|
14
|
+
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
|
15
|
+
* @returns the branded namespace.
|
|
16
|
+
*/
|
|
17
|
+
export function settingsNamespace(value) {
|
|
18
|
+
if (!NAMESPACE_PATTERN.test(value)) {
|
|
19
|
+
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
25
|
+
* Service Definition's single change-detection predicate, exported so the invariant
|
|
26
|
+
* companion checks exactly the implementation's relation.
|
|
27
|
+
* @param a - one JSON-compatible value.
|
|
28
|
+
* @param b - the other JSON-compatible value.
|
|
29
|
+
* @returns whether the two values are structurally equal.
|
|
30
|
+
*/
|
|
31
|
+
export function deepEqualJson(a, b) {
|
|
32
|
+
if (a === b)
|
|
33
|
+
return true;
|
|
34
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
|
|
35
|
+
return false;
|
|
36
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
37
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
38
|
+
return false;
|
|
39
|
+
return a.every((entry, index) => deepEqualJson(entry, b[index]));
|
|
40
|
+
}
|
|
41
|
+
const left = a;
|
|
42
|
+
const right = b;
|
|
43
|
+
const keys = Object.keys(left);
|
|
44
|
+
if (keys.length !== Object.keys(right).length)
|
|
45
|
+
return false;
|
|
46
|
+
return keys.every(key => key in right && deepEqualJson(left[key], right[key]));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A write refused because the namespace moved since the caller read it. The
|
|
50
|
+
* Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
|
|
51
|
+
* from one holding a stale snapshot, which is what this reports.
|
|
52
|
+
*/
|
|
53
|
+
export class SettingsConflictError extends Error {
|
|
54
|
+
/** Stable machine code for wire layers mapping this to their own taxonomy. */
|
|
55
|
+
code = 'SETTINGS_CONFLICT';
|
|
56
|
+
/** The revision the write expected. */
|
|
57
|
+
expected;
|
|
58
|
+
/** The revision the namespace actually stands at. */
|
|
59
|
+
actual;
|
|
60
|
+
/**
|
|
61
|
+
* @param ns - the namespace whose write was refused.
|
|
62
|
+
* @param expected - the revision the caller sent.
|
|
63
|
+
* @param actual - the revision now stored.
|
|
64
|
+
*/
|
|
65
|
+
constructor(ns, expected, actual) {
|
|
66
|
+
super(`settings namespace "${ns}" changed since it was read (expected revision ${String(expected)}, now ${String(actual)})`);
|
|
67
|
+
this.name = 'SettingsConflictError';
|
|
68
|
+
this.expected = expected;
|
|
69
|
+
this.actual = actual;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
|
73
|
+
function isPlainObject(value) {
|
|
74
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
75
|
+
return false;
|
|
76
|
+
const proto = Object.getPrototypeOf(value);
|
|
77
|
+
return proto === Object.prototype || proto === null;
|
|
78
|
+
}
|
|
79
|
+
/** Apply one path op to a detached section, returning the next section. */
|
|
80
|
+
function applyPathOp(section, op) {
|
|
81
|
+
const [head, ...rest] = op.path;
|
|
82
|
+
// The empty path addresses the section itself.
|
|
83
|
+
if (head === undefined) {
|
|
84
|
+
if (op.op === 'unset')
|
|
85
|
+
return {};
|
|
86
|
+
if (!isPlainObject(op.value)) {
|
|
87
|
+
throw new TypeError('settings mutate: setting the section root requires a plain object');
|
|
88
|
+
}
|
|
89
|
+
return { ...op.value };
|
|
90
|
+
}
|
|
91
|
+
if (rest.length === 0) {
|
|
92
|
+
if (op.op === 'set')
|
|
93
|
+
return { ...section, [head]: op.value };
|
|
94
|
+
const { [head]: _removed, ...kept } = section;
|
|
95
|
+
return kept;
|
|
96
|
+
}
|
|
97
|
+
const child = section[head];
|
|
98
|
+
if (!isPlainObject(child)) {
|
|
99
|
+
// Unsetting through an absent path is already satisfied; setting through
|
|
100
|
+
// one creates the intermediate objects it needs.
|
|
101
|
+
if (op.op === 'unset')
|
|
102
|
+
return section;
|
|
103
|
+
return { ...section, [head]: applyPathOp({}, { ...op, path: rest }) };
|
|
104
|
+
}
|
|
105
|
+
return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) };
|
|
106
|
+
}
|
|
107
|
+
/** Human label for a value that lossless JSON cannot represent (numbers reject inline). */
|
|
108
|
+
function describeRejected(value) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return 'undefined';
|
|
111
|
+
if (typeof value === 'object' && value !== null) {
|
|
112
|
+
const proto = Object.getPrototypeOf(value);
|
|
113
|
+
const name = proto?.constructor?.name;
|
|
114
|
+
return name === undefined || name === 'Object' ? 'a non-plain object' : `a ${name}`;
|
|
115
|
+
}
|
|
116
|
+
return `a ${typeof value}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Detach and validate one write input in a single walk before persistence:
|
|
120
|
+
* only JSON data (plain objects, arrays, strings, finite numbers,
|
|
121
|
+
* booleans, `null`) may reach a provider document. `structuredClone` alone
|
|
122
|
+
* would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then
|
|
123
|
+
* silently distorts on the reload round-trip. `undefined` entries in objects
|
|
124
|
+
* are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
|
|
125
|
+
* an `undefined` array entry is rejected rather than coerced.
|
|
126
|
+
* @param root - plain-object write input (caller-checked).
|
|
127
|
+
* @param reject - builds the validation error from a value label and its `$`-rooted path.
|
|
128
|
+
* @returns the detached JSON-compatible clone.
|
|
129
|
+
*/
|
|
130
|
+
function cloneJsonShaped(root, reject) {
|
|
131
|
+
const visiting = new WeakSet();
|
|
132
|
+
const clone = (value, path) => {
|
|
133
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
134
|
+
return value;
|
|
135
|
+
if (typeof value === 'number') {
|
|
136
|
+
if (!Number.isFinite(value))
|
|
137
|
+
throw reject('a non-finite number', path);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
if (visiting.has(value))
|
|
142
|
+
throw reject('a circular reference', path);
|
|
143
|
+
visiting.add(value);
|
|
144
|
+
const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`));
|
|
145
|
+
// Un-mark on exit so one object referenced twice without a cycle passes.
|
|
146
|
+
visiting.delete(value);
|
|
147
|
+
return entries;
|
|
148
|
+
}
|
|
149
|
+
if (isPlainObject(value)) {
|
|
150
|
+
if (visiting.has(value))
|
|
151
|
+
throw reject('a circular reference', path);
|
|
152
|
+
visiting.add(value);
|
|
153
|
+
// TODO(settings-json-properties): Use property-safe construction here and
|
|
154
|
+
// in mergeLayers so valid JSON keys such as "__proto__" remain own data.
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
157
|
+
if (entry === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
out[key] = clone(entry, `${path}.${key}`);
|
|
160
|
+
}
|
|
161
|
+
visiting.delete(value);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
throw reject(describeRejected(value), path);
|
|
165
|
+
};
|
|
166
|
+
return clone(root, '$');
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Layer `over` onto `under`: plain objects merge recursively, every other
|
|
170
|
+
* value (arrays included) replaces the lower layer wholesale. `over` never
|
|
171
|
+
* carries `undefined` entries — sections come from parsed documents and write
|
|
172
|
+
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
|
173
|
+
* cannot erase lower keys.
|
|
174
|
+
*/
|
|
175
|
+
function mergeLayers(under, over) {
|
|
176
|
+
if (over === undefined)
|
|
177
|
+
return under;
|
|
178
|
+
if (!isPlainObject(under) || !isPlainObject(over))
|
|
179
|
+
return over;
|
|
180
|
+
const merged = { ...under };
|
|
181
|
+
for (const [key, value] of Object.entries(over)) {
|
|
182
|
+
merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
|
|
183
|
+
}
|
|
184
|
+
return merged;
|
|
185
|
+
}
|
|
186
|
+
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
|
187
|
+
function deepFreeze(value) {
|
|
188
|
+
if (typeof value !== 'object' || value === null || Object.isFrozen(value))
|
|
189
|
+
return value;
|
|
190
|
+
for (const entry of Object.values(value))
|
|
191
|
+
deepFreeze(entry);
|
|
192
|
+
return Object.freeze(value);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Abstract settings service. Providers implement raw-document storage
|
|
196
|
+
* (`load`/`persist`) and push external changes through {@link Settings.publish};
|
|
197
|
+
* the base class owns namespace registration, resolution, validation, change
|
|
198
|
+
* detection, and the `settings/updated` commit event.
|
|
199
|
+
*/
|
|
200
|
+
export class SettingsProvider extends Service {
|
|
201
|
+
registrations = new Map();
|
|
202
|
+
/** Latest published raw document; empty until the provider's first publish. */
|
|
203
|
+
document = {};
|
|
204
|
+
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
|
|
205
|
+
writeQueues = new Map();
|
|
206
|
+
/** In-flight watcher invocation segments, drained by the dispose teardown. */
|
|
207
|
+
pendingTails = new Set();
|
|
208
|
+
/** Set at service dispose: refuse new writes while queued ones drain. */
|
|
209
|
+
stopped = false;
|
|
210
|
+
/** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
|
|
211
|
+
isStopped() {
|
|
212
|
+
return this.stopped;
|
|
213
|
+
}
|
|
214
|
+
constructor(ctx) {
|
|
215
|
+
super(ctx, 'settings');
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Load the provider's document once and publish it before the service
|
|
219
|
+
* becomes injectable, and register the write-drain teardown. Providers with
|
|
220
|
+
* their own init (watchers, connections) delegate here first via
|
|
221
|
+
* `yield* super[Service.init]()`; their disposers then run before the drain.
|
|
222
|
+
*/
|
|
223
|
+
async *[Service.init]() {
|
|
224
|
+
yield async () => {
|
|
225
|
+
// Teardown: refuse new writes and new watcher starts, then wait until
|
|
226
|
+
// every queued write chain and every started watcher invocation settles
|
|
227
|
+
// so disposal completes only once storage and observers are quiescent.
|
|
228
|
+
// Invocations queued but not yet started skip via the stopped check.
|
|
229
|
+
this.stopped = true;
|
|
230
|
+
await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]);
|
|
231
|
+
};
|
|
232
|
+
this.publish(await this.load());
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Absolute path of the provider's user-editable document, when its storage
|
|
236
|
+
* is one local file. Configuration surfaces use this only as availability
|
|
237
|
+
* metadata; the guarded open operation resolves the path again Host-side.
|
|
238
|
+
* Non-file providers leave it undefined and expose no open-document affordance.
|
|
239
|
+
* @returns the absolute local document path, or undefined for non-file storage.
|
|
240
|
+
*/
|
|
241
|
+
get documentPath() {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Prepare the provider's user-editable document for a native editor. File
|
|
246
|
+
* providers may materialize an absent document before returning its path;
|
|
247
|
+
* non-file providers return undefined.
|
|
248
|
+
* @returns the absolute local document path, or undefined for non-file storage.
|
|
249
|
+
*/
|
|
250
|
+
prepareDocument() {
|
|
251
|
+
return Promise.resolve(this.documentPath);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Register a namespace schema and receive its owner scope. The registration
|
|
255
|
+
* is an effect on the calling plugin's fiber: disposing that fiber removes
|
|
256
|
+
* the namespace and its observers. An invalid stored section fails the
|
|
257
|
+
* registration itself — the earliest point where the schema can judge it.
|
|
258
|
+
* @param ns - unique namespace; duplicate registration fails loud.
|
|
259
|
+
* @param schema - schemastery schema resolving this namespace's value.
|
|
260
|
+
* @param options - composition `base` layer and effect timing.
|
|
261
|
+
* @returns the owner scope for reads, observation, and updates.
|
|
262
|
+
*/
|
|
263
|
+
register(ns, schema, options) {
|
|
264
|
+
if (this.registrations.has(ns)) {
|
|
265
|
+
throw new Error(`settings namespace "${ns}" is already registered`);
|
|
266
|
+
}
|
|
267
|
+
const registration = {
|
|
268
|
+
ns,
|
|
269
|
+
schema: schema,
|
|
270
|
+
base: options?.base,
|
|
271
|
+
applies: options?.applies ?? 'live',
|
|
272
|
+
...options?.validate === undefined
|
|
273
|
+
? {}
|
|
274
|
+
: { validate: options.validate },
|
|
275
|
+
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
|
|
276
|
+
revision: 0,
|
|
277
|
+
watchers: new Set(),
|
|
278
|
+
};
|
|
279
|
+
this.ctx.effect(() => {
|
|
280
|
+
this.registrations.set(ns, registration);
|
|
281
|
+
// TODO(settings-registration-quiescence): Deactivate every watcher and await
|
|
282
|
+
// its tail on disposal so callbacks cannot outlive the registrant fiber.
|
|
283
|
+
return () => this.registrations.delete(ns);
|
|
284
|
+
}, `settings.register(${JSON.stringify(String(ns))})`);
|
|
285
|
+
return {
|
|
286
|
+
get: () => registration.resolved,
|
|
287
|
+
watch: (callback) => {
|
|
288
|
+
const watcher = { callback: callback, tail: Promise.resolve(), active: true };
|
|
289
|
+
registration.watchers.add(watcher);
|
|
290
|
+
return () => {
|
|
291
|
+
watcher.active = false;
|
|
292
|
+
registration.watchers.delete(watcher);
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
update: patch => this.update(ns, patch),
|
|
296
|
+
replace: section => this.replace(ns, section),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Describe every registered namespace for configuration surfaces, including
|
|
301
|
+
* the composition `base` and raw user layers so a form can mark which fields
|
|
302
|
+
* the user overrode (presence in `user`) and what a reset returns to.
|
|
303
|
+
* @param options - redaction switch; wire surfaces must redact.
|
|
304
|
+
* @returns one descriptor per registered namespace, in registration order.
|
|
305
|
+
*/
|
|
306
|
+
describe(options) {
|
|
307
|
+
return [...this.registrations.values()].map((registration) => {
|
|
308
|
+
let user;
|
|
309
|
+
try {
|
|
310
|
+
user = this.section(registration.ns);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// A malformed stored section already warned at publish and kept the
|
|
314
|
+
// last good resolved value; only that malformed shape can throw here,
|
|
315
|
+
// and describing it as "no user layer" keeps this read total.
|
|
316
|
+
user = undefined;
|
|
317
|
+
}
|
|
318
|
+
const base = registration.base === undefined ? undefined : structuredClone(registration.base);
|
|
319
|
+
const detachedUser = user === undefined ? undefined : structuredClone(user);
|
|
320
|
+
const descriptor = {
|
|
321
|
+
ns: registration.ns,
|
|
322
|
+
schema: registration.schema.toJSON(),
|
|
323
|
+
value: registration.resolved,
|
|
324
|
+
revision: registration.revision,
|
|
325
|
+
...base === undefined ? {} : { base },
|
|
326
|
+
...detachedUser === undefined ? {} : { user: detachedUser },
|
|
327
|
+
applies: registration.applies,
|
|
328
|
+
};
|
|
329
|
+
if (options?.redactSecrets !== true)
|
|
330
|
+
return descriptor;
|
|
331
|
+
const schema = registration.schema;
|
|
332
|
+
const redacted = redactSecrets(schema, registration.resolved);
|
|
333
|
+
return {
|
|
334
|
+
...descriptor,
|
|
335
|
+
value: redacted.value,
|
|
336
|
+
...base === undefined ? {} : { base: redactSecrets(schema, base).value },
|
|
337
|
+
...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value },
|
|
338
|
+
secrets: redacted.secrets,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Read one registered namespace's resolved value.
|
|
344
|
+
* @param ns - the namespace to read.
|
|
345
|
+
* @returns the resolved value, or `undefined` while unregistered.
|
|
346
|
+
*/
|
|
347
|
+
get(ns) {
|
|
348
|
+
return this.registrations.get(ns)?.resolved;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Merge a patch into one registered namespace's user layer, validate the
|
|
352
|
+
* resolved candidate, persist through the provider, then commit and emit.
|
|
353
|
+
* A validation failure rejects before anything is persisted. Writes to one
|
|
354
|
+
* namespace are serialized: concurrent updates apply in call order, each
|
|
355
|
+
* merging over the previous write's committed section.
|
|
356
|
+
* @param ns - the registered namespace to update.
|
|
357
|
+
* @param patch - plain-object patch over the user section.
|
|
358
|
+
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
359
|
+
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
360
|
+
*/
|
|
361
|
+
async update(ns, patch, expectedRevision) {
|
|
362
|
+
return this.write(ns, patch, 'merge', expectedRevision);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Replace one registered namespace's user section wholesale, validate,
|
|
366
|
+
* persist, then commit and emit. Keys absent from `section` fall back to the
|
|
367
|
+
* composition `base` and schema defaults — this is the removal/reset path a
|
|
368
|
+
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
|
369
|
+
* @param ns - the registered namespace to replace.
|
|
370
|
+
* @param section - the complete next user section.
|
|
371
|
+
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
372
|
+
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
373
|
+
*/
|
|
374
|
+
async replace(ns, section, expectedRevision) {
|
|
375
|
+
return this.write(ns, section, 'replace', expectedRevision);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Apply path-addressed edits to one registered namespace's user section,
|
|
379
|
+
* validate, persist, then commit and emit. The ops are applied to the
|
|
380
|
+
* section as it stands when the write reaches the front of the queue, so a
|
|
381
|
+
* caller never has to restate fields it did not touch — and, crucially,
|
|
382
|
+
* cannot delete fields it never saw. This is the write path for any caller
|
|
383
|
+
* holding a redacted view; `replace` remains the wholesale reset.
|
|
384
|
+
* @param ns - the registered namespace to edit.
|
|
385
|
+
* @param ops - ordered path edits; later ops observe earlier ones.
|
|
386
|
+
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
387
|
+
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
388
|
+
*/
|
|
389
|
+
async mutate(ns, ops, expectedRevision) {
|
|
390
|
+
if (!Array.isArray(ops))
|
|
391
|
+
throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`);
|
|
392
|
+
for (const op of ops) {
|
|
393
|
+
if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
|
|
394
|
+
throw new TypeError(`settings mutate for "${ns}" ops must be {op:'set'|'unset', path}`);
|
|
395
|
+
}
|
|
396
|
+
if (!Array.isArray(op['path']) || op['path'].some(part => typeof part !== 'string')) {
|
|
397
|
+
throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return this.write(ns, ops, 'mutate', expectedRevision);
|
|
401
|
+
}
|
|
402
|
+
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
|
403
|
+
write(ns, input, mode, expectedRevision) {
|
|
404
|
+
const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate';
|
|
405
|
+
const registration = this.registrations.get(ns);
|
|
406
|
+
if (registration === undefined) {
|
|
407
|
+
throw new Error(`settings namespace "${ns}" is not registered`);
|
|
408
|
+
}
|
|
409
|
+
if (this.isStopped()) {
|
|
410
|
+
throw new Error(`settings service is disposed: "${ns}" cannot be written`);
|
|
411
|
+
}
|
|
412
|
+
if (!this.writable) {
|
|
413
|
+
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`);
|
|
414
|
+
}
|
|
415
|
+
// A mutate's ops array is wrapped so one JSON-shape walk covers both
|
|
416
|
+
// shapes; merge/replace carry the section itself.
|
|
417
|
+
let payload;
|
|
418
|
+
if (mode === 'mutate') {
|
|
419
|
+
payload = { ops: input };
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
if (!isPlainObject(input))
|
|
423
|
+
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`);
|
|
424
|
+
payload = input;
|
|
425
|
+
}
|
|
426
|
+
// Snapshot at call time: the queue must never read a caller-owned object
|
|
427
|
+
// the caller may keep mutating while the write waits its turn. The same
|
|
428
|
+
// walk rejects values that JSON cannot preserve (see cloneJsonShaped).
|
|
429
|
+
const snapshot = cloneJsonShaped(payload, (label, path) => new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`));
|
|
430
|
+
const previous = this.writeQueues.get(ns) ?? Promise.resolve();
|
|
431
|
+
// Chain past a failed predecessor: one rejected write must not poison the
|
|
432
|
+
// namespace queue for every later caller.
|
|
433
|
+
const run = previous.catch(() => undefined).then(async () => {
|
|
434
|
+
if (this.isStopped()) {
|
|
435
|
+
throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`);
|
|
436
|
+
}
|
|
437
|
+
if (this.registrations.get(ns) !== registration) {
|
|
438
|
+
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`);
|
|
439
|
+
}
|
|
440
|
+
// Every mode derives from the section as it stands NOW, at the front of
|
|
441
|
+
// the queue — never from whatever the caller last saw.
|
|
442
|
+
const current = this.section(ns) ?? {};
|
|
443
|
+
// The revision check belongs HERE, not at call time: the queue orders
|
|
444
|
+
// writes but cannot tell a fresh writer from one holding a snapshot
|
|
445
|
+
// that a predecessor already superseded.
|
|
446
|
+
if (expectedRevision !== undefined && expectedRevision !== registration.revision) {
|
|
447
|
+
throw new SettingsConflictError(ns, expectedRevision, registration.revision);
|
|
448
|
+
}
|
|
449
|
+
const section = mode === 'merge'
|
|
450
|
+
? mergeLayers(current, snapshot)
|
|
451
|
+
: mode === 'replace'
|
|
452
|
+
? snapshot
|
|
453
|
+
: snapshot['ops'].reduce(applyPathOp, current);
|
|
454
|
+
const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate));
|
|
455
|
+
await this.persist(ns, section);
|
|
456
|
+
// The write reached storage either way; the cache must say so. Commit
|
|
457
|
+
// only when this registration is still the namespace owner — a fiber
|
|
458
|
+
// disposed (or replaced) mid-persist must not receive the notification.
|
|
459
|
+
this.document[ns] = section;
|
|
460
|
+
// TODO(settings-replacement-resync): Re-resolve any replacement registration
|
|
461
|
+
// from this persisted section so an old in-flight write cannot leave it stale.
|
|
462
|
+
if (this.registrations.get(ns) === registration && !this.isStopped()) {
|
|
463
|
+
this.bumpRevision(registration, current, section);
|
|
464
|
+
this.commit(registration, next, 'update');
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
this.writeQueues.set(ns, run);
|
|
468
|
+
return run;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Provider hook: commit a complete raw document observed in storage. Each
|
|
472
|
+
* registered namespace re-resolves; an invalid section keeps that
|
|
473
|
+
* namespace's last good value and warns, other namespaces still commit.
|
|
474
|
+
* @param doc - the detached raw document (unregistered sections preserved).
|
|
475
|
+
* @param source - change origin; defaults to `provider`.
|
|
476
|
+
*/
|
|
477
|
+
publish(doc, source = 'provider') {
|
|
478
|
+
// Read every raw section BEFORE swapping the document, so the revision
|
|
479
|
+
// bump below compares what was stored with what now is — an external edit
|
|
480
|
+
// moves the revision exactly like an in-process write.
|
|
481
|
+
const before = new Map();
|
|
482
|
+
for (const registration of this.registrations.values()) {
|
|
483
|
+
try {
|
|
484
|
+
before.set(registration.ns, this.section(registration.ns));
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// A malformed stored section is not a readable "before"; treating it
|
|
488
|
+
// as absent still bumps against any well-formed replacement.
|
|
489
|
+
before.set(registration.ns, undefined);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
this.document = doc;
|
|
493
|
+
for (const registration of this.registrations.values()) {
|
|
494
|
+
let next;
|
|
495
|
+
try {
|
|
496
|
+
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate));
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns);
|
|
500
|
+
this.ctx.logger.warn(error);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns));
|
|
504
|
+
this.commit(registration, next, source);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/** Read one namespace's raw user section, rejecting non-object sections. */
|
|
508
|
+
section(ns) {
|
|
509
|
+
const section = this.document[ns];
|
|
510
|
+
if (section === undefined)
|
|
511
|
+
return undefined;
|
|
512
|
+
if (!isPlainObject(section)) {
|
|
513
|
+
throw new TypeError(`settings section "${ns}" must be an object of keys`);
|
|
514
|
+
}
|
|
515
|
+
return section;
|
|
516
|
+
}
|
|
517
|
+
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
|
|
518
|
+
resolve(schema, base, section, validate) {
|
|
519
|
+
// The merged candidate is untyped by construction; the schema call is the
|
|
520
|
+
// runtime validation that admits it into T.
|
|
521
|
+
const value = schema(mergeLayers(base, section));
|
|
522
|
+
// The owner's own check runs on the admitted value, so it sees defaults
|
|
523
|
+
// and the composition base exactly as the owner will.
|
|
524
|
+
validate?.(value);
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Advance a namespace's revision when its RAW section changed, and announce
|
|
529
|
+
* it. Deliberately independent of {@link commit}'s resolved-value equality:
|
|
530
|
+
* storing an override equal to the composition base leaves the resolved
|
|
531
|
+
* value alone but changes what the document says, which is exactly what a
|
|
532
|
+
* configuration surface must re-read.
|
|
533
|
+
*/
|
|
534
|
+
bumpRevision(registration, before, after) {
|
|
535
|
+
if (deepEqualJson(before, after))
|
|
536
|
+
return;
|
|
537
|
+
registration.revision += 1;
|
|
538
|
+
this.emitDocumentUpdated(registration.ns, registration.revision);
|
|
539
|
+
}
|
|
540
|
+
/** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
|
|
541
|
+
emitDocumentUpdated(ns, revision) {
|
|
542
|
+
let invariantFailure;
|
|
543
|
+
const args = ['settings/document-updated', ns, revision];
|
|
544
|
+
for (const listener of this.ctx.events.dispatch('emit', args)) {
|
|
545
|
+
try {
|
|
546
|
+
const returned = listener(ns, revision);
|
|
547
|
+
if (returned != null && typeof returned.then === 'function') {
|
|
548
|
+
void Promise.resolve(returned).then(undefined, (error) => {
|
|
549
|
+
this.warnListenerFailure(ns, error);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error?.code === 'INVARIANT') {
|
|
555
|
+
invariantFailure ??= error;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
this.warnListenerFailure(ns, error);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (invariantFailure !== undefined)
|
|
562
|
+
throw invariantFailure;
|
|
563
|
+
}
|
|
564
|
+
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
|
|
565
|
+
commit(registration, next, source) {
|
|
566
|
+
const prev = registration.resolved;
|
|
567
|
+
if (deepEqualJson(next, prev))
|
|
568
|
+
return;
|
|
569
|
+
registration.resolved = next;
|
|
570
|
+
for (const watcher of [...registration.watchers]) {
|
|
571
|
+
// Serialize per watcher: invocations of one callback run one at a time
|
|
572
|
+
// in commit order, so a slow stale invocation can never apply after a
|
|
573
|
+
// newer one. Sync throws and async rejections land in the same handler.
|
|
574
|
+
// The activity check runs when the queued invocation would start, so a
|
|
575
|
+
// disposer (or service stop) that ran while it waited prevents the
|
|
576
|
+
// start entirely; started invocations drain at service dispose.
|
|
577
|
+
const segment = watcher.tail
|
|
578
|
+
.then(() => {
|
|
579
|
+
if (!watcher.active || this.isStopped())
|
|
580
|
+
return;
|
|
581
|
+
return watcher.callback(next, prev);
|
|
582
|
+
})
|
|
583
|
+
.then(() => undefined, (error) => {
|
|
584
|
+
this.warnWatcherFailure(registration.ns, error);
|
|
585
|
+
});
|
|
586
|
+
watcher.tail = segment;
|
|
587
|
+
this.pendingTails.add(segment);
|
|
588
|
+
void segment.then(() => this.pendingTails.delete(segment));
|
|
589
|
+
}
|
|
590
|
+
// Fan the event out one listener at a time (the plain emit stops at the
|
|
591
|
+
// first throwing listener, starving the rest). Invariant violations are
|
|
592
|
+
// harness-fatal by design and rethrow after every listener ran; any other
|
|
593
|
+
// failure is contained so one broken observer cannot wedge the commit
|
|
594
|
+
// path (and, through it, a provider's reload loop).
|
|
595
|
+
let invariantFailure;
|
|
596
|
+
const args = ['settings/updated', registration.ns, next, prev, source];
|
|
597
|
+
for (const listener of this.ctx.events.dispatch('emit', args)) {
|
|
598
|
+
try {
|
|
599
|
+
const returned = listener(registration.ns, next, prev, source);
|
|
600
|
+
if (returned != null && typeof returned.then === 'function') {
|
|
601
|
+
// An emit listener may still be an async function; its rejection
|
|
602
|
+
// cannot reach the synchronous INVARIANT rethrow below, so it is
|
|
603
|
+
// contained here instead of becoming an unhandled rejection.
|
|
604
|
+
void Promise.resolve(returned).then(undefined, (error) => {
|
|
605
|
+
this.warnListenerFailure(registration.ns, error);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (error?.code === 'INVARIANT') {
|
|
611
|
+
invariantFailure ??= error;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
this.warnListenerFailure(registration.ns, error);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (invariantFailure !== undefined)
|
|
618
|
+
throw invariantFailure;
|
|
619
|
+
}
|
|
620
|
+
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
|
621
|
+
warnWatcherFailure(ns, error) {
|
|
622
|
+
this.ctx.logger.warn('settings: watcher for "%s" failed', ns);
|
|
623
|
+
this.ctx.logger.warn(error);
|
|
624
|
+
}
|
|
625
|
+
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
626
|
+
warnListenerFailure(ns, error) {
|
|
627
|
+
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns);
|
|
628
|
+
this.ctx.logger.warn(error);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Value mirror of the `FiberState` members {@link isUnloading} compares
|
|
633
|
+
* against: a const enum has no runtime object to import, and the value is
|
|
634
|
+
* needed at runtime (same rationale as the CLI boot driver's mirror).
|
|
635
|
+
*/
|
|
636
|
+
const FIBER_DISPOSED = 4;
|
|
637
|
+
const FIBER_UNLOADING = 5;
|
|
638
|
+
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
|
|
639
|
+
function isUnloading(ctx) {
|
|
640
|
+
const state = ctx.fiber.state;
|
|
641
|
+
return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Install the canonical optional-settings consumer wiring: while a settings
|
|
645
|
+
* service exists, register `ns` with the consumer's composition entry as the
|
|
646
|
+
* `base` layer and point the source thunk at the resolved scope; when the
|
|
647
|
+
* service goes away (disposal, provider reload), fall back to the entry so
|
|
648
|
+
* the consumer keeps working exactly as composed. The registration rides the
|
|
649
|
+
* scoped fiber, so no settings service ever mounted means none of this runs.
|
|
650
|
+
* @param ctx - consumer plugin context owning the wiring.
|
|
651
|
+
* @param ns - the consumer-owned settings namespace.
|
|
652
|
+
* @param schema - schema resolving the namespace (typically the plugin Config).
|
|
653
|
+
* @param entry - the consumer's composition entry config, used as `base`.
|
|
654
|
+
* @param hooks - source sink and change notification.
|
|
655
|
+
*/
|
|
656
|
+
export function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
657
|
+
ctx.inject(['settings'], (sctx) => {
|
|
658
|
+
const scope = sctx.settings.register(ns, schema, {
|
|
659
|
+
base: entry,
|
|
660
|
+
...hooks.validate === undefined ? {} : { validate: hooks.validate },
|
|
661
|
+
});
|
|
662
|
+
hooks.setSource(() => scope.get());
|
|
663
|
+
sctx.effect(() => () => {
|
|
664
|
+
// This disposer runs for two different reasons. A settings provider
|
|
665
|
+
// detaching leaves the consumer running, so it must fall back to its
|
|
666
|
+
// composition entry and re-judge what it derived. The consumer's own
|
|
667
|
+
// unload runs it too — and there `onChange` would re-register routes
|
|
668
|
+
// and touch resources the teardown is releasing, so the fallback is
|
|
669
|
+
// pointless and the notification actively harmful.
|
|
670
|
+
if (isUnloading(ctx))
|
|
671
|
+
return;
|
|
672
|
+
hooks.setSource(() => entry);
|
|
673
|
+
hooks.onChange();
|
|
674
|
+
});
|
|
675
|
+
hooks.onChange();
|
|
676
|
+
scope.watch(() => {
|
|
677
|
+
// A stored change landing while the consumer unloads reaches the watcher
|
|
678
|
+
// before the registration is released, and `onChange` is exactly as
|
|
679
|
+
// harmful here as in the disposer above: it re-registers routes against
|
|
680
|
+
// a fiber whose resources are being let go.
|
|
681
|
+
if (isUnloading(ctx))
|
|
682
|
+
return;
|
|
683
|
+
hooks.onChange();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
export default SettingsProvider;
|
|
688
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
|
|
3
|
+
* @module @deepseek-ai/dsh-settings/invariant
|
|
4
|
+
*/
|
|
5
|
+
import { deepEqualJson } from "./index.js";
|
|
6
|
+
const PACKAGE_NAME = '@deepseek-ai/dsh-settings';
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
export const name = 'settings-invariant';
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
export const inject = ['invariants'];
|
|
11
|
+
/**
|
|
12
|
+
* Install the commit-event contract: `settings/updated` fires only for a
|
|
13
|
+
* currently registered namespace, only when the resolved value changed, and
|
|
14
|
+
* only with the service's authoritative resolved value — all judged with the
|
|
15
|
+
* seam's own equality predicate.
|
|
16
|
+
*/
|
|
17
|
+
const install = (ctx, fail) => {
|
|
18
|
+
ctx.on('settings/updated', (ns, next, prev) => {
|
|
19
|
+
const settings = ctx.get('settings');
|
|
20
|
+
if (settings === undefined) {
|
|
21
|
+
fail(`settings/updated for "${ns}" emitted without a live settings service`);
|
|
22
|
+
}
|
|
23
|
+
const current = settings.get(ns);
|
|
24
|
+
if (current === undefined) {
|
|
25
|
+
fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`);
|
|
26
|
+
}
|
|
27
|
+
if (!deepEqualJson(current, next)) {
|
|
28
|
+
fail(`settings/updated for "${ns}" does not match the authoritative resolved value`);
|
|
29
|
+
}
|
|
30
|
+
if (deepEqualJson(next, prev)) {
|
|
31
|
+
fail(`settings/updated for "${ns}" emitted without a resolved-value change`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Register this package's invariant companion.
|
|
37
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
38
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
39
|
+
*/
|
|
40
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
41
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural secret redaction for settings values. `role('secret')` fields are
|
|
3
|
+
* removed from a value before it crosses a wire boundary; a sidecar records
|
|
4
|
+
* each schema-declared secret position and whether it currently holds a value,
|
|
5
|
+
* so a configuration surface can render a write-only input without ever
|
|
6
|
+
* receiving the secret itself.
|
|
7
|
+
* @module @deepseek-ai/dsh-settings/redact
|
|
8
|
+
*/
|
|
9
|
+
/** Whether a value is a plain data object the walker may recurse into. */
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function walk(node, value, path, secrets) {
|
|
14
|
+
if (node === undefined)
|
|
15
|
+
return value;
|
|
16
|
+
if (node.meta?.role === 'secret') {
|
|
17
|
+
secrets.push({ path, set: value !== undefined });
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
switch (node.type) {
|
|
21
|
+
case 'object': {
|
|
22
|
+
const properties = node.dict ?? {};
|
|
23
|
+
const source = isRecord(value) ? value : undefined;
|
|
24
|
+
const rebuilt = {};
|
|
25
|
+
if (source !== undefined) {
|
|
26
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
27
|
+
if (key in properties)
|
|
28
|
+
continue;
|
|
29
|
+
rebuilt[key] = entry;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
33
|
+
const stripped = walk(child, source?.[key], [...path, key], secrets);
|
|
34
|
+
if (stripped !== undefined)
|
|
35
|
+
rebuilt[key] = stripped;
|
|
36
|
+
}
|
|
37
|
+
return source === undefined && Object.keys(rebuilt).length === 0 ? value : rebuilt;
|
|
38
|
+
}
|
|
39
|
+
case 'dict': {
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
return value;
|
|
42
|
+
const rebuilt = {};
|
|
43
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
44
|
+
const stripped = walk(node.inner, entry, [...path, key], secrets);
|
|
45
|
+
if (stripped !== undefined)
|
|
46
|
+
rebuilt[key] = stripped;
|
|
47
|
+
}
|
|
48
|
+
return rebuilt;
|
|
49
|
+
}
|
|
50
|
+
case 'array': {
|
|
51
|
+
if (!Array.isArray(value))
|
|
52
|
+
return value;
|
|
53
|
+
return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
// TODO(settings-wire-redaction): Fail closed instead — a secret reachable
|
|
57
|
+
// only through a union, intersection, or transform is returned verbatim
|
|
58
|
+
// here, with nothing recording that it was missed.
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Remove every `role('secret')` field a schema declares from a value. The
|
|
64
|
+
* walker follows `object`, `dict`, and `array` containers; a secret must be
|
|
65
|
+
* declared directly on a field reachable through those containers (a secret
|
|
66
|
+
* buried inside a union branch or transform is not reachable and must not be
|
|
67
|
+
* modeled that way). The input is never mutated.
|
|
68
|
+
* @param schema - live schemastery schema describing the value.
|
|
69
|
+
* @param value - the value to strip; `undefined` yields an empty record with
|
|
70
|
+
* object-property secret slots still enumerated.
|
|
71
|
+
* @returns the stripped detached value and the ordered secret positions.
|
|
72
|
+
*/
|
|
73
|
+
export function redactSecrets(schema, value) {
|
|
74
|
+
const secrets = [];
|
|
75
|
+
const stripped = walk(schema, value, [], secrets);
|
|
76
|
+
return { value: stripped, secrets };
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe type surface of the user-settings seam: the namespace brand, the
|
|
3
|
+
* commit-origin union, and the seam's Cordis event declarations. Types only —
|
|
4
|
+
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
|
5
|
+
* compilation face reads exactly the signatures the Host emits.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-settings/types
|
|
8
|
+
*/
|
|
9
|
+
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
10
|
+
/** Nominal id of one registered settings namespace. */
|
|
11
|
+
export type SettingsNamespace = Branded<'SettingsNamespace'>;
|
|
12
|
+
/** Origin of one committed settings change. */
|
|
13
|
+
export type SettingsUpdateSource = 'update' | 'provider';
|
|
14
|
+
declare module '@deepseek-ai/cordis' {
|
|
15
|
+
interface Events {
|
|
16
|
+
/**
|
|
17
|
+
* Committed change to one registered namespace's resolved value. Emitted
|
|
18
|
+
* after the provider persisted (for `update`) or published (`provider`)
|
|
19
|
+
* the change; never emitted when the resolved value is deep-equal.
|
|
20
|
+
* Listener failures are contained and logged — a sync throw and an async
|
|
21
|
+
* rejection alike — except `INVARIANT`-coded failures, which rethrow
|
|
22
|
+
* after every listener ran; that rethrow reaches the emitter only from
|
|
23
|
+
* synchronous listeners, so invariant checks on this event must not be
|
|
24
|
+
* async functions.
|
|
25
|
+
* @param ns - the namespace whose resolved value changed.
|
|
26
|
+
* @param next - the new resolved value.
|
|
27
|
+
* @param prev - the previous resolved value.
|
|
28
|
+
* @param source - whether the change entered through `update()` or the provider.
|
|
29
|
+
* @mode emit
|
|
30
|
+
*/
|
|
31
|
+
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void;
|
|
32
|
+
/**
|
|
33
|
+
* One registered namespace's RAW user section changed, whether or not the
|
|
34
|
+
* resolved value did. `settings/updated` is the consumer-facing event and
|
|
35
|
+
* stays deep-equal-gated; this one exists for configuration surfaces,
|
|
36
|
+
* which must learn that a field went from inherited to overridden (same
|
|
37
|
+
* resolved value, different meaning) and that their held revision is
|
|
38
|
+
* stale. Listener containment matches `settings/updated`.
|
|
39
|
+
* @param ns - the namespace whose stored section changed.
|
|
40
|
+
* @param revision - the namespace's new revision.
|
|
41
|
+
* @mode emit
|
|
42
|
+
*/
|
|
43
|
+
'settings/document-updated'(ns: SettingsNamespace, revision: number): void;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe type surface of the user-settings seam: the namespace brand, the
|
|
3
|
+
* commit-origin union, and the seam's Cordis event declarations. Types only —
|
|
4
|
+
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
|
5
|
+
* compilation face reads exactly the signatures the Host emits.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-settings/types
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-settings",
|
|
3
3
|
"description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
|
|
4
|
-
"version": "0.0.1-rc.
|
|
4
|
+
"version": "0.0.1-rc.3",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "restricted"
|
|
7
7
|
},
|
|
@@ -22,25 +22,30 @@
|
|
|
22
22
|
"types": "./lib/types/invariant.d.ts",
|
|
23
23
|
"default": "./lib/invariant.js"
|
|
24
24
|
},
|
|
25
|
+
"./types": {
|
|
26
|
+
"types": "./lib/types/types.d.ts",
|
|
27
|
+
"default": "./lib/types/types.js"
|
|
28
|
+
},
|
|
25
29
|
"./src/*": "./src/*",
|
|
26
30
|
"./package.json": "./package.json"
|
|
27
31
|
},
|
|
28
32
|
"files": [
|
|
29
33
|
"lib/index.js",
|
|
30
34
|
"lib/invariant.js",
|
|
35
|
+
"lib/types/**/*.js",
|
|
31
36
|
"lib/types/**/*.d.ts"
|
|
32
37
|
],
|
|
33
38
|
"license": "BSD-3-Clause",
|
|
34
39
|
"peerDependencies": {
|
|
35
|
-
"@deepseek-ai/dsh-
|
|
36
|
-
"@deepseek-ai/dsh-
|
|
40
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
|
|
41
|
+
"@deepseek-ai/dsh-brand": "^0.0.1-rc.3",
|
|
37
42
|
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
38
43
|
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
41
|
-
"@deepseek-ai/dsh-brand": "^0.0.1-rc.
|
|
42
|
-
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.
|
|
43
|
-
"@deepseek-ai/
|
|
44
|
-
"@deepseek-ai/
|
|
46
|
+
"@deepseek-ai/dsh-brand": "^0.0.1-rc.3",
|
|
47
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1",
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
45
50
|
}
|
|
46
51
|
}
|