@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/lib/invariant.js CHANGED
@@ -1,149 +1,4 @@
1
- import { Service } from "@deepseek-ai/cordis";
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`.
@@ -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
- * Brand a raw string as a {@link SettingsNamespace}.
17
- * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
18
- * @returns the branded namespace.
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: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>;
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: SettingsNamespace): unknown;
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: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void>;
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: SettingsNamespace, section: object, expectedRevision?: number): Promise<void>;
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: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void>;
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 installSettingsSection}. */
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
@@ -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
- if (this.registrations.has(ns)) {
265
- throw new Error(`settings namespace "${ns}" is already registered`);
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(ns), options?.validate)),
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(ns, registration);
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(ns);
284
- }, `settings.register(${JSON.stringify(String(ns))})`);
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(ns, patch),
296
- replace: section => this.replace(ns, section),
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 "${ns}" must be an array of path ops`);
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 "${ns}" ops must be {op:'set'|'unset', path}`);
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 "${ns}" op paths must be arrays of strings`);
399
+ throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`);
398
400
  }
399
401
  }
400
- return this.write(ns, ops, 'mutate', expectedRevision);
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
@@ -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 "./index.js";
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';
@@ -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, and the seam's Cordis event declarations. Types only —
4
- * no runtime code, and nothing here reaches a Host-only symbol, so a Client
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
  /**
@@ -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, and the seam's Cordis event declarations. Types only —
4
- * no runtime code, and nothing here reaches a Host-only symbol, so a Client
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