@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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +45 -0
- package/README.zh.md +45 -0
- package/lib/index.js +638 -0
- package/lib/invariant.js +180 -0
- package/lib/types/index.d.ts +343 -0
- package/lib/types/index.js +688 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +41 -0
- package/lib/types/redact.d.ts +40 -0
- package/lib/types/redact.js +78 -0
- package/lib/types/types.d.ts +46 -0
- package/lib/types/types.js +10 -0
- package/package.json +51 -0
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
//#endregion
|
|
55
|
+
//#region lib/types/index.js
|
|
56
|
+
/**
|
|
57
|
+
* Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
|
|
58
|
+
* per-namespace sections; plugins register a namespace schema and read the
|
|
59
|
+
* resolved value, which layers schema defaults, the registrant's composition
|
|
60
|
+
* `base`, and the user document section, in that order.
|
|
61
|
+
* @module @hasna-internal/kai-settings
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
65
|
+
* Service Definition's single change-detection predicate, exported so the invariant
|
|
66
|
+
* companion checks exactly the implementation's relation.
|
|
67
|
+
* @param a - one JSON-compatible value.
|
|
68
|
+
* @param b - the other JSON-compatible value.
|
|
69
|
+
* @returns whether the two values are structurally equal.
|
|
70
|
+
*/
|
|
71
|
+
function deepEqualJson(a, b) {
|
|
72
|
+
if (a === b) return true;
|
|
73
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
74
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
75
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
76
|
+
return a.every((entry, index) => deepEqualJson(entry, b[index]));
|
|
77
|
+
}
|
|
78
|
+
const left = a;
|
|
79
|
+
const right = b;
|
|
80
|
+
const keys = Object.keys(left);
|
|
81
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
82
|
+
return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
|
|
83
|
+
}
|
|
84
|
+
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
|
85
|
+
function isPlainObject(value) {
|
|
86
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
87
|
+
const proto = Object.getPrototypeOf(value);
|
|
88
|
+
return proto === Object.prototype || proto === null;
|
|
89
|
+
}
|
|
90
|
+
/** Apply one path op to a detached section, returning the next section. */
|
|
91
|
+
function applyPathOp(section, op) {
|
|
92
|
+
const [head, ...rest] = op.path;
|
|
93
|
+
if (head === void 0) {
|
|
94
|
+
if (op.op === "unset") return {};
|
|
95
|
+
if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
|
|
96
|
+
return { ...op.value };
|
|
97
|
+
}
|
|
98
|
+
if (rest.length === 0) {
|
|
99
|
+
if (op.op === "set") return {
|
|
100
|
+
...section,
|
|
101
|
+
[head]: op.value
|
|
102
|
+
};
|
|
103
|
+
const { [head]: _removed, ...kept } = section;
|
|
104
|
+
return kept;
|
|
105
|
+
}
|
|
106
|
+
const child = section[head];
|
|
107
|
+
if (!isPlainObject(child)) {
|
|
108
|
+
if (op.op === "unset") return section;
|
|
109
|
+
return {
|
|
110
|
+
...section,
|
|
111
|
+
[head]: applyPathOp({}, {
|
|
112
|
+
...op,
|
|
113
|
+
path: rest
|
|
114
|
+
})
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
...section,
|
|
119
|
+
[head]: applyPathOp(child, {
|
|
120
|
+
...op,
|
|
121
|
+
path: rest
|
|
122
|
+
})
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Layer `over` onto `under`: plain objects merge recursively, every other
|
|
127
|
+
* value (arrays included) replaces the lower layer wholesale. `over` never
|
|
128
|
+
* carries `undefined` entries — sections come from parsed documents and write
|
|
129
|
+
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
|
130
|
+
* cannot erase lower keys.
|
|
131
|
+
*/
|
|
132
|
+
function mergeLayers(under, over) {
|
|
133
|
+
if (over === void 0) return under;
|
|
134
|
+
if (!isPlainObject(under) || !isPlainObject(over)) return over;
|
|
135
|
+
const merged = { ...under };
|
|
136
|
+
for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
|
139
|
+
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
|
140
|
+
function deepFreeze(value) {
|
|
141
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
142
|
+
for (const entry of Object.values(value)) deepFreeze(entry);
|
|
143
|
+
return Object.freeze(value);
|
|
144
|
+
}
|
|
145
|
+
Service.init;
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region lib/types/invariant.js
|
|
148
|
+
/**
|
|
149
|
+
* Package-owned invariant companion for `@hasna-internal/kai-settings`.
|
|
150
|
+
* @module @hasna-internal/kai-settings/invariant
|
|
151
|
+
*/
|
|
152
|
+
const PACKAGE_NAME = "@hasna-internal/kai-settings";
|
|
153
|
+
/** Cordis companion plugin name. */
|
|
154
|
+
const name = "settings-invariant";
|
|
155
|
+
/** Service required before the companion can reserve package ownership. */
|
|
156
|
+
const inject = ["invariants"];
|
|
157
|
+
/**
|
|
158
|
+
* Install the commit-event contract: `settings/updated` fires only for a
|
|
159
|
+
* currently registered namespace, only when the resolved value changed, and
|
|
160
|
+
* only with the service's authoritative resolved value — all judged with the
|
|
161
|
+
* seam's own equality predicate.
|
|
162
|
+
*/
|
|
163
|
+
const install = (ctx, fail) => {
|
|
164
|
+
ctx.on("settings/updated", (ns, next, prev) => {
|
|
165
|
+
const settings = ctx.get("settings");
|
|
166
|
+
if (settings === void 0) fail(`settings/updated for "${ns}" emitted without a live settings service`);
|
|
167
|
+
const current = settings.get(ns);
|
|
168
|
+
if (current === void 0) fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`);
|
|
169
|
+
if (!deepEqualJson(current, next)) fail(`settings/updated for "${ns}" does not match the authoritative resolved value`);
|
|
170
|
+
if (deepEqualJson(next, prev)) fail(`settings/updated for "${ns}" emitted without a resolved-value change`);
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Register this package's invariant companion.
|
|
175
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
176
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
177
|
+
*/
|
|
178
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
179
|
+
//#endregion
|
|
180
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,343 @@
|
|
|
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 @hasna-internal/kai-settings
|
|
7
|
+
*/
|
|
8
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
9
|
+
import type z from '@deepseek-ai/schemastery';
|
|
10
|
+
import type { RedactedSecret } from './redact.ts';
|
|
11
|
+
import type { SettingsNamespace, SettingsUpdateSource } from './types.ts';
|
|
12
|
+
export { redactSecrets } from './redact.ts';
|
|
13
|
+
export type { RedactedSecret, RedactedValue } from './redact.ts';
|
|
14
|
+
export type { SettingsNamespace, SettingsUpdateSource } from './types.ts';
|
|
15
|
+
/**
|
|
16
|
+
* Brand a raw string as a {@link SettingsNamespace}.
|
|
17
|
+
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
|
18
|
+
* @returns the branded namespace.
|
|
19
|
+
*/
|
|
20
|
+
export declare function settingsNamespace(value: string): SettingsNamespace;
|
|
21
|
+
/** When a namespace's changes take effect for its owner. */
|
|
22
|
+
export type SettingsApplies = 'live' | 'restart';
|
|
23
|
+
/** Registration options beyond the namespace schema. */
|
|
24
|
+
export interface SettingsRegisterOptions<T> {
|
|
25
|
+
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
|
26
|
+
base?: Partial<T>;
|
|
27
|
+
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
|
28
|
+
applies?: SettingsApplies;
|
|
29
|
+
/**
|
|
30
|
+
* Reject a resolved section the owner could not act on, for constraints its
|
|
31
|
+
* schema cannot express — a cross-field requirement, or one field's validity
|
|
32
|
+
* depending on another's. Throwing here refuses the *write* that produced the
|
|
33
|
+
* value, so a caller learns at `update`/`replace`/`mutate` instead of storing
|
|
34
|
+
* something that would silently disable the owner.
|
|
35
|
+
*
|
|
36
|
+
* Kept separate from the schema because the schema is also what a
|
|
37
|
+
* configuration surface renders and what an absent section resolves through;
|
|
38
|
+
* folding a cross-field check into it would change both.
|
|
39
|
+
*
|
|
40
|
+
* Once the owner is registered, a stored section that fails this keeps the
|
|
41
|
+
* namespace's last good value and warns, exactly as a schema failure does,
|
|
42
|
+
* so an externally edited document cannot strand a running owner. At
|
|
43
|
+
* registration there is no last good value yet, so a stored section that
|
|
44
|
+
* already fails rejects the registration itself — again exactly as a schema
|
|
45
|
+
* failure does.
|
|
46
|
+
* @param value - the resolved section, schema-valid by construction.
|
|
47
|
+
*/
|
|
48
|
+
validate?: (value: T) => void;
|
|
49
|
+
}
|
|
50
|
+
/** One registered namespace as surfaced to configuration UIs. */
|
|
51
|
+
export interface SettingsDescriptor {
|
|
52
|
+
/** The registered namespace. */
|
|
53
|
+
ns: SettingsNamespace;
|
|
54
|
+
/** Serialized schemastery schema (`schema.toJSON()`). */
|
|
55
|
+
schema: unknown;
|
|
56
|
+
/** Current resolved value. */
|
|
57
|
+
value: unknown;
|
|
58
|
+
/**
|
|
59
|
+
* Monotonic revision of the raw user section this descriptor was read at.
|
|
60
|
+
* Send it back as `expectedRevision` on a write to refuse a stale one.
|
|
61
|
+
*/
|
|
62
|
+
revision: number;
|
|
63
|
+
/** Registrant's composition `base` layer (detached), when one was declared. */
|
|
64
|
+
base?: unknown;
|
|
65
|
+
/**
|
|
66
|
+
* Raw user section from the stored document (detached), when one exists and
|
|
67
|
+
* is well-formed; a field's presence here is what marks it user-overridden.
|
|
68
|
+
*/
|
|
69
|
+
user?: unknown;
|
|
70
|
+
/** Owner's declared effect timing. */
|
|
71
|
+
applies: SettingsApplies;
|
|
72
|
+
/** Schema-declared secret positions; present only under `redactSecrets`. */
|
|
73
|
+
secrets?: RedactedSecret[];
|
|
74
|
+
}
|
|
75
|
+
/** Options for {@link SettingsProvider.describe}. */
|
|
76
|
+
export interface SettingsDescribeOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Strip `role('secret')` fields from `value`/`base`/`user` and enumerate
|
|
79
|
+
* them in each descriptor's `secrets`. Every wire surface MUST pass this;
|
|
80
|
+
* the verbatim default exists for same-process configuration UIs only.
|
|
81
|
+
*/
|
|
82
|
+
redactSecrets?: boolean;
|
|
83
|
+
}
|
|
84
|
+
/** Owner-facing handle for one registered namespace. */
|
|
85
|
+
export interface SettingsScope<T> {
|
|
86
|
+
/** Current resolved value: schema defaults, then `base`, then the user layer. */
|
|
87
|
+
get(): T;
|
|
88
|
+
/**
|
|
89
|
+
* Observe committed changes to this namespace's resolved value. Invocations
|
|
90
|
+
* of one callback run asynchronously, one at a time, in commit order; a
|
|
91
|
+
* rejection is contained and logged like a sync throw. After the disposer
|
|
92
|
+
* returns, no further invocation starts — one already queued is skipped;
|
|
93
|
+
* one already started still settles, and service disposal waits for it.
|
|
94
|
+
* @param callback - invoked after each commit with the next and previous values.
|
|
95
|
+
* @returns the disposer removing this observer.
|
|
96
|
+
*/
|
|
97
|
+
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void;
|
|
98
|
+
/**
|
|
99
|
+
* Merge a partial patch into this namespace's user layer and persist it.
|
|
100
|
+
* @param patch - plain-object patch over the user section; JSON-compatible data
|
|
101
|
+
* only (non-JSON values reject with their path before anything persists).
|
|
102
|
+
*/
|
|
103
|
+
update(patch: object): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Replace this namespace's user section wholesale; absent keys re-inherit
|
|
106
|
+
* the composition `base` and schema defaults (`replace({})` resets all).
|
|
107
|
+
* @param section - the complete next user section; JSON-compatible data only,
|
|
108
|
+
* as for {@link update}.
|
|
109
|
+
*/
|
|
110
|
+
replace(section: object): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
declare module '@deepseek-ai/cordis' {
|
|
113
|
+
interface Context {
|
|
114
|
+
settings: SettingsProvider;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
119
|
+
* Service Definition's single change-detection predicate, exported so the invariant
|
|
120
|
+
* companion checks exactly the implementation's relation.
|
|
121
|
+
* @param a - one JSON-compatible value.
|
|
122
|
+
* @param b - the other JSON-compatible value.
|
|
123
|
+
* @returns whether the two values are structurally equal.
|
|
124
|
+
*/
|
|
125
|
+
export declare function deepEqualJson(a: unknown, b: unknown): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* A write refused because the namespace moved since the caller read it. The
|
|
128
|
+
* Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
|
|
129
|
+
* from one holding a stale snapshot, which is what this reports.
|
|
130
|
+
*/
|
|
131
|
+
export declare class SettingsConflictError extends Error {
|
|
132
|
+
/** Stable machine code for wire layers mapping this to their own taxonomy. */
|
|
133
|
+
readonly code = "SETTINGS_CONFLICT";
|
|
134
|
+
/** The revision the write expected. */
|
|
135
|
+
readonly expected: number;
|
|
136
|
+
/** The revision the namespace actually stands at. */
|
|
137
|
+
readonly actual: number;
|
|
138
|
+
/**
|
|
139
|
+
* @param ns - the namespace whose write was refused.
|
|
140
|
+
* @param expected - the revision the caller sent.
|
|
141
|
+
* @param actual - the revision now stored.
|
|
142
|
+
*/
|
|
143
|
+
constructor(ns: SettingsNamespace, expected: number, actual: number);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* One path-addressed edit to a namespace's user section. Path mutation exists
|
|
147
|
+
* for a caller holding an INCOMPLETE view of the section — a configuration UI
|
|
148
|
+
* reads the redacted descriptor, which by construction never received the
|
|
149
|
+
* `role('secret')` fields. Such a caller can name the field it means without
|
|
150
|
+
* restating the section: a wholesale `replace` rebuilt from a redacted
|
|
151
|
+
* document silently deletes every secret the wire never returned.
|
|
152
|
+
*/
|
|
153
|
+
export type SettingsPathOp = {
|
|
154
|
+
op: 'set';
|
|
155
|
+
path: readonly string[];
|
|
156
|
+
value: unknown;
|
|
157
|
+
} | {
|
|
158
|
+
op: 'unset';
|
|
159
|
+
path: readonly string[];
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Abstract settings service. Providers implement raw-document storage
|
|
163
|
+
* (`load`/`persist`) and push external changes through {@link Settings.publish};
|
|
164
|
+
* the base class owns namespace registration, resolution, validation, change
|
|
165
|
+
* detection, and the `settings/updated` commit event.
|
|
166
|
+
*/
|
|
167
|
+
export declare abstract class SettingsProvider extends Service {
|
|
168
|
+
private readonly registrations;
|
|
169
|
+
/** Latest published raw document; empty until the provider's first publish. */
|
|
170
|
+
private document;
|
|
171
|
+
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
|
|
172
|
+
private readonly writeQueues;
|
|
173
|
+
/** In-flight watcher invocation segments, drained by the dispose teardown. */
|
|
174
|
+
private readonly pendingTails;
|
|
175
|
+
/** Set at service dispose: refuse new writes while queued ones drain. */
|
|
176
|
+
private stopped;
|
|
177
|
+
/** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
|
|
178
|
+
private isStopped;
|
|
179
|
+
constructor(ctx: Context);
|
|
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
|
+
[Service.init](): AsyncGenerator<() => Promise<void> | void, void, void>;
|
|
187
|
+
/** Whether {@link update} may persist through this provider. */
|
|
188
|
+
abstract readonly writable: boolean;
|
|
189
|
+
/**
|
|
190
|
+
* Absolute path of the provider's user-editable document, when its storage
|
|
191
|
+
* is one local file. Configuration surfaces use this only as availability
|
|
192
|
+
* metadata; the guarded open operation resolves the path again Host-side.
|
|
193
|
+
* Non-file providers leave it undefined and expose no open-document affordance.
|
|
194
|
+
* @returns the absolute local document path, or undefined for non-file storage.
|
|
195
|
+
*/
|
|
196
|
+
get documentPath(): string | undefined;
|
|
197
|
+
/**
|
|
198
|
+
* Prepare the provider's user-editable document for a native editor. File
|
|
199
|
+
* providers may materialize an absent document before returning its path;
|
|
200
|
+
* non-file providers return undefined.
|
|
201
|
+
* @returns the absolute local document path, or undefined for non-file storage.
|
|
202
|
+
*/
|
|
203
|
+
prepareDocument(): Promise<string | undefined>;
|
|
204
|
+
/**
|
|
205
|
+
* Read the provider's current raw document (namespace to raw section).
|
|
206
|
+
* @returns the detached raw document.
|
|
207
|
+
*/
|
|
208
|
+
protected abstract load(): Promise<Record<string, unknown>>;
|
|
209
|
+
/**
|
|
210
|
+
* Durably store one namespace's merged user section.
|
|
211
|
+
* @param ns - the namespace being written.
|
|
212
|
+
* @param section - the complete merged user section to store.
|
|
213
|
+
*/
|
|
214
|
+
protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>;
|
|
215
|
+
/**
|
|
216
|
+
* Register a namespace schema and receive its owner scope. The registration
|
|
217
|
+
* is an effect on the calling plugin's fiber: disposing that fiber removes
|
|
218
|
+
* the namespace and its observers. An invalid stored section fails the
|
|
219
|
+
* registration itself — the earliest point where the schema can judge it.
|
|
220
|
+
* @param ns - unique namespace; duplicate registration fails loud.
|
|
221
|
+
* @param schema - schemastery schema resolving this namespace's value.
|
|
222
|
+
* @param options - composition `base` layer and effect timing.
|
|
223
|
+
* @returns the owner scope for reads, observation, and updates.
|
|
224
|
+
*/
|
|
225
|
+
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>;
|
|
226
|
+
/**
|
|
227
|
+
* Describe every registered namespace for configuration surfaces, including
|
|
228
|
+
* the composition `base` and raw user layers so a form can mark which fields
|
|
229
|
+
* the user overrode (presence in `user`) and what a reset returns to.
|
|
230
|
+
* @param options - redaction switch; wire surfaces must redact.
|
|
231
|
+
* @returns one descriptor per registered namespace, in registration order.
|
|
232
|
+
*/
|
|
233
|
+
describe(options?: SettingsDescribeOptions): SettingsDescriptor[];
|
|
234
|
+
/**
|
|
235
|
+
* Read one registered namespace's resolved value.
|
|
236
|
+
* @param ns - the namespace to read.
|
|
237
|
+
* @returns the resolved value, or `undefined` while unregistered.
|
|
238
|
+
*/
|
|
239
|
+
get(ns: SettingsNamespace): unknown;
|
|
240
|
+
/**
|
|
241
|
+
* Merge a patch into one registered namespace's user layer, validate the
|
|
242
|
+
* resolved candidate, persist through the provider, then commit and emit.
|
|
243
|
+
* A validation failure rejects before anything is persisted. Writes to one
|
|
244
|
+
* namespace are serialized: concurrent updates apply in call order, each
|
|
245
|
+
* merging over the previous write's committed section.
|
|
246
|
+
* @param ns - the registered namespace to update.
|
|
247
|
+
* @param patch - plain-object patch over the user section.
|
|
248
|
+
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
249
|
+
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
250
|
+
*/
|
|
251
|
+
update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void>;
|
|
252
|
+
/**
|
|
253
|
+
* Replace one registered namespace's user section wholesale, validate,
|
|
254
|
+
* persist, then commit and emit. Keys absent from `section` fall back to the
|
|
255
|
+
* composition `base` and schema defaults — this is the removal/reset path a
|
|
256
|
+
* merge-only patch cannot express (`replace({})` re-inherits everything).
|
|
257
|
+
* @param ns - the registered namespace to replace.
|
|
258
|
+
* @param section - the complete next user section.
|
|
259
|
+
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
260
|
+
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
261
|
+
*/
|
|
262
|
+
replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise<void>;
|
|
263
|
+
/**
|
|
264
|
+
* Apply path-addressed edits to one registered namespace's user section,
|
|
265
|
+
* validate, persist, then commit and emit. The ops are applied to the
|
|
266
|
+
* section as it stands when the write reaches the front of the queue, so a
|
|
267
|
+
* caller never has to restate fields it did not touch — and, crucially,
|
|
268
|
+
* cannot delete fields it never saw. This is the write path for any caller
|
|
269
|
+
* holding a redacted view; `replace` remains the wholesale reset.
|
|
270
|
+
* @param ns - the registered namespace to edit.
|
|
271
|
+
* @param ops - ordered path edits; later ops observe earlier ones.
|
|
272
|
+
* @param expectedRevision - the descriptor `revision` the caller read; a
|
|
273
|
+
* namespace that moved past it rejects with {@link SettingsConflictError}.
|
|
274
|
+
*/
|
|
275
|
+
mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void>;
|
|
276
|
+
/** Validate a write, then queue it on the namespace's serialized write chain. */
|
|
277
|
+
private write;
|
|
278
|
+
/**
|
|
279
|
+
* Provider hook: commit a complete raw document observed in storage. Each
|
|
280
|
+
* registered namespace re-resolves; an invalid section keeps that
|
|
281
|
+
* namespace's last good value and warns, other namespaces still commit.
|
|
282
|
+
* @param doc - the detached raw document (unregistered sections preserved).
|
|
283
|
+
* @param source - change origin; defaults to `provider`.
|
|
284
|
+
*/
|
|
285
|
+
protected publish(doc: Record<string, unknown>, source?: SettingsUpdateSource): void;
|
|
286
|
+
/** Read one namespace's raw user section, rejecting non-object sections. */
|
|
287
|
+
private section;
|
|
288
|
+
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
|
|
289
|
+
private resolve;
|
|
290
|
+
/**
|
|
291
|
+
* Advance a namespace's revision when its RAW section changed, and announce
|
|
292
|
+
* it. Deliberately independent of {@link commit}'s resolved-value equality:
|
|
293
|
+
* storing an override equal to the composition base leaves the resolved
|
|
294
|
+
* value alone but changes what the document says, which is exactly what a
|
|
295
|
+
* configuration surface must re-read.
|
|
296
|
+
*/
|
|
297
|
+
private bumpRevision;
|
|
298
|
+
/** Contained fan-out of `settings/document-updated`, mirroring {@link commit}'s. */
|
|
299
|
+
private emitDocumentUpdated;
|
|
300
|
+
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
|
|
301
|
+
private commit;
|
|
302
|
+
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
|
|
303
|
+
private warnWatcherFailure;
|
|
304
|
+
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
305
|
+
private warnListenerFailure;
|
|
306
|
+
}
|
|
307
|
+
/** Hooks a consumer hands to {@link installSettingsSection}. */
|
|
308
|
+
export interface SettingsSectionHooks<T> {
|
|
309
|
+
/**
|
|
310
|
+
* Receive the active configuration source: the resolved settings scope
|
|
311
|
+
* while one is attached, the composition entry otherwise. Called before
|
|
312
|
+
* the matching `onChange` at attach and at detach.
|
|
313
|
+
* @param current - thunk returning the currently authoritative value.
|
|
314
|
+
*/
|
|
315
|
+
setSource(current: () => T): void;
|
|
316
|
+
/**
|
|
317
|
+
* Re-judge anything derived from the source — registration-level facts,
|
|
318
|
+
* memoized resolutions — after an attach, a detach, or a committed change.
|
|
319
|
+
*/
|
|
320
|
+
onChange(): void;
|
|
321
|
+
/**
|
|
322
|
+
* Reject a resolved section this consumer could not act on, for constraints
|
|
323
|
+
* its schema cannot express. See {@link SettingsRegisterOptions.validate}.
|
|
324
|
+
* @param value - the resolved section, schema-valid by construction.
|
|
325
|
+
*/
|
|
326
|
+
validate?: (value: T) => void;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Install the canonical optional-settings consumer wiring: while a settings
|
|
330
|
+
* service exists, register `ns` with the consumer's composition entry as the
|
|
331
|
+
* `base` layer and point the source thunk at the resolved scope; when the
|
|
332
|
+
* service goes away (disposal, provider reload), fall back to the entry so
|
|
333
|
+
* the consumer keeps working exactly as composed. The registration rides the
|
|
334
|
+
* scoped fiber, so no settings service ever mounted means none of this runs.
|
|
335
|
+
* @param ctx - consumer plugin context owning the wiring.
|
|
336
|
+
* @param ns - the consumer-owned settings namespace.
|
|
337
|
+
* @param schema - schema resolving the namespace (typically the plugin Config).
|
|
338
|
+
* @param entry - the consumer's composition entry config, used as `base`.
|
|
339
|
+
* @param hooks - source sink and change notification.
|
|
340
|
+
*/
|
|
341
|
+
export declare function installSettingsSection<T>(ctx: Context, ns: SettingsNamespace, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>): void;
|
|
342
|
+
export default SettingsProvider;
|
|
343
|
+
//# sourceMappingURL=index.d.ts.map
|