@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.
- package/README.i18n.yaml +2 -2
- package/README.md +19 -96
- package/README.zh.md +25 -102
- package/lib/index.js +360 -426
- package/lib/types/index.d.ts +66 -283
- package/lib/types/index.js +271 -512
- package/lib/types/redact.d.ts +2 -4
- package/lib/types/redact.js +14 -8
- package/lib/types/schema.d.ts +24 -0
- package/lib/types/schema.js +85 -0
- package/lib/types/types.d.ts +16 -44
- package/lib/types/types.js +1 -9
- package/package.json +21 -17
- package/lib/invariant.js +0 -35
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -41
package/lib/types/index.js
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
const
|
|
69
|
-
if (!isPlainObject(
|
|
70
|
-
|
|
71
|
-
|
|
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 -
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* @
|
|
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
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
this.
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
250
|
+
try {
|
|
251
|
+
this.describe();
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.ownerContext.logger.error(error);
|
|
255
|
+
}
|
|
294
256
|
});
|
|
295
257
|
}
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
* @returns
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
*
|
|
341
|
-
* @param
|
|
342
|
-
* @
|
|
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
|
-
|
|
318
|
+
const input = cloneJsonShaped(patch);
|
|
319
|
+
await this.write(ns, current => mergeLayers(current, input), expectedRevision);
|
|
362
320
|
}
|
|
363
|
-
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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
|
-
|
|
327
|
+
const input = cloneJsonShaped(section);
|
|
328
|
+
await this.write(ns, (_current, base) => mergeLayers(base, input), expectedRevision);
|
|
376
329
|
}
|
|
377
|
-
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|