@deepseek-ai/dsh-settings 0.1.1-rc.2 → 0.1.2-alpha.2
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 +139 -23
- package/README.zh.md +140 -24
- package/lib/index.js +53 -81
- package/lib/invariant.js +1 -146
- package/lib/types/index.d.ts +28 -35
- package/lib/types/index.js +57 -99
- package/lib/types/invariant.js +1 -1
- package/lib/types/types.d.ts +60 -2
- package/lib/types/types.js +3 -2
- package/package.json +14 -9
package/lib/invariant.js
CHANGED
|
@@ -1,149 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
//#region lib/types/redact.js
|
|
3
|
-
/**
|
|
4
|
-
* Structural secret redaction for settings values. `role('secret')` fields are
|
|
5
|
-
* removed from a value before it crosses a wire boundary; a sidecar records
|
|
6
|
-
* each schema-declared secret position and whether it currently holds a value,
|
|
7
|
-
* so a configuration surface can render a write-only input without ever
|
|
8
|
-
* receiving the secret itself.
|
|
9
|
-
* @module @deepseek-ai/dsh-settings/redact
|
|
10
|
-
*/
|
|
11
|
-
/** Whether a value is a plain data object the walker may recurse into. */
|
|
12
|
-
function isRecord(value) {
|
|
13
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
-
}
|
|
15
|
-
function walk(node, value, path, secrets) {
|
|
16
|
-
if (node === void 0) return value;
|
|
17
|
-
if (node.meta?.role === "secret") {
|
|
18
|
-
secrets.push({
|
|
19
|
-
path,
|
|
20
|
-
set: value !== void 0
|
|
21
|
-
});
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
switch (node.type) {
|
|
25
|
-
case "object": {
|
|
26
|
-
const properties = node.dict ?? {};
|
|
27
|
-
const source = isRecord(value) ? value : void 0;
|
|
28
|
-
const rebuilt = {};
|
|
29
|
-
if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
|
|
30
|
-
if (key in properties) continue;
|
|
31
|
-
rebuilt[key] = entry;
|
|
32
|
-
}
|
|
33
|
-
for (const [key, child] of Object.entries(properties)) {
|
|
34
|
-
const stripped = walk(child, source?.[key], [...path, key], secrets);
|
|
35
|
-
if (stripped !== void 0) rebuilt[key] = stripped;
|
|
36
|
-
}
|
|
37
|
-
return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
|
|
38
|
-
}
|
|
39
|
-
case "dict": {
|
|
40
|
-
if (!isRecord(value)) return value;
|
|
41
|
-
const rebuilt = {};
|
|
42
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
43
|
-
const stripped = walk(node.inner, entry, [...path, key], secrets);
|
|
44
|
-
if (stripped !== void 0) rebuilt[key] = stripped;
|
|
45
|
-
}
|
|
46
|
-
return rebuilt;
|
|
47
|
-
}
|
|
48
|
-
case "array":
|
|
49
|
-
if (!Array.isArray(value)) return value;
|
|
50
|
-
return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
|
|
51
|
-
default: return value;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
//#endregion
|
|
55
|
-
//#region lib/types/index.js
|
|
56
|
-
/**
|
|
57
|
-
* Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
|
|
58
|
-
* per-namespace sections; plugins register a namespace schema and read the
|
|
59
|
-
* resolved value, which layers schema defaults, the registrant's composition
|
|
60
|
-
* `base`, and the user document section, in that order.
|
|
61
|
-
* @module @deepseek-ai/dsh-settings
|
|
62
|
-
*/
|
|
63
|
-
/**
|
|
64
|
-
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
65
|
-
* Service Definition's single change-detection predicate, exported so the invariant
|
|
66
|
-
* companion checks exactly the implementation's relation.
|
|
67
|
-
* @param a - one JSON-compatible value.
|
|
68
|
-
* @param b - the other JSON-compatible value.
|
|
69
|
-
* @returns whether the two values are structurally equal.
|
|
70
|
-
*/
|
|
71
|
-
function deepEqualJson(a, b) {
|
|
72
|
-
if (a === b) return true;
|
|
73
|
-
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
74
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
75
|
-
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
76
|
-
return a.every((entry, index) => deepEqualJson(entry, b[index]));
|
|
77
|
-
}
|
|
78
|
-
const left = a;
|
|
79
|
-
const right = b;
|
|
80
|
-
const keys = Object.keys(left);
|
|
81
|
-
if (keys.length !== Object.keys(right).length) return false;
|
|
82
|
-
return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
|
|
83
|
-
}
|
|
84
|
-
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
|
85
|
-
function isPlainObject(value) {
|
|
86
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
87
|
-
const proto = Object.getPrototypeOf(value);
|
|
88
|
-
return proto === Object.prototype || proto === null;
|
|
89
|
-
}
|
|
90
|
-
/** Apply one path op to a detached section, returning the next section. */
|
|
91
|
-
function applyPathOp(section, op) {
|
|
92
|
-
const [head, ...rest] = op.path;
|
|
93
|
-
if (head === void 0) {
|
|
94
|
-
if (op.op === "unset") return {};
|
|
95
|
-
if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
|
|
96
|
-
return { ...op.value };
|
|
97
|
-
}
|
|
98
|
-
if (rest.length === 0) {
|
|
99
|
-
if (op.op === "set") return {
|
|
100
|
-
...section,
|
|
101
|
-
[head]: op.value
|
|
102
|
-
};
|
|
103
|
-
const { [head]: _removed, ...kept } = section;
|
|
104
|
-
return kept;
|
|
105
|
-
}
|
|
106
|
-
const child = section[head];
|
|
107
|
-
if (!isPlainObject(child)) {
|
|
108
|
-
if (op.op === "unset") return section;
|
|
109
|
-
return {
|
|
110
|
-
...section,
|
|
111
|
-
[head]: applyPathOp({}, {
|
|
112
|
-
...op,
|
|
113
|
-
path: rest
|
|
114
|
-
})
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
return {
|
|
118
|
-
...section,
|
|
119
|
-
[head]: applyPathOp(child, {
|
|
120
|
-
...op,
|
|
121
|
-
path: rest
|
|
122
|
-
})
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Layer `over` onto `under`: plain objects merge recursively, every other
|
|
127
|
-
* value (arrays included) replaces the lower layer wholesale. `over` never
|
|
128
|
-
* carries `undefined` entries — sections come from parsed documents and write
|
|
129
|
-
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
|
130
|
-
* cannot erase lower keys.
|
|
131
|
-
*/
|
|
132
|
-
function mergeLayers(under, over) {
|
|
133
|
-
if (over === void 0) return under;
|
|
134
|
-
if (!isPlainObject(under) || !isPlainObject(over)) return over;
|
|
135
|
-
const merged = { ...under };
|
|
136
|
-
for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
|
|
137
|
-
return merged;
|
|
138
|
-
}
|
|
139
|
-
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
|
140
|
-
function deepFreeze(value) {
|
|
141
|
-
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
142
|
-
for (const entry of Object.values(value)) deepFreeze(entry);
|
|
143
|
-
return Object.freeze(value);
|
|
144
|
-
}
|
|
145
|
-
Service.init;
|
|
146
|
-
//#endregion
|
|
1
|
+
import { deepEqualJson } from "@deepseek-ai/dsh-util-values";
|
|
147
2
|
//#region lib/types/invariant.js
|
|
148
3
|
/**
|
|
149
4
|
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
|
package/lib/types/index.d.ts
CHANGED
|
@@ -12,12 +12,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
14
|
export type { SettingsNamespace, SettingsUpdateSource } from './types.ts';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export declare function settingsNamespace(value: string): SettingsNamespace;
|
|
15
|
+
type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
|
|
16
|
+
type DecimalDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
|
|
17
|
+
type NamespaceCharacter = LowercaseLetter | DecimalDigit | '-';
|
|
18
|
+
type ValidNamespaceTail<Value extends string> = Value extends '' ? true : Value extends `${NamespaceCharacter}${infer Rest}` ? ValidNamespaceTail<Rest> : false;
|
|
19
|
+
type SettingsNamespaceInput<Value extends string> = Value extends SettingsNamespace ? Value : string extends Value ? string : Value extends `${LowercaseLetter}${infer Rest}` ? ValidNamespaceTail<Rest> extends true ? Value : never : never;
|
|
21
20
|
/** When a namespace's changes take effect for its owner. */
|
|
22
21
|
export type SettingsApplies = 'live' | 'restart';
|
|
23
22
|
/** Registration options beyond the namespace schema. */
|
|
@@ -114,15 +113,6 @@ declare module '@deepseek-ai/cordis' {
|
|
|
114
113
|
settings: SettingsProvider;
|
|
115
114
|
}
|
|
116
115
|
}
|
|
117
|
-
/**
|
|
118
|
-
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
119
|
-
* Service Definition's single change-detection predicate, exported so the invariant
|
|
120
|
-
* companion checks exactly the implementation's relation.
|
|
121
|
-
* @param a - one JSON-compatible value.
|
|
122
|
-
* @param b - the other JSON-compatible value.
|
|
123
|
-
* @returns whether the two values are structurally equal.
|
|
124
|
-
*/
|
|
125
|
-
export declare function deepEqualJson(a: unknown, b: unknown): boolean;
|
|
126
116
|
/**
|
|
127
117
|
* A write refused because the namespace moved since the caller read it. The
|
|
128
118
|
* Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
|
|
@@ -221,8 +211,21 @@ export declare abstract class SettingsProvider extends Service {
|
|
|
221
211
|
* @param schema - schemastery schema resolving this namespace's value.
|
|
222
212
|
* @param options - composition `base` layer and effect timing.
|
|
223
213
|
* @returns the owner scope for reads, observation, and updates.
|
|
214
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
224
215
|
*/
|
|
225
|
-
register<T>(ns:
|
|
216
|
+
register<const Namespace extends string, T>(ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>;
|
|
217
|
+
/**
|
|
218
|
+
* Attach one optional-settings consumer to this provider. The consumer
|
|
219
|
+
* registers its composition entry as the base layer while this provider is
|
|
220
|
+
* present, then falls back to that entry if the provider detaches.
|
|
221
|
+
* @param owner - consumer context whose unload suppresses fallback work.
|
|
222
|
+
* @param ns - consumer-owned settings namespace.
|
|
223
|
+
* @param schema - schema resolving the namespace.
|
|
224
|
+
* @param entry - composition entry used as the base and fallback value.
|
|
225
|
+
* @param hooks - source sink, change notification, and optional validation.
|
|
226
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
227
|
+
*/
|
|
228
|
+
installSection<const Namespace extends string, T>(owner: Context, ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>): void;
|
|
226
229
|
/**
|
|
227
230
|
* Describe every registered namespace for configuration surfaces, including
|
|
228
231
|
* the composition `base` and raw user layers so a form can mark which fields
|
|
@@ -235,8 +238,9 @@ export declare abstract class SettingsProvider extends Service {
|
|
|
235
238
|
* Read one registered namespace's resolved value.
|
|
236
239
|
* @param ns - the namespace to read.
|
|
237
240
|
* @returns the resolved value, or `undefined` while unregistered.
|
|
241
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
238
242
|
*/
|
|
239
|
-
get(ns:
|
|
243
|
+
get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown;
|
|
240
244
|
/**
|
|
241
245
|
* Merge a patch into one registered namespace's user layer, validate the
|
|
242
246
|
* resolved candidate, persist through the provider, then commit and emit.
|
|
@@ -247,8 +251,9 @@ export declare abstract class SettingsProvider extends Service {
|
|
|
247
251
|
* @param patch - plain-object patch over the user section.
|
|
248
252
|
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
249
253
|
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
254
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
250
255
|
*/
|
|
251
|
-
update(ns:
|
|
256
|
+
update<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number): Promise<void>;
|
|
252
257
|
/**
|
|
253
258
|
* Replace one registered namespace's user section wholesale, validate,
|
|
254
259
|
* persist, then commit and emit. Keys absent from `section` fall back to the
|
|
@@ -258,8 +263,9 @@ export declare abstract class SettingsProvider extends Service {
|
|
|
258
263
|
* @param section - the complete next user section.
|
|
259
264
|
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
260
265
|
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
266
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
261
267
|
*/
|
|
262
|
-
replace(ns:
|
|
268
|
+
replace<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number): Promise<void>;
|
|
263
269
|
/**
|
|
264
270
|
* Apply path-addressed edits to one registered namespace's user section,
|
|
265
271
|
* validate, persist, then commit and emit. The ops are applied to the
|
|
@@ -271,8 +277,9 @@ export declare abstract class SettingsProvider extends Service {
|
|
|
271
277
|
* @param ops - ordered path edits; later ops observe earlier ones.
|
|
272
278
|
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
273
279
|
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
280
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
274
281
|
*/
|
|
275
|
-
mutate(ns:
|
|
282
|
+
mutate<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void>;
|
|
276
283
|
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
|
277
284
|
private write;
|
|
278
285
|
/**
|
|
@@ -304,7 +311,7 @@ export declare abstract class SettingsProvider extends Service {
|
|
|
304
311
|
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
305
312
|
private warnListenerFailure;
|
|
306
313
|
}
|
|
307
|
-
/** Hooks a consumer hands to {@link
|
|
314
|
+
/** Hooks a consumer hands to {@link SettingsProvider.installSection}. */
|
|
308
315
|
export interface SettingsSectionHooks<T> {
|
|
309
316
|
/**
|
|
310
317
|
* Receive the active configuration source: the resolved settings scope
|
|
@@ -325,19 +332,5 @@ export interface SettingsSectionHooks<T> {
|
|
|
325
332
|
*/
|
|
326
333
|
validate?: (value: T) => void;
|
|
327
334
|
}
|
|
328
|
-
/**
|
|
329
|
-
* Install the canonical optional-settings consumer wiring: while a settings
|
|
330
|
-
* service exists, register `ns` with the consumer's composition entry as the
|
|
331
|
-
* `base` layer and point the source thunk at the resolved scope; when the
|
|
332
|
-
* service goes away (disposal, provider reload), fall back to the entry so
|
|
333
|
-
* the consumer keeps working exactly as composed. The registration rides the
|
|
334
|
-
* scoped fiber, so no settings service ever mounted means none of this runs.
|
|
335
|
-
* @param ctx - consumer plugin context owning the wiring.
|
|
336
|
-
* @param ns - the consumer-owned settings namespace.
|
|
337
|
-
* @param schema - schema resolving the namespace (typically the plugin Config).
|
|
338
|
-
* @param entry - the consumer's composition entry config, used as `base`.
|
|
339
|
-
* @param hooks - source sink and change notification.
|
|
340
|
-
*/
|
|
341
|
-
export declare function installSettingsSection<T>(ctx: Context, ns: SettingsNamespace, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>): void;
|
|
342
335
|
export default SettingsProvider;
|
|
343
336
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/types/index.js
CHANGED
|
@@ -6,45 +6,16 @@
|
|
|
6
6
|
* @module @deepseek-ai/dsh-settings
|
|
7
7
|
*/
|
|
8
8
|
import { Service } from '@deepseek-ai/cordis';
|
|
9
|
+
import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values';
|
|
9
10
|
import { redactSecrets } from "./redact.js";
|
|
10
11
|
export { redactSecrets } from "./redact.js";
|
|
11
12
|
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) {
|
|
13
|
+
function parseSettingsNamespace(value) {
|
|
18
14
|
if (!NAMESPACE_PATTERN.test(value)) {
|
|
19
15
|
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
|
|
20
16
|
}
|
|
21
17
|
return value;
|
|
22
18
|
}
|
|
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
19
|
/**
|
|
49
20
|
* A write refused because the namespace moved since the caller read it. The
|
|
50
21
|
* Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
|
|
@@ -183,14 +154,6 @@ function mergeLayers(under, over) {
|
|
|
183
154
|
}
|
|
184
155
|
return merged;
|
|
185
156
|
}
|
|
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
157
|
/**
|
|
195
158
|
* Abstract settings service. Providers implement raw-document storage
|
|
196
159
|
* (`load`/`persist`) and push external changes through {@link Settings.publish};
|
|
@@ -259,29 +222,31 @@ export class SettingsProvider extends Service {
|
|
|
259
222
|
* @param schema - schemastery schema resolving this namespace's value.
|
|
260
223
|
* @param options - composition `base` layer and effect timing.
|
|
261
224
|
* @returns the owner scope for reads, observation, and updates.
|
|
225
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
262
226
|
*/
|
|
263
227
|
register(ns, schema, options) {
|
|
264
|
-
|
|
265
|
-
|
|
228
|
+
const parsedNs = parseSettingsNamespace(ns);
|
|
229
|
+
if (this.registrations.has(parsedNs)) {
|
|
230
|
+
throw new Error(`settings namespace "${parsedNs}" is already registered`);
|
|
266
231
|
}
|
|
267
232
|
const registration = {
|
|
268
|
-
ns,
|
|
233
|
+
ns: parsedNs,
|
|
269
234
|
schema: schema,
|
|
270
235
|
base: options?.base,
|
|
271
236
|
applies: options?.applies ?? 'live',
|
|
272
237
|
...options?.validate === undefined
|
|
273
238
|
? {}
|
|
274
239
|
: { validate: options.validate },
|
|
275
|
-
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(
|
|
240
|
+
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
|
|
276
241
|
revision: 0,
|
|
277
242
|
watchers: new Set(),
|
|
278
243
|
};
|
|
279
244
|
this.ctx.effect(() => {
|
|
280
|
-
this.registrations.set(
|
|
245
|
+
this.registrations.set(parsedNs, registration);
|
|
281
246
|
// TODO(settings-registration-quiescence): Deactivate every watcher and await
|
|
282
247
|
// its tail on disposal so callbacks cannot outlive the registrant fiber.
|
|
283
|
-
return () => this.registrations.delete(
|
|
284
|
-
}, `settings.register(${JSON.stringify(String(
|
|
248
|
+
return () => this.registrations.delete(parsedNs);
|
|
249
|
+
}, `settings.register(${JSON.stringify(String(parsedNs))})`);
|
|
285
250
|
return {
|
|
286
251
|
get: () => registration.resolved,
|
|
287
252
|
watch: (callback) => {
|
|
@@ -292,10 +257,42 @@ export class SettingsProvider extends Service {
|
|
|
292
257
|
registration.watchers.delete(watcher);
|
|
293
258
|
};
|
|
294
259
|
},
|
|
295
|
-
update: patch => this.update(
|
|
296
|
-
replace: section => this.replace(
|
|
260
|
+
update: patch => this.update(parsedNs, patch),
|
|
261
|
+
replace: section => this.replace(parsedNs, section),
|
|
297
262
|
};
|
|
298
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Attach one optional-settings consumer to this provider. The consumer
|
|
266
|
+
* registers its composition entry as the base layer while this provider is
|
|
267
|
+
* present, then falls back to that entry if the provider detaches.
|
|
268
|
+
* @param owner - consumer context whose unload suppresses fallback work.
|
|
269
|
+
* @param ns - consumer-owned settings namespace.
|
|
270
|
+
* @param schema - schema resolving the namespace.
|
|
271
|
+
* @param entry - composition entry used as the base and fallback value.
|
|
272
|
+
* @param hooks - source sink, change notification, and optional validation.
|
|
273
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
274
|
+
*/
|
|
275
|
+
installSection(owner, ns, schema, entry, hooks) {
|
|
276
|
+
const scope = this.register(ns, schema, {
|
|
277
|
+
base: entry,
|
|
278
|
+
...hooks.validate === undefined ? {} : { validate: hooks.validate },
|
|
279
|
+
});
|
|
280
|
+
hooks.setSource(() => scope.get());
|
|
281
|
+
this.ctx.effect(() => () => {
|
|
282
|
+
// Losing the provider leaves the consumer running; unloading the
|
|
283
|
+
// consumer does not, so only the former needs fallback work.
|
|
284
|
+
if (isUnloading(owner))
|
|
285
|
+
return;
|
|
286
|
+
hooks.setSource(() => entry);
|
|
287
|
+
hooks.onChange();
|
|
288
|
+
});
|
|
289
|
+
hooks.onChange();
|
|
290
|
+
scope.watch(() => {
|
|
291
|
+
if (isUnloading(owner))
|
|
292
|
+
return;
|
|
293
|
+
hooks.onChange();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
299
296
|
/**
|
|
300
297
|
* Describe every registered namespace for configuration surfaces, including
|
|
301
298
|
* the composition `base` and raw user layers so a form can mark which fields
|
|
@@ -343,9 +340,10 @@ export class SettingsProvider extends Service {
|
|
|
343
340
|
* Read one registered namespace's resolved value.
|
|
344
341
|
* @param ns - the namespace to read.
|
|
345
342
|
* @returns the resolved value, or `undefined` while unregistered.
|
|
343
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
346
344
|
*/
|
|
347
345
|
get(ns) {
|
|
348
|
-
return this.registrations.get(ns)?.resolved;
|
|
346
|
+
return this.registrations.get(parseSettingsNamespace(ns))?.resolved;
|
|
349
347
|
}
|
|
350
348
|
/**
|
|
351
349
|
* Merge a patch into one registered namespace's user layer, validate the
|
|
@@ -357,9 +355,10 @@ export class SettingsProvider extends Service {
|
|
|
357
355
|
* @param patch - plain-object patch over the user section.
|
|
358
356
|
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
359
357
|
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
358
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
360
359
|
*/
|
|
361
360
|
async update(ns, patch, expectedRevision) {
|
|
362
|
-
return this.write(ns, patch, 'merge', expectedRevision);
|
|
361
|
+
return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision);
|
|
363
362
|
}
|
|
364
363
|
/**
|
|
365
364
|
* Replace one registered namespace's user section wholesale, validate,
|
|
@@ -370,9 +369,10 @@ export class SettingsProvider extends Service {
|
|
|
370
369
|
* @param section - the complete next user section.
|
|
371
370
|
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
372
371
|
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
372
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
373
373
|
*/
|
|
374
374
|
async replace(ns, section, expectedRevision) {
|
|
375
|
-
return this.write(ns, section, 'replace', expectedRevision);
|
|
375
|
+
return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision);
|
|
376
376
|
}
|
|
377
377
|
/**
|
|
378
378
|
* Apply path-addressed edits to one registered namespace's user section,
|
|
@@ -385,19 +385,21 @@ export class SettingsProvider extends Service {
|
|
|
385
385
|
* @param ops - ordered path edits; later ops observe earlier ones.
|
|
386
386
|
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
387
387
|
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
388
|
+
* @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
|
|
388
389
|
*/
|
|
389
390
|
async mutate(ns, ops, expectedRevision) {
|
|
391
|
+
const parsedNs = parseSettingsNamespace(ns);
|
|
390
392
|
if (!Array.isArray(ops))
|
|
391
|
-
throw new TypeError(`settings mutate for "${
|
|
393
|
+
throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`);
|
|
392
394
|
for (const op of ops) {
|
|
393
395
|
if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
|
|
394
|
-
throw new TypeError(`settings mutate for "${
|
|
396
|
+
throw new TypeError(`settings mutate for "${parsedNs}" ops must be {op:'set'|'unset', path}`);
|
|
395
397
|
}
|
|
396
398
|
if (!Array.isArray(op['path']) || op['path'].some(part => typeof part !== 'string')) {
|
|
397
|
-
throw new TypeError(`settings mutate for "${
|
|
399
|
+
throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`);
|
|
398
400
|
}
|
|
399
401
|
}
|
|
400
|
-
return this.write(
|
|
402
|
+
return this.write(parsedNs, ops, 'mutate', expectedRevision);
|
|
401
403
|
}
|
|
402
404
|
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
|
403
405
|
write(ns, input, mode, expectedRevision) {
|
|
@@ -640,49 +642,5 @@ function isUnloading(ctx) {
|
|
|
640
642
|
const state = ctx.fiber.state;
|
|
641
643
|
return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
|
|
642
644
|
}
|
|
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
645
|
export default SettingsProvider;
|
|
688
646
|
//# sourceMappingURL=index.js.map
|
package/lib/types/invariant.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
|
|
3
3
|
* @module @deepseek-ai/dsh-settings/invariant
|
|
4
4
|
*/
|
|
5
|
-
import { deepEqualJson } from
|
|
5
|
+
import { deepEqualJson } from '@deepseek-ai/dsh-util-values';
|
|
6
6
|
const PACKAGE_NAME = '@deepseek-ai/dsh-settings';
|
|
7
7
|
/** Cordis companion plugin name. */
|
|
8
8
|
export const name = 'settings-invariant';
|
package/lib/types/types.d.ts
CHANGED
|
@@ -1,16 +1,74 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-safe type surface of the user-settings seam: the namespace brand, the
|
|
3
|
-
* commit-origin union,
|
|
4
|
-
*
|
|
3
|
+
* commit-origin union, the redacted views a configuration surface reads over
|
|
4
|
+
* the Remote wire, and the seam's Cordis event declarations. Types only — no
|
|
5
|
+
* runtime code, and nothing here reaches a Host-only symbol, so a Client
|
|
5
6
|
* compilation face reads exactly the signatures the Host emits.
|
|
6
7
|
*
|
|
7
8
|
* @module @deepseek-ai/dsh-settings/types
|
|
8
9
|
*/
|
|
9
10
|
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
11
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values';
|
|
10
12
|
/** Nominal id of one registered settings namespace. */
|
|
11
13
|
export type SettingsNamespace = Branded<'SettingsNamespace'>;
|
|
12
14
|
/** Origin of one committed settings change. */
|
|
13
15
|
export type SettingsUpdateSource = 'update' | 'provider';
|
|
16
|
+
/** One schema-declared secret slot inside a redacted namespace value. */
|
|
17
|
+
export interface SettingsSecretView {
|
|
18
|
+
/** Path from the section root to the removed field. */
|
|
19
|
+
path: string[];
|
|
20
|
+
/** Whether the slot currently holds a value; the value itself never rides. */
|
|
21
|
+
set: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wire view of one registered namespace, always read under `redactSecrets`. The
|
|
25
|
+
* JSON-valued fields are `JsonValue` rather than the descriptor's `unknown`
|
|
26
|
+
* because the Remote boundary admits no unconstrained data.
|
|
27
|
+
*/
|
|
28
|
+
export interface SettingsNamespaceView {
|
|
29
|
+
/** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
|
|
30
|
+
ns: string;
|
|
31
|
+
/** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
|
|
32
|
+
schema: JsonValue;
|
|
33
|
+
/** Redacted resolved value (schema defaults → composition base → user layer). */
|
|
34
|
+
value: JsonValue;
|
|
35
|
+
/** Redacted composition base layer, when the registrant declared one. */
|
|
36
|
+
base?: JsonValue;
|
|
37
|
+
/** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */
|
|
38
|
+
user?: JsonValue;
|
|
39
|
+
/** When the owner applies changes. */
|
|
40
|
+
applies: 'live' | 'restart';
|
|
41
|
+
/** Every schema-declared secret slot with its configured state. */
|
|
42
|
+
secrets: SettingsSecretView[];
|
|
43
|
+
/**
|
|
44
|
+
* Monotonic revision of the raw user section this view was read at. Send it
|
|
45
|
+
* back as `expectedRevision` on a write so a stale editor is refused rather
|
|
46
|
+
* than silently overwriting a concurrent change.
|
|
47
|
+
*/
|
|
48
|
+
revision: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* One path-addressed edit carried by a remote settings write. `set` writes the
|
|
52
|
+
* value at the path, creating intermediate objects; `unset` removes it. The
|
|
53
|
+
* empty path addresses the section root.
|
|
54
|
+
*/
|
|
55
|
+
export type SettingsPathOpView = {
|
|
56
|
+
op: 'set';
|
|
57
|
+
path: string[];
|
|
58
|
+
value: JsonValue;
|
|
59
|
+
} | {
|
|
60
|
+
op: 'unset';
|
|
61
|
+
path: string[];
|
|
62
|
+
};
|
|
63
|
+
/** Every registered namespace with the deployment facts a configuration page renders around them. */
|
|
64
|
+
export interface SettingsDescribeValue {
|
|
65
|
+
/** Whether the provider accepts writes; `false` disables every write control. */
|
|
66
|
+
writable: boolean;
|
|
67
|
+
/** Whether a file-backed provider owns a local document, without exposing its Host path. */
|
|
68
|
+
hasDocument: boolean;
|
|
69
|
+
/** One view per registered namespace. */
|
|
70
|
+
namespaces: SettingsNamespaceView[];
|
|
71
|
+
}
|
|
14
72
|
declare module '@deepseek-ai/cordis' {
|
|
15
73
|
interface Events {
|
|
16
74
|
/**
|
package/lib/types/types.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-safe type surface of the user-settings seam: the namespace brand, the
|
|
3
|
-
* commit-origin union,
|
|
4
|
-
*
|
|
3
|
+
* commit-origin union, the redacted views a configuration surface reads over
|
|
4
|
+
* the Remote wire, and the seam's Cordis event declarations. Types only — no
|
|
5
|
+
* runtime code, and nothing here reaches a Host-only symbol, so a Client
|
|
5
6
|
* compilation face reads exactly the signatures the Host emits.
|
|
6
7
|
*
|
|
7
8
|
* @module @deepseek-ai/dsh-settings/types
|