@frockbot/configuration-core 0.0.0 → 0.1.0
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/package.json +22 -6
- package/src/bot-id.ts +5 -0
- package/src/bot-identity.test.ts +186 -0
- package/src/errors.ts +18 -0
- package/src/identifiers.ts +40 -0
- package/src/index.test.ts +1387 -0
- package/src/index.ts +2498 -0
- package/src/package-settings.test.ts +200 -0
- package/src/package-settings.ts +335 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
|
|
3
|
+
import {
|
|
4
|
+
decodePackageSettingsPatchV1,
|
|
5
|
+
decodePackageSettingValuesV1,
|
|
6
|
+
emptyPackageSettingValuesV1,
|
|
7
|
+
MAX_PACKAGE_SETTINGS_V1,
|
|
8
|
+
resolvePackageSettingValuesV1,
|
|
9
|
+
} from "./package-settings.js";
|
|
10
|
+
|
|
11
|
+
function definition(
|
|
12
|
+
overrides: Partial<PackageSettingDefinition> & {
|
|
13
|
+
id: string;
|
|
14
|
+
},
|
|
15
|
+
): PackageSettingDefinition {
|
|
16
|
+
return {
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
scopes: ["user"],
|
|
19
|
+
schema: { type: "string" },
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const declared: PackageSettingDefinition[] = [
|
|
25
|
+
definition({
|
|
26
|
+
id: "model",
|
|
27
|
+
schema: { type: "string", enum: ["fast", "slow"] },
|
|
28
|
+
}),
|
|
29
|
+
definition({
|
|
30
|
+
id: "max-results",
|
|
31
|
+
schema: { type: "integer", minimum: 1, maximum: 10 },
|
|
32
|
+
}),
|
|
33
|
+
definition({ id: "verbose", schema: { type: "boolean" } }),
|
|
34
|
+
definition({
|
|
35
|
+
id: "endpoint",
|
|
36
|
+
schema: { type: "string", minLength: 4, maxLength: 32 },
|
|
37
|
+
}),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
describe("the Package setting values codec", () => {
|
|
41
|
+
test("accepts every declared scalar kind against its own schema", () => {
|
|
42
|
+
expect(
|
|
43
|
+
decodePackageSettingsPatchV1(declared, {
|
|
44
|
+
model: "fast",
|
|
45
|
+
"max-results": 3,
|
|
46
|
+
verbose: true,
|
|
47
|
+
endpoint: "https://example.test",
|
|
48
|
+
}),
|
|
49
|
+
).toEqual({
|
|
50
|
+
model: "fast",
|
|
51
|
+
"max-results": 3,
|
|
52
|
+
verbose: true,
|
|
53
|
+
endpoint: "https://example.test",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("refuses a setting the Package never declared", () => {
|
|
58
|
+
expect(() =>
|
|
59
|
+
decodePackageSettingsPatchV1(declared, { unknown: "value" }),
|
|
60
|
+
).toThrow(/not declared by this Package/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("refuses a value of the wrong type", () => {
|
|
64
|
+
expect(() =>
|
|
65
|
+
decodePackageSettingsPatchV1(declared, { "max-results": "3" }),
|
|
66
|
+
).toThrow(/must be a number/);
|
|
67
|
+
expect(() =>
|
|
68
|
+
decodePackageSettingsPatchV1(declared, { verbose: "yes" }),
|
|
69
|
+
).toThrow(/must be a boolean/);
|
|
70
|
+
expect(() => decodePackageSettingsPatchV1(declared, { model: 1 })).toThrow(
|
|
71
|
+
/must be a string/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("refuses a value outside the schema's bounds", () => {
|
|
76
|
+
expect(() =>
|
|
77
|
+
decodePackageSettingsPatchV1(declared, { "max-results": 11 }),
|
|
78
|
+
).toThrow(/is above 10/);
|
|
79
|
+
expect(() =>
|
|
80
|
+
decodePackageSettingsPatchV1(declared, { "max-results": 0 }),
|
|
81
|
+
).toThrow(/is below 1/);
|
|
82
|
+
expect(() =>
|
|
83
|
+
decodePackageSettingsPatchV1(declared, { "max-results": 2.5 }),
|
|
84
|
+
).toThrow(/must be an integer/);
|
|
85
|
+
expect(() =>
|
|
86
|
+
decodePackageSettingsPatchV1(declared, { endpoint: "abc" }),
|
|
87
|
+
).toThrow(/shorter than 4/);
|
|
88
|
+
expect(() =>
|
|
89
|
+
decodePackageSettingsPatchV1(declared, { endpoint: "x".repeat(33) }),
|
|
90
|
+
).toThrow(/longer than 32/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("refuses a value off the declared enum", () => {
|
|
94
|
+
expect(() =>
|
|
95
|
+
decodePackageSettingsPatchV1(declared, { model: "medium" }),
|
|
96
|
+
).toThrow(/is not one of/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("refuses a value that is not a scalar at all", () => {
|
|
100
|
+
expect(() =>
|
|
101
|
+
decodePackageSettingsPatchV1(declared, { model: { nested: true } }),
|
|
102
|
+
).toThrow(/must be a string, number or boolean/);
|
|
103
|
+
expect(() =>
|
|
104
|
+
decodePackageSettingsPatchV1(declared, { "max-results": Number.NaN }),
|
|
105
|
+
).toThrow(/not a finite number/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("refuses a setting whose declared type is an object or an array", () => {
|
|
109
|
+
const structured = [
|
|
110
|
+
definition({ id: "shape", schema: { type: "object" } }),
|
|
111
|
+
];
|
|
112
|
+
expect(() =>
|
|
113
|
+
decodePackageSettingsPatchV1(structured, { shape: "anything" }),
|
|
114
|
+
).toThrow(/is not a scalar setting/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("refuses a Connection-scoped setting, naming the Connection", () => {
|
|
118
|
+
const connectionScoped = [
|
|
119
|
+
definition({ id: "url", scopes: ["connection"] }),
|
|
120
|
+
];
|
|
121
|
+
expect(() =>
|
|
122
|
+
decodePackageSettingsPatchV1(connectionScoped, { url: "https://x.test" }),
|
|
123
|
+
).toThrow(/use a Connection/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("refuses a Bot-scoped setting: it has no home in User settings", () => {
|
|
127
|
+
const botScoped = [definition({ id: "tone", scopes: ["bot"] })];
|
|
128
|
+
expect(() =>
|
|
129
|
+
decodePackageSettingsPatchV1(botScoped, { tone: "terse" }),
|
|
130
|
+
).toThrow(/not a User-level setting/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("refuses a secret, whatever its schema says", () => {
|
|
134
|
+
const secret = [
|
|
135
|
+
{
|
|
136
|
+
...definition({ id: "api-key" }),
|
|
137
|
+
secret: true,
|
|
138
|
+
} as PackageSettingDefinition,
|
|
139
|
+
];
|
|
140
|
+
expect(() =>
|
|
141
|
+
decodePackageSettingsPatchV1(secret, { "api-key": "sk-live" }),
|
|
142
|
+
).toThrow(/use a Connection/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("bounds how many values one Package may carry", () => {
|
|
146
|
+
const many = Object.fromEntries(
|
|
147
|
+
Array.from({ length: MAX_PACKAGE_SETTINGS_V1 + 1 }, (_value, index) => [
|
|
148
|
+
`setting-${index}`,
|
|
149
|
+
"x",
|
|
150
|
+
]),
|
|
151
|
+
);
|
|
152
|
+
expect(() => decodePackageSettingsPatchV1(declared, many)).toThrow(
|
|
153
|
+
/too many/,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("decodes the durable record whole, and refuses another schema", () => {
|
|
158
|
+
expect(
|
|
159
|
+
decodePackageSettingValuesV1(declared, {
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
values: { verbose: false },
|
|
162
|
+
}),
|
|
163
|
+
).toEqual({ schemaVersion: 1, values: { verbose: false } });
|
|
164
|
+
expect(() =>
|
|
165
|
+
decodePackageSettingValuesV1(declared, {
|
|
166
|
+
schemaVersion: 2,
|
|
167
|
+
values: {},
|
|
168
|
+
}),
|
|
169
|
+
).toThrow(/unsupported/);
|
|
170
|
+
expect(emptyPackageSettingValuesV1()).toEqual({
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
values: {},
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("resolving stored values for a running Package", () => {
|
|
178
|
+
test("keeps what the Package declares and drops the rest", () => {
|
|
179
|
+
// `catalog-setup-field` is what a Catalog install wrote (ADR 0014): it is
|
|
180
|
+
// in the same bag and is not one of this Package's declared settings.
|
|
181
|
+
expect(
|
|
182
|
+
resolvePackageSettingValuesV1(declared, {
|
|
183
|
+
verbose: true,
|
|
184
|
+
"catalog-setup-field": "kept elsewhere",
|
|
185
|
+
}),
|
|
186
|
+
).toEqual({ verbose: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("drops a stored value the installed version no longer accepts", () => {
|
|
190
|
+
// The bag was written when the ceiling was higher. A Composition must not
|
|
191
|
+
// fail over it: the Package falls back to its own default instead.
|
|
192
|
+
expect(
|
|
193
|
+
resolvePackageSettingValuesV1(declared, { "max-results": 99 }),
|
|
194
|
+
).toEqual({});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("answers empty for a Package that has never been configured", () => {
|
|
198
|
+
expect(resolvePackageSettingValuesV1(declared, undefined)).toEqual({});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Package-level setting *values*: the durable configuration one installed
|
|
2
|
+
// Package carries for one User.
|
|
3
|
+
//
|
|
4
|
+
// A manifest declares `configuration.settings` — the knobs a Package offers —
|
|
5
|
+
// and until now nothing anywhere held a value for one, so every Package read
|
|
6
|
+
// its own default forever. This module is the codec that stands between a
|
|
7
|
+
// command and that durable state.
|
|
8
|
+
//
|
|
9
|
+
// WHAT BELONGS HERE, AND WHAT DOES NOT. A Package-level setting is
|
|
10
|
+
// configuration scoped to the User: a default model, a ceiling, an endpoint
|
|
11
|
+
// root. A *secret* never is. A credential reaches the keyring through a
|
|
12
|
+
// Connection and only through one, so a definition that declares itself secret
|
|
13
|
+
// is refused here with that answer rather than stored.
|
|
14
|
+
//
|
|
15
|
+
// STRICT IN, LENIENT OUT. A write is validated against the exact schema the
|
|
16
|
+
// Package declared: an unknown setting id, a wrong type, a value outside its
|
|
17
|
+
// bounds or off its enum is refused and nothing is stored. A read is lenient
|
|
18
|
+
// about keys it does not recognise, because the durable bag has a second
|
|
19
|
+
// writer — a Catalog install's setup `values` (ADR 0014) — whose keys a
|
|
20
|
+
// Package's declared settings need not cover. Refusing a read over one of
|
|
21
|
+
// those would take a Package's whole configuration away over a value written
|
|
22
|
+
// by someone else.
|
|
23
|
+
import type {
|
|
24
|
+
PackageSettingDefinition,
|
|
25
|
+
PackageSettingSchema,
|
|
26
|
+
PackageSettingSchemaValue,
|
|
27
|
+
} from "@frockbot/kernel-composition";
|
|
28
|
+
import { ConfigurationDecodeError } from "./errors.js";
|
|
29
|
+
import { isPublicIdentifier } from "./identifiers.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What one Package-level setting may hold. Scalars only: a Package-level value
|
|
33
|
+
* is a knob a User turns on a generated form, and an object or an array is a
|
|
34
|
+
* document, which belongs in a Package's own durable state rather than in the
|
|
35
|
+
* User's settings bag.
|
|
36
|
+
*/
|
|
37
|
+
export type PackageSettingValueV1 = string | number | boolean;
|
|
38
|
+
|
|
39
|
+
/** The durable record of one installed Package's setting values. */
|
|
40
|
+
export interface PackageSettingValuesV1 {
|
|
41
|
+
schemaVersion: 1;
|
|
42
|
+
values: Record<string, PackageSettingValueV1>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The durable per-User ceiling on how many values one Package may hold. */
|
|
46
|
+
export const MAX_PACKAGE_SETTINGS_V1 = 32;
|
|
47
|
+
|
|
48
|
+
/** The ceiling on one text value, whatever the schema's own `maxLength`. */
|
|
49
|
+
export const MAX_PACKAGE_SETTING_TEXT_V1 = 2_048;
|
|
50
|
+
|
|
51
|
+
/** An installed Package with no values yet. */
|
|
52
|
+
export function emptyPackageSettingValuesV1(): PackageSettingValuesV1 {
|
|
53
|
+
return { schemaVersion: 1, values: {} };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
57
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
58
|
+
throw new ConfigurationDecodeError(`${label} must be an object`);
|
|
59
|
+
}
|
|
60
|
+
return value as Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The declared type a value must satisfy. A schema that names none is treated
|
|
65
|
+
* as unconstrained in *kind* — every other rule it states still applies — so a
|
|
66
|
+
* Package that declares only an `enum` is honoured rather than refused.
|
|
67
|
+
*/
|
|
68
|
+
type ScalarSettingType = "string" | "number" | "integer" | "boolean";
|
|
69
|
+
|
|
70
|
+
function scalarType(
|
|
71
|
+
schema: PackageSettingSchema,
|
|
72
|
+
settingId: string,
|
|
73
|
+
): ScalarSettingType | undefined {
|
|
74
|
+
if (schema.type === undefined) return undefined;
|
|
75
|
+
if (
|
|
76
|
+
schema.type === "string" ||
|
|
77
|
+
schema.type === "number" ||
|
|
78
|
+
schema.type === "integer" ||
|
|
79
|
+
schema.type === "boolean"
|
|
80
|
+
) {
|
|
81
|
+
return schema.type;
|
|
82
|
+
}
|
|
83
|
+
throw new ConfigurationDecodeError(
|
|
84
|
+
`Package setting "${settingId}" is not a scalar setting`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matchesEnumValue(
|
|
89
|
+
value: PackageSettingValueV1,
|
|
90
|
+
candidate: PackageSettingSchemaValue,
|
|
91
|
+
): boolean {
|
|
92
|
+
return value === candidate;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkString(
|
|
96
|
+
value: string,
|
|
97
|
+
schema: PackageSettingSchema,
|
|
98
|
+
settingId: string,
|
|
99
|
+
): void {
|
|
100
|
+
if (value.length > MAX_PACKAGE_SETTING_TEXT_V1) {
|
|
101
|
+
throw new ConfigurationDecodeError(
|
|
102
|
+
`Package setting "${settingId}" is too long`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
106
|
+
throw new ConfigurationDecodeError(
|
|
107
|
+
`Package setting "${settingId}" is shorter than ${schema.minLength}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
111
|
+
throw new ConfigurationDecodeError(
|
|
112
|
+
`Package setting "${settingId}" is longer than ${schema.maxLength}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function checkNumber(
|
|
118
|
+
value: number,
|
|
119
|
+
schema: PackageSettingSchema,
|
|
120
|
+
settingId: string,
|
|
121
|
+
): void {
|
|
122
|
+
if (!Number.isFinite(value)) {
|
|
123
|
+
throw new ConfigurationDecodeError(
|
|
124
|
+
`Package setting "${settingId}" is not a finite number`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (schema.type === "integer" && !Number.isSafeInteger(value)) {
|
|
128
|
+
throw new ConfigurationDecodeError(
|
|
129
|
+
`Package setting "${settingId}" must be an integer`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
133
|
+
throw new ConfigurationDecodeError(
|
|
134
|
+
`Package setting "${settingId}" is below ${schema.minimum}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
138
|
+
throw new ConfigurationDecodeError(
|
|
139
|
+
`Package setting "${settingId}" is above ${schema.maximum}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
schema.exclusiveMinimum !== undefined &&
|
|
144
|
+
value <= schema.exclusiveMinimum
|
|
145
|
+
) {
|
|
146
|
+
throw new ConfigurationDecodeError(
|
|
147
|
+
`Package setting "${settingId}" is not above ${schema.exclusiveMinimum}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
schema.exclusiveMaximum !== undefined &&
|
|
152
|
+
value >= schema.exclusiveMaximum
|
|
153
|
+
) {
|
|
154
|
+
throw new ConfigurationDecodeError(
|
|
155
|
+
`Package setting "${settingId}" is not below ${schema.exclusiveMaximum}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (schema.multipleOf !== undefined && schema.multipleOf > 0) {
|
|
159
|
+
const quotient = value / schema.multipleOf;
|
|
160
|
+
if (!Number.isInteger(Number(quotient.toFixed(10)))) {
|
|
161
|
+
throw new ConfigurationDecodeError(
|
|
162
|
+
`Package setting "${settingId}" is not a multiple of ${schema.multipleOf}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One value, against the definition its Package declared.
|
|
170
|
+
*
|
|
171
|
+
* The scope check is the constitution's, not a convenience: Package
|
|
172
|
+
* availability is User-level, so a User-level bag holds a `user`-scoped
|
|
173
|
+
* setting. A `connection`-scoped one belongs to a Connection and a `bot`-only
|
|
174
|
+
* one to a Bot, and neither has a home here.
|
|
175
|
+
*/
|
|
176
|
+
export function decodePackageSettingValueV1(
|
|
177
|
+
definition: PackageSettingDefinition,
|
|
178
|
+
value: unknown,
|
|
179
|
+
): PackageSettingValueV1 {
|
|
180
|
+
const settingId = definition.id;
|
|
181
|
+
// Defensive, and deliberately not dead: `PackageSettingDefinition` carries no
|
|
182
|
+
// `secret` flag today, and the manifest decoder refuses unknown fields, so
|
|
183
|
+
// one cannot arrive. The day the schema grows one, a secret must be refused
|
|
184
|
+
// here rather than quietly stored in User settings the client reads back.
|
|
185
|
+
if ((definition as { secret?: unknown }).secret === true) {
|
|
186
|
+
throw new ConfigurationDecodeError(
|
|
187
|
+
`Package setting "${settingId}" is a secret: use a Connection`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (!definition.scopes.includes("user")) {
|
|
191
|
+
throw new ConfigurationDecodeError(
|
|
192
|
+
definition.scopes.includes("connection")
|
|
193
|
+
? `Package setting "${settingId}" is Connection-scoped: use a Connection`
|
|
194
|
+
: `Package setting "${settingId}" is not a User-level setting`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const schema = definition.schema;
|
|
198
|
+
const declared = scalarType(schema, settingId);
|
|
199
|
+
if (
|
|
200
|
+
typeof value !== "string" &&
|
|
201
|
+
typeof value !== "number" &&
|
|
202
|
+
typeof value !== "boolean"
|
|
203
|
+
) {
|
|
204
|
+
throw new ConfigurationDecodeError(
|
|
205
|
+
`Package setting "${settingId}" must be a string, number or boolean`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (declared === "boolean" && typeof value !== "boolean") {
|
|
209
|
+
throw new ConfigurationDecodeError(
|
|
210
|
+
`Package setting "${settingId}" must be a boolean`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (declared === "string" && typeof value !== "string") {
|
|
214
|
+
throw new ConfigurationDecodeError(
|
|
215
|
+
`Package setting "${settingId}" must be a string`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
(declared === "number" || declared === "integer") &&
|
|
220
|
+
typeof value !== "number"
|
|
221
|
+
) {
|
|
222
|
+
throw new ConfigurationDecodeError(
|
|
223
|
+
`Package setting "${settingId}" must be a number`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (schema.const !== undefined && !matchesEnumValue(value, schema.const)) {
|
|
227
|
+
throw new ConfigurationDecodeError(
|
|
228
|
+
`Package setting "${settingId}" must be ${JSON.stringify(schema.const)}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
schema.enum !== undefined &&
|
|
233
|
+
!schema.enum.some((candidate) => matchesEnumValue(value, candidate))
|
|
234
|
+
) {
|
|
235
|
+
throw new ConfigurationDecodeError(
|
|
236
|
+
`Package setting "${settingId}" is not one of ${JSON.stringify(schema.enum)}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (typeof value === "string") checkString(value, schema, settingId);
|
|
240
|
+
if (typeof value === "number") checkNumber(value, schema, settingId);
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function definitionsById(
|
|
245
|
+
definitions: readonly PackageSettingDefinition[],
|
|
246
|
+
): Map<string, PackageSettingDefinition> {
|
|
247
|
+
const map = new Map<string, PackageSettingDefinition>();
|
|
248
|
+
for (const definition of definitions) map.set(definition.id, definition);
|
|
249
|
+
return map;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The `values` a `user/set-package-settings` command carries: a *partial*
|
|
254
|
+
* update, so only the ids it names are decoded. Every one of them must be a
|
|
255
|
+
* setting the Package declares at User scope.
|
|
256
|
+
*/
|
|
257
|
+
export function decodePackageSettingsPatchV1(
|
|
258
|
+
definitions: readonly PackageSettingDefinition[],
|
|
259
|
+
input: unknown,
|
|
260
|
+
): Record<string, PackageSettingValueV1> {
|
|
261
|
+
const values = record(input, "values");
|
|
262
|
+
const entries = Object.entries(values);
|
|
263
|
+
if (entries.length > MAX_PACKAGE_SETTINGS_V1) {
|
|
264
|
+
throw new ConfigurationDecodeError("Package settings are too many");
|
|
265
|
+
}
|
|
266
|
+
const declared = definitionsById(definitions);
|
|
267
|
+
const decoded: Record<string, PackageSettingValueV1> = {};
|
|
268
|
+
for (const [settingId, value] of entries) {
|
|
269
|
+
if (!isPublicIdentifier(settingId)) {
|
|
270
|
+
throw new ConfigurationDecodeError("Package setting id is invalid");
|
|
271
|
+
}
|
|
272
|
+
const definition = declared.get(settingId);
|
|
273
|
+
if (!definition) {
|
|
274
|
+
throw new ConfigurationDecodeError(
|
|
275
|
+
`Package setting "${settingId}" is not declared by this Package`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
decoded[settingId] = decodePackageSettingValueV1(definition, value);
|
|
279
|
+
}
|
|
280
|
+
return decoded;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The durable record, decoded whole. */
|
|
284
|
+
export function decodePackageSettingValuesV1(
|
|
285
|
+
definitions: readonly PackageSettingDefinition[],
|
|
286
|
+
input: unknown,
|
|
287
|
+
): PackageSettingValuesV1 {
|
|
288
|
+
const value = record(input, "Package setting values");
|
|
289
|
+
if (value.schemaVersion !== 1) {
|
|
290
|
+
throw new ConfigurationDecodeError(
|
|
291
|
+
"unsupported Package setting values schema",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (
|
|
295
|
+
Object.keys(value).some(
|
|
296
|
+
(key) => key !== "schemaVersion" && key !== "values",
|
|
297
|
+
)
|
|
298
|
+
) {
|
|
299
|
+
throw new ConfigurationDecodeError("Package setting values are invalid");
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
schemaVersion: 1,
|
|
303
|
+
values: decodePackageSettingsPatchV1(definitions, value.values),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The read side: the values an installed Package's stored bag holds *for the
|
|
309
|
+
* settings it declares*, and nothing else.
|
|
310
|
+
*
|
|
311
|
+
* Anything the Package does not declare, or that no longer satisfies the
|
|
312
|
+
* schema of the version now installed, is dropped rather than raised. The bag
|
|
313
|
+
* is written by two writers and read on every Turn: a Catalog setup value with
|
|
314
|
+
* no matching declaration, or a value a Package narrowed in a later version,
|
|
315
|
+
* must leave the Package on its default rather than fail Composition.
|
|
316
|
+
*/
|
|
317
|
+
export function resolvePackageSettingValuesV1(
|
|
318
|
+
definitions: readonly PackageSettingDefinition[],
|
|
319
|
+
stored: Readonly<Record<string, unknown>> | undefined,
|
|
320
|
+
): Record<string, PackageSettingValueV1> {
|
|
321
|
+
if (!stored) return {};
|
|
322
|
+
const resolved: Record<string, PackageSettingValueV1> = {};
|
|
323
|
+
for (const definition of definitions) {
|
|
324
|
+
if (!Object.hasOwn(stored, definition.id)) continue;
|
|
325
|
+
try {
|
|
326
|
+
resolved[definition.id] = decodePackageSettingValueV1(
|
|
327
|
+
definition,
|
|
328
|
+
stored[definition.id],
|
|
329
|
+
);
|
|
330
|
+
} catch {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return resolved;
|
|
335
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": ["ES2023", "DOM"],
|
|
11
|
+
"types": ["bun"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
package/README.md
DELETED