@deepseek-ai/dsh-settings 0.1.6-alpha.2 → 0.1.7-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.
@@ -1,26 +1,14 @@
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 { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values';
1
+ /** Config-schema projection and form edits over Cordis profile patches. */
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, rename } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { parse } from 'yaml';
6
+ import { Service, resolveConfig } from '@deepseek-ai/cordis';
7
+ import { interpolate } from '@deepseek-ai/cordis-plugin-loader';
10
8
  import { redactSecrets } from "./redact.js";
9
+ import { isVolatilePath, plainConfig, projectForm, volatileForm } from "./schema.js";
11
10
  export { redactSecrets } from "./redact.js";
12
- const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
13
- function parseSettingsNamespace(value) {
14
- if (!NAMESPACE_PATTERN.test(value)) {
15
- throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
16
- }
17
- return value;
18
- }
19
- /**
20
- * A write refused because the namespace moved since the caller read it. The
21
- * Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
22
- * from one holding a stale snapshot, which is what this reports.
23
- */
11
+ /** Refusal to overwrite configuration changed since the form was read. */
24
12
  export class SettingsConflictError extends Error {
25
13
  /** Stable machine code for wire layers mapping this to their own taxonomy. */
26
14
  code = 'SETTINGS_CONFLICT';
@@ -48,32 +36,36 @@ function isPlainObject(value) {
48
36
  return proto === Object.prototype || proto === null;
49
37
  }
50
38
  /** Apply one path op to a detached section, returning the next section. */
51
- function applyPathOp(section, op) {
52
- const [head, ...rest] = op.path;
53
- // The empty path addresses the section itself.
54
- if (head === undefined) {
55
- if (op.op === 'unset')
56
- return {};
57
- if (!isPlainObject(op.value)) {
58
- throw new TypeError('settings mutate: setting the section root requires a plain object');
39
+ function applyPathOp(section, op, schema) {
40
+ const edit = (input, path, node) => {
41
+ const [head, ...rest] = path;
42
+ if (head === undefined)
43
+ return op.op === 'set' ? op.value : undefined;
44
+ const value = input === undefined ? node?.meta.default : input;
45
+ if (Array.isArray(value)) {
46
+ if (!/^(0|[1-9][0-9]*)$/.test(head) || (Number(head) > value.length || Number(head) === value.length && (rest.length > 0 || op.op === 'unset'))) {
47
+ throw new TypeError(`Config array index "${head}" is out of range`);
48
+ }
49
+ const result = [...value];
50
+ const index = Number(head);
51
+ if (rest.length === 0 && op.op === 'unset')
52
+ result.splice(index, 1);
53
+ else
54
+ result[index] = edit(value[index], rest, node?.inner);
55
+ return result;
59
56
  }
60
- return { ...op.value };
61
- }
62
- if (rest.length === 0) {
63
- if (op.op === 'set')
64
- return { ...section, [head]: op.value };
65
- const { [head]: _removed, ...kept } = section;
66
- return kept;
67
- }
68
- const child = section[head];
69
- if (!isPlainObject(child)) {
70
- // Unsetting through an absent path is already satisfied; setting through
71
- // one creates the intermediate objects it needs.
72
- if (op.op === 'unset')
73
- return section;
74
- return { ...section, [head]: applyPathOp({}, { ...op, path: rest }) };
75
- }
76
- return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) };
57
+ const result = isPlainObject(value) ? { ...value } : {};
58
+ const child = edit(Object.hasOwn(result, head) ? result[head] : undefined, rest, node?.dict?.[head] ?? node?.inner);
59
+ if (child === undefined)
60
+ Reflect.deleteProperty(result, head);
61
+ else
62
+ Object.defineProperty(result, head, { value: child, enumerable: true, writable: true, configurable: true });
63
+ return result;
64
+ };
65
+ const result = edit(section, op.path, schema);
66
+ if (!isPlainObject(result))
67
+ throw new TypeError('Config root must be a plain object');
68
+ return result;
77
69
  }
78
70
  /** Human label for a value that lossless JSON cannot represent (numbers reject inline). */
79
71
  function describeRejected(value) {
@@ -94,11 +86,13 @@ function describeRejected(value) {
94
86
  * silently distorts on the reload round-trip. `undefined` entries in objects
95
87
  * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
96
88
  * an `undefined` array entry is rejected rather than coerced.
97
- * @param root - plain-object write input (caller-checked).
98
- * @param reject - builds the validation error from a value label and its `$`-rooted path.
89
+ * @param root - write input to validate before merging.
99
90
  * @returns the detached JSON-compatible clone.
100
91
  */
101
- function cloneJsonShaped(root, reject) {
92
+ function cloneJsonShaped(root) {
93
+ const reject = (label, path) => new TypeError(`Config ${path} contains ${label}`);
94
+ if (!isPlainObject(root))
95
+ throw reject('a non-plain root', '$');
102
96
  const visiting = new WeakSet();
103
97
  const clone = (value, path) => {
104
98
  if (value === null || typeof value === 'string' || typeof value === 'boolean')
@@ -121,13 +115,11 @@ function cloneJsonShaped(root, reject) {
121
115
  if (visiting.has(value))
122
116
  throw reject('a circular reference', path);
123
117
  visiting.add(value);
124
- // TODO(settings-json-properties): Use property-safe construction here and
125
- // in mergeLayers so valid JSON keys such as "__proto__" remain own data.
126
118
  const out = {};
127
119
  for (const [key, entry] of Object.entries(value)) {
128
120
  if (entry === undefined)
129
121
  continue;
130
- out[key] = clone(entry, `${path}.${key}`);
122
+ Object.defineProperty(out, key, { value: clone(entry, `${path}.${key}`), enumerable: true, configurable: true, writable: true });
131
123
  }
132
124
  visiting.delete(value);
133
125
  return out;
@@ -144,503 +136,270 @@ function cloneJsonShaped(root, reject) {
144
136
  * cannot erase lower keys.
145
137
  */
146
138
  function mergeLayers(under, over) {
147
- if (over === undefined)
148
- return under;
149
139
  if (!isPlainObject(under) || !isPlainObject(over))
150
140
  return over;
151
141
  const merged = { ...under };
152
142
  for (const [key, value] of Object.entries(over)) {
153
- merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
143
+ Object.defineProperty(merged, key, {
144
+ value: Object.hasOwn(merged, key) ? mergeLayers(merged[key], value) : value,
145
+ enumerable: true, configurable: true, writable: true,
146
+ });
154
147
  }
155
148
  return merged;
156
149
  }
157
- /**
158
- * Abstract settings service. Providers implement raw-document storage
159
- * (`load`/`persist`) and push external changes through {@link Settings.publish};
160
- * the base class owns namespace registration, resolution, validation, change
161
- * detection, and the `settings/updated` commit event.
150
+ /** Read one member of a plain object or array; `own` limits the read to own properties.
151
+ * @param node Candidate container.
152
+ * @param key Member name.
153
+ * @param own Whether inherited members count as absent.
154
+ * @returns The member, or undefined when the node is not a container or lacks the member.
162
155
  */
163
- export class SettingsProvider extends Service {
164
- registrations = new Map();
165
- /** Latest published raw document; empty until the provider's first publish. */
166
- document = {};
167
- /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
168
- writeQueues = new Map();
169
- /** In-flight watcher invocation segments, drained by the dispose teardown. */
170
- pendingTails = new Set();
171
- /** Set at service dispose: refuse new writes while queued ones drain. */
172
- stopped = false;
173
- /** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
174
- isStopped() {
175
- return this.stopped;
176
- }
177
- constructor(ctx) {
178
- super(ctx, 'settings');
179
- }
180
- /**
181
- * Load the provider's document once and publish it before the service
182
- * becomes injectable, and register the write-drain teardown. Providers with
183
- * their own init (watchers, connections) delegate here first via
184
- * `yield* super[Service.init]()`; their disposers then run before the drain.
185
- */
186
- async *[Service.init]() {
187
- yield async () => {
188
- // Teardown: refuse new writes and new watcher starts, then wait until
189
- // every queued write chain and every started watcher invocation settles
190
- // so disposal completes only once storage and observers are quiescent.
191
- // Invocations queued but not yet started skip via the stopped check.
192
- this.stopped = true;
193
- await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]);
194
- };
195
- this.publish(await this.load());
196
- }
197
- /**
198
- * Absolute path of the provider's user-editable document, when its storage
199
- * is one local file. Configuration surfaces use this only as availability
200
- * metadata; the guarded open operation resolves the path again Host-side.
201
- * Non-file providers leave it undefined and expose no open-document affordance.
202
- * @returns the absolute local document path, or undefined for non-file storage.
203
- */
204
- get documentPath() {
156
+ function member(node, key, own = false) {
157
+ if (!(isPlainObject(node) || Array.isArray(node)) || (own && !Object.hasOwn(node, key)))
205
158
  return undefined;
159
+ const value = Reflect.get(node, key);
160
+ return value;
161
+ }
162
+ /** Entry ids of the removed `settings.yaml` sections whose owning entry carries another id. */
163
+ const LEGACY_SECTION_ENTRIES = {
164
+ 'ui-developer-tools': 'ui-settings',
165
+ 'ui-onboarding': 'ui-settings-general',
166
+ /* v8 ignore next -- the base bundle composes one shell executor per platform */
167
+ shell: process.platform === 'win32' ? 'pwsh-sandbox' : 'bash-sandbox',
168
+ };
169
+ /** Resolve the inherited layers alone, or keep their raw values when required fields arrive only through the profile.
170
+ * @param runtime Plugin runtime owning the Config schema.
171
+ * @param inherited Interpolated config beneath the profile override.
172
+ * @returns Values the profile override sits on.
173
+ */
174
+ function inheritedConfig(runtime, inherited) {
175
+ try {
176
+ return resolveConfig(runtime, inherited);
206
177
  }
207
- /**
208
- * Prepare the provider's user-editable document for a native editor. File
209
- * providers may materialize an absent document before returning its path;
210
- * non-file providers return undefined.
211
- * @returns the absolute local document path, or undefined for non-file storage.
212
- */
213
- prepareDocument() {
214
- return Promise.resolve(this.documentPath);
178
+ catch (_error) {
179
+ // The profile override supplies fields the inherited layers lack; the form shows their raw values as the base.
180
+ return inherited;
215
181
  }
216
- /**
217
- * Register a namespace schema and receive its owner scope. The registration
218
- * is an effect on the calling plugin's fiber: disposing that fiber removes
219
- * the namespace and its observers. An invalid stored section fails the
220
- * registration itself — the earliest point where the schema can judge it.
221
- * @param ns - unique namespace; duplicate registration fails loud.
222
- * @param schema - schemastery schema resolving this namespace's value.
223
- * @param options - composition `base` layer and effect timing.
224
- * @returns the owner scope for reads, observation, and updates.
225
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
226
- */
227
- register(ns, schema, options) {
228
- const parsedNs = parseSettingsNamespace(ns);
229
- if (this.registrations.has(parsedNs)) {
230
- throw new Error(`settings namespace "${parsedNs}" is already registered`);
182
+ }
183
+ /** Project Config schemas into forms and own optional instance-level UI policy. */
184
+ export class SettingsForms extends Service {
185
+ ownerContext;
186
+ static inject = ['configEditor', 'profileContext'];
187
+ revisions = new Map();
188
+ closed = false;
189
+ scheduled = false;
190
+ presentations = new Map();
191
+ constructor(ownerContext) {
192
+ super(ownerContext, 'settings');
193
+ this.ownerContext = ownerContext;
194
+ const ctx = ownerContext;
195
+ ctx.effect(() => () => { this.closed = true; });
196
+ ctx.on('app-boot/config-reload', () => { this.invalidate(); });
197
+ void ctx.root.loader.await().then(() => this.importLegacyDocument()).catch((error) => { ctx.logger.error(error); });
198
+ }
199
+ /** Move the sections of the removed `settings.yaml` into the active profile once the Loader has settled every entry.
200
+ * The document is renamed before the first write, so a partial import never repeats; a section the running
201
+ * composition rejects is logged and remains only in the renamed file. */
202
+ async importLegacyDocument() {
203
+ const profile = this.ownerContext.profileContext;
204
+ const path = join(profile.home, 'settings.yaml');
205
+ if (!existsSync(path))
206
+ return;
207
+ const imported = `${path}.imported`;
208
+ await rename(path, imported);
209
+ const sections = parse(await readFile(imported, 'utf8'));
210
+ for (const [section, values] of Object.entries(sections ?? {})) {
211
+ const ns = LEGACY_SECTION_ENTRIES[section] ?? section;
212
+ try {
213
+ await this.update(ns, values);
214
+ }
215
+ catch (error) {
216
+ this.ownerContext.logger.warn('settings: section %s of %s was not imported into entry %s', section, imported, ns);
217
+ this.ownerContext.logger.warn(error);
218
+ }
231
219
  }
232
- const registration = {
233
- ns: parsedNs,
234
- schema: schema,
235
- base: options?.base,
236
- applies: options?.applies ?? 'live',
237
- ...options?.validate === undefined
238
- ? {}
239
- : { validate: options.validate },
240
- resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
241
- revision: 0,
242
- watchers: new Set(),
243
- };
244
- this.ctx.effect(() => {
245
- this.registrations.set(parsedNs, registration);
246
- // TODO(settings-registration-quiescence): Deactivate every watcher and await
247
- // its tail on disposal so callbacks cannot outlive the registrant fiber.
248
- return () => this.registrations.delete(parsedNs);
249
- }, `settings.register(${JSON.stringify(String(parsedNs))})`);
250
- return {
251
- get: () => registration.resolved,
252
- watch: (callback) => {
253
- const watcher = { callback: callback, tail: Promise.resolve(), active: true };
254
- registration.watchers.add(watcher);
255
- return () => {
256
- watcher.active = false;
257
- registration.watchers.delete(watcher);
258
- };
259
- },
260
- update: patch => this.update(parsedNs, patch),
261
- replace: section => this.replace(parsedNs, section),
262
- };
220
+ this.ownerContext.logger.info('settings: imported %s into profile %s', imported, profile.name);
263
221
  }
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.
222
+ /** Register the calling plugin instance's page policy without changing its Config.
223
+ * @param presentation Automatic-page policy for this instance; `auto` defaults to true.
224
+ * @param owner Plugin instance the policy belongs to; defaults to the calling fiber.
225
+ * @returns Disposer; register it with the calling plugin's effects.
226
+ * @throws If this instance already has a registered policy.
274
227
  */
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))
228
+ configure(presentation, owner = this.ctx.fiber) {
229
+ const fiber = owner;
230
+ if (this.presentations.has(fiber))
231
+ throw new Error('Settings presentation is already configured for this plugin instance');
232
+ const policy = { ...presentation };
233
+ this.presentations.set(fiber, policy);
234
+ this.invalidate();
235
+ return () => {
236
+ if (this.presentations.get(fiber) !== policy)
285
237
  return;
286
- hooks.setSource(() => entry);
287
- hooks.onChange();
288
- });
289
- hooks.onChange();
290
- scope.watch(() => {
291
- if (isUnloading(owner))
238
+ this.presentations.delete(fiber);
239
+ this.invalidate();
240
+ };
241
+ }
242
+ invalidate() {
243
+ if (this.scheduled || this.closed)
244
+ return;
245
+ this.scheduled = true;
246
+ queueMicrotask(() => {
247
+ this.scheduled = false;
248
+ if (this.closed || this.ownerContext.fiber.state !== 2 /* FiberState.ACTIVE */)
292
249
  return;
293
- hooks.onChange();
250
+ try {
251
+ this.describe();
252
+ }
253
+ catch (error) {
254
+ this.ownerContext.logger.error(error);
255
+ }
294
256
  });
295
257
  }
296
- /**
297
- * Describe every registered namespace for configuration surfaces, including
298
- * the composition `base` and raw user layers so a form can mark which fields
299
- * the user overrode (presence in `user`) and what a reset returns to.
300
- * @param options - redaction switch; wire surfaces must redact.
301
- * @returns one descriptor per registered namespace, in registration order.
258
+ /** Whether the active profile accepts form edits. */
259
+ get writable() { return true; }
260
+ /** Current profile patch shown by the native configuration editor. */
261
+ get documentPath() { return this.ownerContext.configEditor.documentPath; }
262
+ /** Locate the profile patch for native editing.
263
+ * @returns The existing profile patch path.
264
+ */
265
+ prepareDocument() { return Promise.resolve(this.documentPath); }
266
+ /** Read active plugin schemas and their live values.
267
+ * @param options Redaction required for remote callers.
268
+ * @returns Forms keyed by unique profile entry ids.
302
269
  */
303
270
  describe(options) {
304
- return [...this.registrations.values()].map((registration) => {
305
- let user;
306
- try {
307
- user = this.section(registration.ns);
308
- }
309
- catch {
310
- // A malformed stored section already warned at publish and kept the
311
- // last good resolved value; only that malformed shape can throw here,
312
- // and describing it as "no user layer" keeps this read total.
313
- user = undefined;
271
+ const active = new Set();
272
+ const descriptors = this.ownerContext.configEditor.configuration().flatMap(({ entry, inherited, override }) => {
273
+ const schema = this.schema(entry);
274
+ if (schema === undefined || entry.fiber === undefined
275
+ || entry.fiber.runtime === null || entry.fiber.state !== 2 /* FiberState.ACTIVE */)
276
+ return [];
277
+ const form = volatileForm(schema);
278
+ if (form === undefined)
279
+ return [];
280
+ active.add(entry.id);
281
+ const raw = JSON.stringify([entry.fiber.uid, schema.toJSON(), entry.options.config ?? {}]);
282
+ const autoGenerate = this.presentations.get(entry.fiber)?.auto ?? true;
283
+ const previous = this.revisions.get(entry.id);
284
+ const revision = previous === undefined ? 0 : previous.revision + Number(previous.raw !== raw);
285
+ this.revisions.set(entry.id, { raw, revision, ns: entry.options.id, autoGenerate });
286
+ if (previous?.raw !== raw || previous.autoGenerate !== autoGenerate) {
287
+ this.ownerContext.emit('settings/document-updated', entry.options.id, revision);
314
288
  }
315
- const base = registration.base === undefined ? undefined : structuredClone(registration.base);
316
- const detachedUser = user === undefined ? undefined : structuredClone(user);
317
- const descriptor = {
318
- ns: registration.ns,
319
- schema: registration.schema.toJSON(),
320
- value: registration.resolved,
321
- revision: registration.revision,
322
- ...base === undefined ? {} : { base },
323
- ...detachedUser === undefined ? {} : { user: detachedUser },
324
- applies: registration.applies,
325
- };
326
- if (options?.redactSecrets !== true)
327
- return descriptor;
328
- const schema = registration.schema;
329
- const redacted = redactSecrets(schema, registration.resolved);
330
- return {
331
- ...descriptor,
332
- value: redacted.value,
333
- ...base === undefined ? {} : { base: redactSecrets(schema, base).value },
334
- ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value },
335
- secrets: redacted.secrets,
336
- };
289
+ const value = projectForm(form, plainConfig(entry.fiber.config));
290
+ const resolved = interpolate(entry.fiber.ctx, inherited);
291
+ const base = projectForm(form, plainConfig(inheritedConfig(entry.fiber.runtime, resolved)));
292
+ const user = projectForm(form, override);
293
+ const redacted = redactSecrets(form, value);
294
+ return [{
295
+ autoGenerate,
296
+ ns: entry.options.id, schema: form.toJSON(), revision, applies: 'live',
297
+ value: options?.redactSecrets ? redacted.value : value,
298
+ base: options?.redactSecrets ? redactSecrets(form, base).value : base,
299
+ user: options?.redactSecrets ? redactSecrets(form, user).value : user,
300
+ ...options?.redactSecrets ? { secrets: redacted.secrets } : {},
301
+ }];
337
302
  });
303
+ for (const [id, previous] of this.revisions) {
304
+ if (active.has(id) || previous.raw === undefined)
305
+ continue;
306
+ const revision = previous.revision + 1;
307
+ this.revisions.set(id, { ...previous, raw: undefined, revision });
308
+ this.ownerContext.emit('settings/document-updated', previous.ns, revision);
309
+ }
310
+ return descriptors;
338
311
  }
339
- /**
340
- * Read one registered namespace's resolved value.
341
- * @param ns - the namespace to read.
342
- * @returns the resolved value, or `undefined` while unregistered.
343
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
344
- */
345
- get(ns) {
346
- return this.registrations.get(parseSettingsNamespace(ns))?.resolved;
347
- }
348
- /**
349
- * Merge a patch into one registered namespace's user layer, validate the
350
- * resolved candidate, persist through the provider, then commit and emit.
351
- * A validation failure rejects before anything is persisted. Writes to one
352
- * namespace are serialized: concurrent updates apply in call order, each
353
- * merging over the previous write's committed section.
354
- * @param ns - the registered namespace to update.
355
- * @param patch - plain-object patch over the user section.
356
- * @param expectedRevision - the descriptor `revision` the caller read; a
357
- * namespace that moved past it rejects with {@link SettingsConflictError}.
358
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
312
+ /** Merge editable fields into an entry's config.
313
+ * @param ns Profile entry id.
314
+ * @param patch Fields to merge.
315
+ * @param expectedRevision Revision returned by describe.
359
316
  */
360
317
  async update(ns, patch, expectedRevision) {
361
- return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision);
318
+ const input = cloneJsonShaped(patch);
319
+ await this.write(ns, current => mergeLayers(current, input), expectedRevision);
362
320
  }
363
- /**
364
- * Replace one registered namespace's user section wholesale, validate,
365
- * persist, then commit and emit. Keys absent from `section` fall back to the
366
- * composition `base` and schema defaults — this is the removal/reset path a
367
- * merge-only patch cannot express (`replace({})` re-inherits everything).
368
- * @param ns - the registered namespace to replace.
369
- * @param section - the complete next user section.
370
- * @param expectedRevision - the descriptor `revision` the caller read; a
371
- * namespace that moved past it rejects with {@link SettingsConflictError}.
372
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
321
+ /** Reset all live fields, then set the supplied fields; ordinary config is preserved.
322
+ * @param ns Profile entry id.
323
+ * @param section Complete form values.
324
+ * @param expectedRevision Revision returned by describe.
373
325
  */
374
326
  async replace(ns, section, expectedRevision) {
375
- return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision);
327
+ const input = cloneJsonShaped(section);
328
+ await this.write(ns, (_current, base) => mergeLayers(base, input), expectedRevision);
376
329
  }
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
- * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
330
+ /** Apply field edits without restating redacted secrets; unsetting an array index removes its element.
331
+ * @param ns Profile entry id.
332
+ * @param ops Ordered form edits.
333
+ * @param expectedRevision Revision returned by describe.
389
334
  */
390
335
  async mutate(ns, ops, expectedRevision) {
391
- const parsedNs = parseSettingsNamespace(ns);
392
- if (!Array.isArray(ops))
393
- throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`);
394
- for (const op of ops) {
395
- if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
396
- throw new TypeError(`settings mutate for "${parsedNs}" ops must be {op:'set'|'unset', path}`);
397
- }
398
- if (!Array.isArray(op['path']) || op['path'].some(part => typeof part !== 'string')) {
399
- throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`);
400
- }
401
- }
402
- return this.write(parsedNs, ops, 'mutate', expectedRevision);
403
- }
404
- /** Validate a write, then queue it on the namespace's serialized write chain. */
405
- write(ns, input, mode, expectedRevision) {
406
- const verb = mode === 'merge' ? 'update' : mode === 'replace' ? 'replace' : 'mutate';
407
- const registration = this.registrations.get(ns);
408
- if (registration === undefined) {
409
- throw new Error(`settings namespace "${ns}" is not registered`);
410
- }
411
- if (this.isStopped()) {
412
- throw new Error(`settings service is disposed: "${ns}" cannot be written`);
413
- }
414
- if (!this.writable) {
415
- throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`);
416
- }
417
- // A mutate's ops array is wrapped so one JSON-shape walk covers both
418
- // shapes; merge/replace carry the section itself.
419
- let payload;
420
- if (mode === 'mutate') {
421
- payload = { ops: input };
422
- }
423
- else {
424
- if (!isPlainObject(input))
425
- throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`);
426
- payload = input;
427
- }
428
- // Snapshot at call time: the queue must never read a caller-owned object
429
- // the caller may keep mutating while the write waits its turn. The same
430
- // walk rejects values that JSON cannot preserve (see cloneJsonShaped).
431
- const snapshot = cloneJsonShaped(payload, (label, path) => new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`));
432
- const previous = this.writeQueues.get(ns) ?? Promise.resolve();
433
- // Chain past a failed predecessor: one rejected write must not poison the
434
- // namespace queue for every later caller.
435
- const run = previous.catch(() => undefined).then(async () => {
436
- if (this.isStopped()) {
437
- throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`);
438
- }
439
- if (this.registrations.get(ns) !== registration) {
440
- throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`);
441
- }
442
- // Every mode derives from the section as it stands NOW, at the front of
443
- // the queue — never from whatever the caller last saw.
444
- const current = this.section(ns) ?? {};
445
- // The revision check belongs HERE, not at call time: the queue orders
446
- // writes but cannot tell a fresh writer from one holding a snapshot
447
- // that a predecessor already superseded.
448
- if (expectedRevision !== undefined && expectedRevision !== registration.revision) {
449
- throw new SettingsConflictError(ns, expectedRevision, registration.revision);
450
- }
451
- const section = mode === 'merge'
452
- ? mergeLayers(current, snapshot)
453
- : mode === 'replace'
454
- ? snapshot
455
- : snapshot['ops'].reduce(applyPathOp, current);
456
- const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate));
457
- await this.persist(ns, section);
458
- // The write reached storage either way; the cache must say so. Commit
459
- // only when this registration is still the namespace owner — a fiber
460
- // disposed (or replaced) mid-persist must not receive the notification.
461
- this.document[ns] = section;
462
- // TODO(settings-replacement-resync): Re-resolve any replacement registration
463
- // from this persisted section so an old in-flight write cannot leave it stale.
464
- if (this.registrations.get(ns) === registration && !this.isStopped()) {
465
- this.bumpRevision(registration, current, section);
466
- this.commit(registration, next, 'update');
467
- }
468
- });
469
- this.writeQueues.set(ns, run);
470
- return run;
336
+ await this.write(ns, (current, base, schema) => ops.reduce((value, op) => {
337
+ if (op.op === 'set')
338
+ return applyPathOp(value, op, schema);
339
+ const parent = op.path.slice(0, -1).reduce((node, key) => member(node, key), value);
340
+ if (Array.isArray(parent))
341
+ return applyPathOp(value, op, schema);
342
+ const inherited = op.path.reduce((node, key) => member(node, key, true), base);
343
+ return applyPathOp(value, inherited === undefined ? op : { op: 'set', path: op.path, value: inherited }, schema);
344
+ }, current), expectedRevision, ops.map(op => op.path));
471
345
  }
472
- /**
473
- * Provider hook: commit a complete raw document observed in storage. Each
474
- * registered namespace re-resolves; an invalid section keeps that
475
- * namespace's last good value and warns, other namespaces still commit.
476
- * @param doc - the detached raw document (unregistered sections preserved).
477
- * @param source - change origin; defaults to `provider`.
478
- */
479
- publish(doc, source = 'provider') {
480
- // Read every raw section BEFORE swapping the document, so the revision
481
- // bump below compares what was stored with what now is — an external edit
482
- // moves the revision exactly like an in-process write.
483
- const before = new Map();
484
- for (const registration of this.registrations.values()) {
485
- try {
486
- before.set(registration.ns, this.section(registration.ns));
487
- }
488
- catch {
489
- // A malformed stored section is not a readable "before"; treating it
490
- // as absent still bumps against any well-formed replacement.
491
- before.set(registration.ns, undefined);
492
- }
346
+ async write(ns, change, expected, paths = []) {
347
+ const entry = this.ownerContext.configEditor.entries().find(row => row.options.id === ns);
348
+ const schema = entry === undefined ? undefined : this.schema(entry);
349
+ if (entry === undefined || schema === undefined)
350
+ throw new Error(`No configurable plugin entry "${ns}"`);
351
+ const form = volatileForm(schema);
352
+ if (form === undefined)
353
+ throw new Error(`Plugin entry "${ns}" has no volatile fields`);
354
+ for (const path of paths) {
355
+ if (path.length && !isVolatilePath(schema, path))
356
+ throw new Error(`Config field "${path.join('.')}" is not volatile`);
493
357
  }
494
- this.document = doc;
495
- for (const registration of this.registrations.values()) {
496
- let next;
497
- try {
498
- next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate));
358
+ await this.ownerContext.configEditor.edit(entry, (raw, inherited) => {
359
+ const descriptor = this.describe().find(row => row.ns === ns);
360
+ if (descriptor === undefined)
361
+ throw new Error(`Plugin entry "${ns}" is no longer configurable`);
362
+ if (expected !== undefined && descriptor.revision !== expected) {
363
+ throw new SettingsConflictError(ns, expected, descriptor.revision);
499
364
  }
500
- catch (error) {
501
- this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns);
502
- this.ctx.logger.warn(error);
503
- continue;
504
- }
505
- this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns));
506
- this.commit(registration, next, source);
507
- }
508
- }
509
- /** Read one namespace's raw user section, rejecting non-object sections. */
510
- section(ns) {
511
- const section = this.document[ns];
512
- if (section === undefined)
513
- return undefined;
514
- if (!isPlainObject(section)) {
515
- throw new TypeError(`settings section "${ns}" must be an object of keys`);
516
- }
517
- return section;
518
- }
519
- /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
520
- resolve(schema, base, section, validate) {
521
- // The merged candidate is untyped by construction; the schema call is the
522
- // runtime validation that admits it into T.
523
- const value = schema(mergeLayers(base, section));
524
- // The owner's own check runs on the admitted value, so it sees defaults
525
- // and the composition base exactly as the owner will.
526
- validate?.(value);
527
- return value;
528
- }
529
- /**
530
- * Advance a namespace's revision when its RAW section changed, and announce
531
- * it. Deliberately independent of {@link commit}'s resolved-value equality:
532
- * storing an override equal to the composition base leaves the resolved
533
- * value alone but changes what the document says, which is exactly what a
534
- * configuration surface must re-read.
535
- */
536
- bumpRevision(registration, before, after) {
537
- if (deepEqualJson(before, after))
538
- return;
539
- registration.revision += 1;
540
- this.emitDocumentUpdated(registration.ns, registration.revision);
541
- }
542
- /** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
543
- emitDocumentUpdated(ns, revision) {
544
- let invariantFailure;
545
- const args = ['settings/document-updated', ns, revision];
546
- for (const listener of this.ctx.events.dispatch('emit', args)) {
547
- try {
548
- const returned = listener(ns, revision);
549
- if (returned != null && typeof returned.then === 'function') {
550
- void Promise.resolve(returned).then(undefined, (error) => {
551
- this.warnListenerFailure(ns, error);
552
- });
365
+ const current = projectForm(form, raw);
366
+ const base = projectForm(form, inherited);
367
+ const next = cloneJsonShaped(change(current, base, schema));
368
+ const validatePaths = (value, node, path = []) => {
369
+ for (const [key, child] of Object.entries(value)) {
370
+ const target = [...path, key];
371
+ if (isVolatilePath(schema, target))
372
+ continue;
373
+ const fields = node.dict;
374
+ const field = Object.hasOwn(fields, key) ? fields[key] : undefined;
375
+ if (isPlainObject(child) && field !== undefined)
376
+ validatePaths(child, field, target);
377
+ else
378
+ throw new Error(`Config field "${target.join('.')}" is not volatile`);
553
379
  }
554
- }
555
- catch (error) {
556
- if (error?.code === 'INVARIANT') {
557
- invariantFailure ??= error;
558
- continue;
559
- }
560
- this.warnListenerFailure(ns, error);
561
- }
562
- }
563
- if (invariantFailure !== undefined)
564
- throw invariantFailure;
565
- }
566
- /** Commit a resolved value when changed: swap, notify watchers, emit the event. */
567
- commit(registration, next, source) {
568
- const prev = registration.resolved;
569
- if (deepEqualJson(next, prev))
570
- return;
571
- registration.resolved = next;
572
- for (const watcher of [...registration.watchers]) {
573
- // Serialize per watcher: invocations of one callback run one at a time
574
- // in commit order, so a slow stale invocation can never apply after a
575
- // newer one. Sync throws and async rejections land in the same handler.
576
- // The activity check runs when the queued invocation would start, so a
577
- // disposer (or service stop) that ran while it waited prevents the
578
- // start entirely; started invocations drain at service dispose.
579
- const segment = watcher.tail
580
- .then(() => {
581
- if (!watcher.active || this.isStopped())
582
- return;
583
- return watcher.callback(next, prev);
584
- })
585
- .then(() => undefined, (error) => {
586
- this.warnWatcherFailure(registration.ns, error);
587
- });
588
- watcher.tail = segment;
589
- this.pendingTails.add(segment);
590
- void segment.then(() => this.pendingTails.delete(segment));
591
- }
592
- // Fan the event out one listener at a time (the plain emit stops at the
593
- // first throwing listener, starving the rest). Invariant violations are
594
- // harness-fatal by design and rethrow after every listener ran; any other
595
- // failure is contained so one broken observer cannot wedge the commit
596
- // path (and, through it, a provider's reload loop).
597
- let invariantFailure;
598
- const args = ['settings/updated', registration.ns, next, prev, source];
599
- for (const listener of this.ctx.events.dispatch('emit', args)) {
600
- try {
601
- const returned = listener(registration.ns, next, prev, source);
602
- if (returned != null && typeof returned.then === 'function') {
603
- // An emit listener may still be an async function; its rejection
604
- // cannot reach the synchronous INVARIANT rethrow below, so it is
605
- // contained here instead of becoming an unhandled rejection.
606
- void Promise.resolve(returned).then(undefined, (error) => {
607
- this.warnListenerFailure(registration.ns, error);
608
- });
609
- }
610
- }
611
- catch (error) {
612
- if (error?.code === 'INVARIANT') {
613
- invariantFailure ??= error;
614
- continue;
380
+ };
381
+ validatePaths(next, form);
382
+ const strip = (value, node, path = []) => {
383
+ if (isVolatilePath(schema, path))
384
+ return {};
385
+ const result = { ...value };
386
+ for (const [key, field] of Object.entries(node.dict)) {
387
+ const target = [...path, key];
388
+ if (isVolatilePath(schema, target))
389
+ Reflect.deleteProperty(result, key);
390
+ else if (isPlainObject(result[key]))
391
+ result[key] = strip(result[key], field, target);
615
392
  }
616
- this.warnListenerFailure(registration.ns, error);
617
- }
618
- }
619
- if (invariantFailure !== undefined)
620
- throw invariantFailure;
621
- }
622
- /** Contained-watcher diagnostic shared by the sync and async failure paths. */
623
- warnWatcherFailure(ns, error) {
624
- this.ctx.logger.warn('settings: watcher for "%s" failed', ns);
625
- this.ctx.logger.warn(error);
393
+ return result;
394
+ };
395
+ return mergeLayers(strip(raw, form), next);
396
+ });
397
+ this.describe();
626
398
  }
627
- /** Contained-listener diagnostic shared by the sync and async failure paths. */
628
- warnListenerFailure(ns, error) {
629
- this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns);
630
- this.ctx.logger.warn(error);
399
+ schema(entry) {
400
+ const schema = entry.fiber?.runtime?.Config;
401
+ return schema !== undefined && 'toJSON' in schema ? schema : undefined;
631
402
  }
632
403
  }
633
- /**
634
- * Value mirror of the `FiberState` members {@link isUnloading} compares
635
- * against: a const enum has no runtime object to import, and the value is
636
- * needed at runtime (same rationale as the CLI boot driver's mirror).
637
- */
638
- const FIBER_DISPOSED = 4;
639
- const FIBER_UNLOADING = 5;
640
- /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
641
- function isUnloading(ctx) {
642
- const state = ctx.fiber.state;
643
- return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
644
- }
645
- export default SettingsProvider;
404
+ export default SettingsForms;
646
405
  //# sourceMappingURL=index.js.map