@hasna-internal/kai-settings 0.1.1-rc.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/index.js ADDED
@@ -0,0 +1,638 @@
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 @hasna-internal/kai-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
+ /**
55
+ * Remove every `role('secret')` field a schema declares from a value. The
56
+ * walker follows `object`, `dict`, and `array` containers; a secret must be
57
+ * declared directly on a field reachable through those containers (a secret
58
+ * buried inside a union branch or transform is not reachable and must not be
59
+ * modeled that way). The input is never mutated.
60
+ * @param schema - live schemastery schema describing the value.
61
+ * @param value - the value to strip; `undefined` yields an empty record with
62
+ * object-property secret slots still enumerated.
63
+ * @returns the stripped detached value and the ordered secret positions.
64
+ */
65
+ function redactSecrets(schema, value) {
66
+ const secrets = [];
67
+ return {
68
+ value: walk(schema, value, [], secrets),
69
+ secrets
70
+ };
71
+ }
72
+ //#endregion
73
+ //#region lib/types/index.js
74
+ /**
75
+ * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
76
+ * per-namespace sections; plugins register a namespace schema and read the
77
+ * resolved value, which layers schema defaults, the registrant's composition
78
+ * `base`, and the user document section, in that order.
79
+ * @module @hasna-internal/kai-settings
80
+ */
81
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
82
+ /**
83
+ * Brand a raw string as a {@link SettingsNamespace}.
84
+ * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
85
+ * @returns the branded namespace.
86
+ */
87
+ function settingsNamespace(value) {
88
+ if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
89
+ return value;
90
+ }
91
+ /**
92
+ * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
93
+ * Service Definition's single change-detection predicate, exported so the invariant
94
+ * companion checks exactly the implementation's relation.
95
+ * @param a - one JSON-compatible value.
96
+ * @param b - the other JSON-compatible value.
97
+ * @returns whether the two values are structurally equal.
98
+ */
99
+ function deepEqualJson(a, b) {
100
+ if (a === b) return true;
101
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
102
+ if (Array.isArray(a) || Array.isArray(b)) {
103
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
104
+ return a.every((entry, index) => deepEqualJson(entry, b[index]));
105
+ }
106
+ const left = a;
107
+ const right = b;
108
+ const keys = Object.keys(left);
109
+ if (keys.length !== Object.keys(right).length) return false;
110
+ return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
111
+ }
112
+ /**
113
+ * A write refused because the namespace moved since the caller read it. The
114
+ * Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
115
+ * from one holding a stale snapshot, which is what this reports.
116
+ */
117
+ var SettingsConflictError = class extends Error {
118
+ /** Stable machine code for wire layers mapping this to their own taxonomy. */
119
+ code = "SETTINGS_CONFLICT";
120
+ /** The revision the write expected. */
121
+ expected;
122
+ /** The revision the namespace actually stands at. */
123
+ actual;
124
+ /**
125
+ * @param ns - the namespace whose write was refused.
126
+ * @param expected - the revision the caller sent.
127
+ * @param actual - the revision now stored.
128
+ */
129
+ constructor(ns, expected, actual) {
130
+ super(`settings namespace "${ns}" changed since it was read (expected revision ${String(expected)}, now ${String(actual)})`);
131
+ this.name = "SettingsConflictError";
132
+ this.expected = expected;
133
+ this.actual = actual;
134
+ }
135
+ };
136
+ /** Whether a value is a plain data object (not an array, null, or class instance). */
137
+ function isPlainObject(value) {
138
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
139
+ const proto = Object.getPrototypeOf(value);
140
+ return proto === Object.prototype || proto === null;
141
+ }
142
+ /** Apply one path op to a detached section, returning the next section. */
143
+ function applyPathOp(section, op) {
144
+ const [head, ...rest] = op.path;
145
+ if (head === void 0) {
146
+ if (op.op === "unset") return {};
147
+ if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
148
+ return { ...op.value };
149
+ }
150
+ if (rest.length === 0) {
151
+ if (op.op === "set") return {
152
+ ...section,
153
+ [head]: op.value
154
+ };
155
+ const { [head]: _removed, ...kept } = section;
156
+ return kept;
157
+ }
158
+ const child = section[head];
159
+ if (!isPlainObject(child)) {
160
+ if (op.op === "unset") return section;
161
+ return {
162
+ ...section,
163
+ [head]: applyPathOp({}, {
164
+ ...op,
165
+ path: rest
166
+ })
167
+ };
168
+ }
169
+ return {
170
+ ...section,
171
+ [head]: applyPathOp(child, {
172
+ ...op,
173
+ path: rest
174
+ })
175
+ };
176
+ }
177
+ /** Human label for a value that lossless JSON cannot represent (numbers reject inline). */
178
+ function describeRejected(value) {
179
+ if (value === void 0) return "undefined";
180
+ if (typeof value === "object" && value !== null) {
181
+ const name = Object.getPrototypeOf(value)?.constructor?.name;
182
+ return name === void 0 || name === "Object" ? "a non-plain object" : `a ${name}`;
183
+ }
184
+ return `a ${typeof value}`;
185
+ }
186
+ /**
187
+ * Detach and validate one write input in a single walk before persistence:
188
+ * only JSON data (plain objects, arrays, strings, finite numbers,
189
+ * booleans, `null`) may reach a provider document. `structuredClone` alone
190
+ * would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then
191
+ * silently distorts on the reload round-trip. `undefined` entries in objects
192
+ * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
193
+ * an `undefined` array entry is rejected rather than coerced.
194
+ * @param root - plain-object write input (caller-checked).
195
+ * @param reject - builds the validation error from a value label and its `$`-rooted path.
196
+ * @returns the detached JSON-compatible clone.
197
+ */
198
+ function cloneJsonShaped(root, reject) {
199
+ const visiting = /* @__PURE__ */ new WeakSet();
200
+ const clone = (value, path) => {
201
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
202
+ if (typeof value === "number") {
203
+ if (!Number.isFinite(value)) throw reject("a non-finite number", path);
204
+ return value;
205
+ }
206
+ if (Array.isArray(value)) {
207
+ if (visiting.has(value)) throw reject("a circular reference", path);
208
+ visiting.add(value);
209
+ const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`));
210
+ visiting.delete(value);
211
+ return entries;
212
+ }
213
+ if (isPlainObject(value)) {
214
+ if (visiting.has(value)) throw reject("a circular reference", path);
215
+ visiting.add(value);
216
+ const out = {};
217
+ for (const [key, entry] of Object.entries(value)) {
218
+ if (entry === void 0) continue;
219
+ out[key] = clone(entry, `${path}.${key}`);
220
+ }
221
+ visiting.delete(value);
222
+ return out;
223
+ }
224
+ throw reject(describeRejected(value), path);
225
+ };
226
+ return clone(root, "$");
227
+ }
228
+ /**
229
+ * Layer `over` onto `under`: plain objects merge recursively, every other
230
+ * value (arrays included) replaces the lower layer wholesale. `over` never
231
+ * carries `undefined` entries — sections come from parsed documents and write
232
+ * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
233
+ * cannot erase lower keys.
234
+ */
235
+ function mergeLayers(under, over) {
236
+ if (over === void 0) return under;
237
+ if (!isPlainObject(under) || !isPlainObject(over)) return over;
238
+ const merged = { ...under };
239
+ for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
240
+ return merged;
241
+ }
242
+ /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
243
+ function deepFreeze(value) {
244
+ if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
245
+ for (const entry of Object.values(value)) deepFreeze(entry);
246
+ return Object.freeze(value);
247
+ }
248
+ /**
249
+ * Abstract settings service. Providers implement raw-document storage
250
+ * (`load`/`persist`) and push external changes through {@link Settings.publish};
251
+ * the base class owns namespace registration, resolution, validation, change
252
+ * detection, and the `settings/updated` commit event.
253
+ */
254
+ var SettingsProvider = class extends Service {
255
+ registrations = /* @__PURE__ */ new Map();
256
+ /** Latest published raw document; empty until the provider's first publish. */
257
+ document = {};
258
+ /** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
259
+ writeQueues = /* @__PURE__ */ new Map();
260
+ /** In-flight watcher invocation segments, drained by the dispose teardown. */
261
+ pendingTails = /* @__PURE__ */ new Set();
262
+ /** Set at service dispose: refuse new writes while queued ones drain. */
263
+ stopped = false;
264
+ /** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
265
+ isStopped() {
266
+ return this.stopped;
267
+ }
268
+ constructor(ctx) {
269
+ super(ctx, "settings");
270
+ }
271
+ /**
272
+ * Load the provider's document once and publish it before the service
273
+ * becomes injectable, and register the write-drain teardown. Providers with
274
+ * their own init (watchers, connections) delegate here first via
275
+ * `yield* super[Service.init]()`; their disposers then run before the drain.
276
+ */
277
+ async *[Service.init]() {
278
+ yield async () => {
279
+ this.stopped = true;
280
+ await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails]);
281
+ };
282
+ this.publish(await this.load());
283
+ }
284
+ /**
285
+ * Absolute path of the provider's user-editable document, when its storage
286
+ * is one local file. Configuration surfaces use this only as availability
287
+ * metadata; the guarded open operation resolves the path again Host-side.
288
+ * Non-file providers leave it undefined and expose no open-document affordance.
289
+ * @returns the absolute local document path, or undefined for non-file storage.
290
+ */
291
+ get documentPath() {}
292
+ /**
293
+ * Prepare the provider's user-editable document for a native editor. File
294
+ * providers may materialize an absent document before returning its path;
295
+ * non-file providers return undefined.
296
+ * @returns the absolute local document path, or undefined for non-file storage.
297
+ */
298
+ prepareDocument() {
299
+ return Promise.resolve(this.documentPath);
300
+ }
301
+ /**
302
+ * Register a namespace schema and receive its owner scope. The registration
303
+ * is an effect on the calling plugin's fiber: disposing that fiber removes
304
+ * the namespace and its observers. An invalid stored section fails the
305
+ * registration itself — the earliest point where the schema can judge it.
306
+ * @param ns - unique namespace; duplicate registration fails loud.
307
+ * @param schema - schemastery schema resolving this namespace's value.
308
+ * @param options - composition `base` layer and effect timing.
309
+ * @returns the owner scope for reads, observation, and updates.
310
+ */
311
+ register(ns, schema, options) {
312
+ if (this.registrations.has(ns)) throw new Error(`settings namespace "${ns}" is already registered`);
313
+ const registration = {
314
+ ns,
315
+ schema,
316
+ base: options?.base,
317
+ applies: options?.applies ?? "live",
318
+ ...options?.validate === void 0 ? {} : { validate: options.validate },
319
+ resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
320
+ revision: 0,
321
+ watchers: /* @__PURE__ */ new Set()
322
+ };
323
+ this.ctx.effect(() => {
324
+ this.registrations.set(ns, registration);
325
+ return () => this.registrations.delete(ns);
326
+ }, `settings.register(${JSON.stringify(String(ns))})`);
327
+ return {
328
+ get: () => registration.resolved,
329
+ watch: (callback) => {
330
+ const watcher = {
331
+ callback,
332
+ tail: Promise.resolve(),
333
+ active: true
334
+ };
335
+ registration.watchers.add(watcher);
336
+ return () => {
337
+ watcher.active = false;
338
+ registration.watchers.delete(watcher);
339
+ };
340
+ },
341
+ update: (patch) => this.update(ns, patch),
342
+ replace: (section) => this.replace(ns, section)
343
+ };
344
+ }
345
+ /**
346
+ * Describe every registered namespace for configuration surfaces, including
347
+ * the composition `base` and raw user layers so a form can mark which fields
348
+ * the user overrode (presence in `user`) and what a reset returns to.
349
+ * @param options - redaction switch; wire surfaces must redact.
350
+ * @returns one descriptor per registered namespace, in registration order.
351
+ */
352
+ describe(options) {
353
+ return [...this.registrations.values()].map((registration) => {
354
+ let user;
355
+ try {
356
+ user = this.section(registration.ns);
357
+ } catch {
358
+ user = void 0;
359
+ }
360
+ const base = registration.base === void 0 ? void 0 : structuredClone(registration.base);
361
+ const detachedUser = user === void 0 ? void 0 : structuredClone(user);
362
+ const descriptor = {
363
+ ns: registration.ns,
364
+ schema: registration.schema.toJSON(),
365
+ value: registration.resolved,
366
+ revision: registration.revision,
367
+ ...base === void 0 ? {} : { base },
368
+ ...detachedUser === void 0 ? {} : { user: detachedUser },
369
+ applies: registration.applies
370
+ };
371
+ if (options?.redactSecrets !== true) return descriptor;
372
+ const schema = registration.schema;
373
+ const redacted = redactSecrets(schema, registration.resolved);
374
+ return {
375
+ ...descriptor,
376
+ value: redacted.value,
377
+ ...base === void 0 ? {} : { base: redactSecrets(schema, base).value },
378
+ ...detachedUser === void 0 ? {} : { user: redactSecrets(schema, detachedUser).value },
379
+ secrets: redacted.secrets
380
+ };
381
+ });
382
+ }
383
+ /**
384
+ * Read one registered namespace's resolved value.
385
+ * @param ns - the namespace to read.
386
+ * @returns the resolved value, or `undefined` while unregistered.
387
+ */
388
+ get(ns) {
389
+ return this.registrations.get(ns)?.resolved;
390
+ }
391
+ /**
392
+ * Merge a patch into one registered namespace's user layer, validate the
393
+ * resolved candidate, persist through the provider, then commit and emit.
394
+ * A validation failure rejects before anything is persisted. Writes to one
395
+ * namespace are serialized: concurrent updates apply in call order, each
396
+ * merging over the previous write's committed section.
397
+ * @param ns - the registered namespace to update.
398
+ * @param patch - plain-object patch over the user section.
399
+ * @param expectedRevision - the descriptor `revision` the caller read; a
400
+ * namespace that moved past it rejects with {@link SettingsConflictError}.
401
+ */
402
+ async update(ns, patch, expectedRevision) {
403
+ return this.write(ns, patch, "merge", expectedRevision);
404
+ }
405
+ /**
406
+ * Replace one registered namespace's user section wholesale, validate,
407
+ * persist, then commit and emit. Keys absent from `section` fall back to the
408
+ * composition `base` and schema defaults — this is the removal/reset path a
409
+ * merge-only patch cannot express (`replace({})` re-inherits everything).
410
+ * @param ns - the registered namespace to replace.
411
+ * @param section - the complete next user section.
412
+ * @param expectedRevision - the descriptor `revision` the caller read; a
413
+ * namespace that moved past it rejects with {@link SettingsConflictError}.
414
+ */
415
+ async replace(ns, section, expectedRevision) {
416
+ return this.write(ns, section, "replace", expectedRevision);
417
+ }
418
+ /**
419
+ * Apply path-addressed edits to one registered namespace's user section,
420
+ * validate, persist, then commit and emit. The ops are applied to the
421
+ * section as it stands when the write reaches the front of the queue, so a
422
+ * caller never has to restate fields it did not touch — and, crucially,
423
+ * cannot delete fields it never saw. This is the write path for any caller
424
+ * holding a redacted view; `replace` remains the wholesale reset.
425
+ * @param ns - the registered namespace to edit.
426
+ * @param ops - ordered path edits; later ops observe earlier ones.
427
+ * @param expectedRevision - the descriptor `revision` the caller read; a
428
+ * namespace that moved past it rejects with {@link SettingsConflictError}.
429
+ */
430
+ async mutate(ns, ops, expectedRevision) {
431
+ if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`);
432
+ for (const op of ops) {
433
+ if (!isPlainObject(op) || op["op"] !== "set" && op["op"] !== "unset") throw new TypeError(`settings mutate for "${ns}" ops must be {op:'set'|'unset', path}`);
434
+ if (!Array.isArray(op["path"]) || op["path"].some((part) => typeof part !== "string")) throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`);
435
+ }
436
+ return this.write(ns, ops, "mutate", expectedRevision);
437
+ }
438
+ /** Validate a write, then queue it on the namespace's serialized write chain. */
439
+ write(ns, input, mode, expectedRevision) {
440
+ const verb = mode === "merge" ? "update" : mode === "replace" ? "replace" : "mutate";
441
+ const registration = this.registrations.get(ns);
442
+ if (registration === void 0) throw new Error(`settings namespace "${ns}" is not registered`);
443
+ if (this.isStopped()) throw new Error(`settings service is disposed: "${ns}" cannot be written`);
444
+ if (!this.writable) throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`);
445
+ let payload;
446
+ if (mode === "mutate") payload = { ops: input };
447
+ else {
448
+ if (!isPlainObject(input)) throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`);
449
+ payload = input;
450
+ }
451
+ const snapshot = cloneJsonShaped(payload, (label, path) => /* @__PURE__ */ new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`));
452
+ const run = (this.writeQueues.get(ns) ?? Promise.resolve()).catch(() => void 0).then(async () => {
453
+ if (this.isStopped()) throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`);
454
+ if (this.registrations.get(ns) !== registration) throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`);
455
+ const current = this.section(ns) ?? {};
456
+ if (expectedRevision !== void 0 && expectedRevision !== registration.revision) throw new SettingsConflictError(ns, expectedRevision, registration.revision);
457
+ const section = mode === "merge" ? mergeLayers(current, snapshot) : mode === "replace" ? snapshot : snapshot["ops"].reduce(applyPathOp, current);
458
+ const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate));
459
+ await this.persist(ns, section);
460
+ this.document[ns] = section;
461
+ if (this.registrations.get(ns) === registration && !this.isStopped()) {
462
+ this.bumpRevision(registration, current, section);
463
+ this.commit(registration, next, "update");
464
+ }
465
+ });
466
+ this.writeQueues.set(ns, run);
467
+ return run;
468
+ }
469
+ /**
470
+ * Provider hook: commit a complete raw document observed in storage. Each
471
+ * registered namespace re-resolves; an invalid section keeps that
472
+ * namespace's last good value and warns, other namespaces still commit.
473
+ * @param doc - the detached raw document (unregistered sections preserved).
474
+ * @param source - change origin; defaults to `provider`.
475
+ */
476
+ publish(doc, source = "provider") {
477
+ const before = /* @__PURE__ */ new Map();
478
+ for (const registration of this.registrations.values()) try {
479
+ before.set(registration.ns, this.section(registration.ns));
480
+ } catch {
481
+ before.set(registration.ns, void 0);
482
+ }
483
+ this.document = doc;
484
+ for (const registration of this.registrations.values()) {
485
+ let next;
486
+ try {
487
+ next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate));
488
+ } catch (error) {
489
+ this.ctx.logger.warn("settings: keeping last good \"%s\" after invalid stored section", registration.ns);
490
+ this.ctx.logger.warn(error);
491
+ continue;
492
+ }
493
+ this.bumpRevision(registration, before.get(registration.ns), this.section(registration.ns));
494
+ this.commit(registration, next, source);
495
+ }
496
+ }
497
+ /** Read one namespace's raw user section, rejecting non-object sections. */
498
+ section(ns) {
499
+ const section = this.document[ns];
500
+ if (section === void 0) return void 0;
501
+ if (!isPlainObject(section)) throw new TypeError(`settings section "${ns}" must be an object of keys`);
502
+ return section;
503
+ }
504
+ /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
505
+ resolve(schema, base, section, validate) {
506
+ const value = schema(mergeLayers(base, section));
507
+ validate?.(value);
508
+ return value;
509
+ }
510
+ /**
511
+ * Advance a namespace's revision when its RAW section changed, and announce
512
+ * it. Deliberately independent of {@link commit}'s resolved-value equality:
513
+ * storing an override equal to the composition base leaves the resolved
514
+ * value alone but changes what the document says, which is exactly what a
515
+ * configuration surface must re-read.
516
+ */
517
+ bumpRevision(registration, before, after) {
518
+ if (deepEqualJson(before, after)) return;
519
+ registration.revision += 1;
520
+ this.emitDocumentUpdated(registration.ns, registration.revision);
521
+ }
522
+ /** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
523
+ emitDocumentUpdated(ns, revision) {
524
+ let invariantFailure;
525
+ const args = [
526
+ "settings/document-updated",
527
+ ns,
528
+ revision
529
+ ];
530
+ for (const listener of this.ctx.events.dispatch("emit", args)) try {
531
+ const returned = listener(ns, revision);
532
+ if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
533
+ this.warnListenerFailure(ns, error);
534
+ });
535
+ } catch (error) {
536
+ if (error?.code === "INVARIANT") {
537
+ invariantFailure ??= error;
538
+ continue;
539
+ }
540
+ this.warnListenerFailure(ns, error);
541
+ }
542
+ if (invariantFailure !== void 0) throw invariantFailure;
543
+ }
544
+ /** Commit a resolved value when changed: swap, notify watchers, emit the event. */
545
+ commit(registration, next, source) {
546
+ const prev = registration.resolved;
547
+ if (deepEqualJson(next, prev)) return;
548
+ registration.resolved = next;
549
+ for (const watcher of [...registration.watchers]) {
550
+ const segment = watcher.tail.then(() => {
551
+ if (!watcher.active || this.isStopped()) return;
552
+ return watcher.callback(next, prev);
553
+ }).then(() => void 0, (error) => {
554
+ this.warnWatcherFailure(registration.ns, error);
555
+ });
556
+ watcher.tail = segment;
557
+ this.pendingTails.add(segment);
558
+ segment.then(() => this.pendingTails.delete(segment));
559
+ }
560
+ let invariantFailure;
561
+ const args = [
562
+ "settings/updated",
563
+ registration.ns,
564
+ next,
565
+ prev,
566
+ source
567
+ ];
568
+ for (const listener of this.ctx.events.dispatch("emit", args)) try {
569
+ const returned = listener(registration.ns, next, prev, source);
570
+ if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
571
+ this.warnListenerFailure(registration.ns, error);
572
+ });
573
+ } catch (error) {
574
+ if (error?.code === "INVARIANT") {
575
+ invariantFailure ??= error;
576
+ continue;
577
+ }
578
+ this.warnListenerFailure(registration.ns, error);
579
+ }
580
+ if (invariantFailure !== void 0) throw invariantFailure;
581
+ }
582
+ /** Contained-watcher diagnostic shared by the sync and async failure paths. */
583
+ warnWatcherFailure(ns, error) {
584
+ this.ctx.logger.warn("settings: watcher for \"%s\" failed", ns);
585
+ this.ctx.logger.warn(error);
586
+ }
587
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
588
+ warnListenerFailure(ns, error) {
589
+ this.ctx.logger.warn("settings: a settings/updated listener for \"%s\" failed", ns);
590
+ this.ctx.logger.warn(error);
591
+ }
592
+ };
593
+ /**
594
+ * Value mirror of the `FiberState` members {@link isUnloading} compares
595
+ * against: a const enum has no runtime object to import, and the value is
596
+ * needed at runtime (same rationale as the CLI boot driver's mirror).
597
+ */
598
+ const FIBER_DISPOSED = 4;
599
+ const FIBER_UNLOADING = 5;
600
+ /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
601
+ function isUnloading(ctx) {
602
+ const state = ctx.fiber.state;
603
+ return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
604
+ }
605
+ /**
606
+ * Install the canonical optional-settings consumer wiring: while a settings
607
+ * service exists, register `ns` with the consumer's composition entry as the
608
+ * `base` layer and point the source thunk at the resolved scope; when the
609
+ * service goes away (disposal, provider reload), fall back to the entry so
610
+ * the consumer keeps working exactly as composed. The registration rides the
611
+ * scoped fiber, so no settings service ever mounted means none of this runs.
612
+ * @param ctx - consumer plugin context owning the wiring.
613
+ * @param ns - the consumer-owned settings namespace.
614
+ * @param schema - schema resolving the namespace (typically the plugin Config).
615
+ * @param entry - the consumer's composition entry config, used as `base`.
616
+ * @param hooks - source sink and change notification.
617
+ */
618
+ function installSettingsSection(ctx, ns, schema, entry, hooks) {
619
+ ctx.inject(["settings"], (sctx) => {
620
+ const scope = sctx.settings.register(ns, schema, {
621
+ base: entry,
622
+ ...hooks.validate === void 0 ? {} : { validate: hooks.validate }
623
+ });
624
+ hooks.setSource(() => scope.get());
625
+ sctx.effect(() => () => {
626
+ if (isUnloading(ctx)) return;
627
+ hooks.setSource(() => entry);
628
+ hooks.onChange();
629
+ });
630
+ hooks.onChange();
631
+ scope.watch(() => {
632
+ if (isUnloading(ctx)) return;
633
+ hooks.onChange();
634
+ });
635
+ });
636
+ }
637
+ //#endregion
638
+ export { SettingsConflictError, SettingsProvider, SettingsProvider as default, deepEqualJson, installSettingsSection, redactSecrets, settingsNamespace };