@lorion-org/runtime-config 1.0.0-beta.0 → 1.0.0-beta.1

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/src/project.ts ADDED
@@ -0,0 +1,208 @@
1
+ import { createRuntimeConfigKey, type RuntimeConfigKeyOptions } from './key';
2
+ import { toRuntimeConfigFragment, type NormalizeRuntimeConfigFragmentOptions } from './fragment';
3
+ import {
4
+ runtimeConfigVisibilities,
5
+ type NamedRuntimeConfigFragment,
6
+ type RuntimeConfigContext,
7
+ type RuntimeConfigFragment,
8
+ type RuntimeConfigFragmentMap,
9
+ type RuntimeConfigNamespaceProjection,
10
+ type RuntimeConfigSection,
11
+ type SectionedRuntimeConfig,
12
+ type ConfigVisibility,
13
+ } from './types';
14
+
15
+ export type ProjectSectionedRuntimeConfigOptions = RuntimeConfigKeyOptions &
16
+ NormalizeRuntimeConfigFragmentOptions & {
17
+ contextOutputKey?: string;
18
+ includeContexts?: boolean;
19
+ scopeIds?: string[];
20
+ };
21
+
22
+ export type ProjectRuntimeConfigFragmentOptions = Omit<
23
+ ProjectSectionedRuntimeConfigOptions,
24
+ 'scopeIds'
25
+ >;
26
+
27
+ export type ProjectRuntimeConfigNamespacesOptions = {
28
+ contextInputKey?: string;
29
+ contextId?: string;
30
+ namespaceStrategy?: 'nested' | 'flat';
31
+ scopeIds?: string[];
32
+ };
33
+
34
+ export type ProjectRuntimeConfigNamespaceOptions = Omit<
35
+ ProjectRuntimeConfigNamespacesOptions,
36
+ 'scopeIds'
37
+ >;
38
+
39
+ export function normalizeRuntimeConfigFragments(
40
+ fragments: Iterable<NamedRuntimeConfigFragment> | RuntimeConfigFragmentMap,
41
+ options: NormalizeRuntimeConfigFragmentOptions = {},
42
+ ): NamedRuntimeConfigFragment[] {
43
+ const entries =
44
+ fragments instanceof Map
45
+ ? Array.from(fragments.entries()).map(([scopeId, config]) => ({ scopeId, config }))
46
+ : Array.from(fragments);
47
+
48
+ return entries
49
+ .map((fragment) => ({
50
+ scopeId: fragment.scopeId.trim(),
51
+ config: toRuntimeConfigFragment(fragment.config, options),
52
+ }))
53
+ .filter((fragment) => fragment.scopeId.length > 0)
54
+ .sort((left, right) => left.scopeId.localeCompare(right.scopeId));
55
+ }
56
+
57
+ function filterFragments(input: {
58
+ fragments: Iterable<NamedRuntimeConfigFragment> | RuntimeConfigFragmentMap;
59
+ contextInputKey?: string;
60
+ scopeIds?: string[];
61
+ }): NamedRuntimeConfigFragment[] {
62
+ const scopeIdSet = input.scopeIds
63
+ ? new Set(input.scopeIds.map((id) => id.trim()).filter(Boolean))
64
+ : undefined;
65
+
66
+ return normalizeRuntimeConfigFragments(input.fragments, {
67
+ ...(input.contextInputKey ? { contextInputKey: input.contextInputKey } : {}),
68
+ }).filter((fragment) => !scopeIdSet || scopeIdSet.has(fragment.scopeId));
69
+ }
70
+
71
+ function getSection(
72
+ config: RuntimeConfigContext | undefined,
73
+ visibility: ConfigVisibility,
74
+ ): RuntimeConfigSection | undefined {
75
+ return config?.[visibility];
76
+ }
77
+
78
+ function assignDefined(target: RuntimeConfigSection, key: string, value: unknown): void {
79
+ if (value !== undefined) {
80
+ target[key] = value;
81
+ }
82
+ }
83
+
84
+ export function projectSectionedRuntimeConfig(
85
+ fragments: Iterable<NamedRuntimeConfigFragment> | RuntimeConfigFragmentMap,
86
+ options: ProjectSectionedRuntimeConfigOptions = {},
87
+ ): SectionedRuntimeConfig {
88
+ const result: SectionedRuntimeConfig = {
89
+ public: {},
90
+ private: {},
91
+ };
92
+ const contextOutputKey = options.contextOutputKey ?? '__contexts';
93
+
94
+ const filteredFragments = filterFragments({
95
+ fragments,
96
+ ...(options.contextInputKey ? { contextInputKey: options.contextInputKey } : {}),
97
+ ...(options.scopeIds ? { scopeIds: options.scopeIds } : {}),
98
+ });
99
+
100
+ for (const fragment of filteredFragments) {
101
+ for (const visibility of runtimeConfigVisibilities) {
102
+ const section = getSection(fragment.config, visibility);
103
+ if (!section) continue;
104
+
105
+ for (const [key, value] of Object.entries(section)) {
106
+ assignDefined(
107
+ result[visibility],
108
+ createRuntimeConfigKey(fragment.scopeId, key, options),
109
+ value,
110
+ );
111
+ }
112
+ }
113
+
114
+ if (options.includeContexts === false) continue;
115
+
116
+ for (const [contextId, context] of Object.entries(fragment.config.contexts ?? {})) {
117
+ for (const visibility of runtimeConfigVisibilities) {
118
+ const section = getSection(context, visibility);
119
+ if (!section) continue;
120
+
121
+ const contexts =
122
+ (result[visibility][contextOutputKey] as
123
+ | Record<string, RuntimeConfigSection>
124
+ | undefined) ?? {};
125
+ const contextValues = contexts[contextId] ?? {};
126
+
127
+ for (const [key, value] of Object.entries(section)) {
128
+ assignDefined(
129
+ contextValues,
130
+ createRuntimeConfigKey(fragment.scopeId, key, options),
131
+ value,
132
+ );
133
+ }
134
+
135
+ contexts[contextId] = contextValues;
136
+ result[visibility][contextOutputKey] = contexts;
137
+ }
138
+ }
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ export function projectRuntimeConfigFragment(
145
+ scopeId: string,
146
+ config: RuntimeConfigFragment,
147
+ options: ProjectRuntimeConfigFragmentOptions = {},
148
+ ): SectionedRuntimeConfig {
149
+ return projectSectionedRuntimeConfig(new Map([[scopeId, config]]), options);
150
+ }
151
+
152
+ export function projectRuntimeConfigNamespace(
153
+ scopeId: string,
154
+ config: RuntimeConfigFragment,
155
+ options: ProjectRuntimeConfigNamespaceOptions = {},
156
+ ): RuntimeConfigNamespaceProjection {
157
+ return projectRuntimeConfigNamespaces([{ scopeId, config }], options);
158
+ }
159
+
160
+ export function projectRuntimeConfigNamespaces(
161
+ fragments: Iterable<NamedRuntimeConfigFragment> | RuntimeConfigFragmentMap,
162
+ options: ProjectRuntimeConfigNamespacesOptions = {},
163
+ ): RuntimeConfigNamespaceProjection {
164
+ const result: RuntimeConfigNamespaceProjection = {
165
+ public: {},
166
+ };
167
+ const namespaceStrategy = options.namespaceStrategy ?? 'nested';
168
+
169
+ const filteredFragments = filterFragments({
170
+ fragments,
171
+ ...(options.contextInputKey ? { contextInputKey: options.contextInputKey } : {}),
172
+ ...(options.scopeIds ? { scopeIds: options.scopeIds } : {}),
173
+ });
174
+
175
+ for (const fragment of filteredFragments) {
176
+ const context = options.contextId ? fragment.config.contexts?.[options.contextId] : undefined;
177
+ const publicSection = {
178
+ ...(fragment.config.public ?? {}),
179
+ ...(context?.public ?? {}),
180
+ };
181
+ const privateSection = {
182
+ ...(fragment.config.private ?? {}),
183
+ ...(context?.private ?? {}),
184
+ };
185
+
186
+ if (namespaceStrategy === 'flat') {
187
+ Object.assign(result.public, publicSection);
188
+ Object.assign(result, privateSection);
189
+ continue;
190
+ }
191
+
192
+ if (Object.keys(publicSection).length) {
193
+ result.public[fragment.scopeId] = {
194
+ ...(result.public[fragment.scopeId] ?? {}),
195
+ ...publicSection,
196
+ };
197
+ }
198
+
199
+ if (Object.keys(privateSection).length) {
200
+ result[fragment.scopeId] = {
201
+ ...(result[fragment.scopeId] ?? {}),
202
+ ...privateSection,
203
+ };
204
+ }
205
+ }
206
+
207
+ return result;
208
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { createRuntimeConfigKey, type RuntimeConfigKeyOptions } from './key';
2
+ import type { ConfigVisibility, RuntimeConfigSection } from './types';
3
+
4
+ export type ResolveRuntimeConfigValueOptions<T = unknown> = RuntimeConfigKeyOptions & {
5
+ contextId?: string;
6
+ contextOutputKey?: string;
7
+ defaultValue?: T;
8
+ };
9
+
10
+ export type ResolveRuntimeConfigValueFromRuntimeConfigOptions<T = unknown> =
11
+ ResolveRuntimeConfigValueOptions<T> & {
12
+ visibility?: ConfigVisibility;
13
+ };
14
+
15
+ export function resolveRuntimeConfigValue<T = unknown>(
16
+ scopeConfig: RuntimeConfigSection | undefined,
17
+ scopeId: string,
18
+ key: string,
19
+ options: ResolveRuntimeConfigValueOptions<T> = {},
20
+ ): T | undefined {
21
+ const configKey = createRuntimeConfigKey(scopeId, key, options);
22
+ const contextOutputKey = options.contextOutputKey ?? '__contexts';
23
+
24
+ if (options.contextId) {
25
+ const contexts = scopeConfig?.[contextOutputKey] as
26
+ | Record<string, RuntimeConfigSection>
27
+ | undefined;
28
+ const contextValue = contexts?.[options.contextId]?.[configKey] as T | undefined;
29
+ if (contextValue !== undefined) {
30
+ return contextValue;
31
+ }
32
+ }
33
+
34
+ const globalValue = scopeConfig?.[configKey] as T | undefined;
35
+ if (globalValue !== undefined) {
36
+ return globalValue;
37
+ }
38
+
39
+ return options.defaultValue;
40
+ }
41
+
42
+ export function resolveRuntimeConfigValueFromRuntimeConfig<T = unknown>(
43
+ runtimeConfig: Partial<Record<ConfigVisibility, RuntimeConfigSection | undefined>>,
44
+ scopeId: string,
45
+ key: string,
46
+ options: ResolveRuntimeConfigValueFromRuntimeConfigOptions<T> = {},
47
+ ): T | undefined {
48
+ const { visibility = 'public', ...resolveOptions } = options;
49
+
50
+ return resolveRuntimeConfigValue(runtimeConfig[visibility], scopeId, key, resolveOptions);
51
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { stripRuntimeConfigScopePrefix, type RuntimeConfigKeyOptions } from './key';
2
+ import { resolveRuntimeConfigValue } from './resolve';
3
+ import type { ConfigVisibility, RuntimeConfigContext, RuntimeConfigSection } from './types';
4
+
5
+ export type GetRuntimeConfigScopeOptions = RuntimeConfigKeyOptions & {
6
+ contextId?: string;
7
+ contextOutputKey?: string;
8
+ keys?: string[];
9
+ visibility?: ConfigVisibility;
10
+ };
11
+
12
+ export type GetRuntimeConfigFragmentOptions = RuntimeConfigKeyOptions & {
13
+ contextId?: string;
14
+ contextOutputKey?: string;
15
+ keys?: Partial<Record<ConfigVisibility, string[]>>;
16
+ };
17
+
18
+ function assignDefined(target: RuntimeConfigSection, key: string, value: unknown): void {
19
+ if (value !== undefined) {
20
+ target[key] = value;
21
+ }
22
+ }
23
+
24
+ function collectRuntimeConfigScopeValues(input: {
25
+ scopeConfig: RuntimeConfigSection | undefined;
26
+ scopeId: string;
27
+ options: RuntimeConfigKeyOptions;
28
+ }): RuntimeConfigSection {
29
+ const values: RuntimeConfigSection = {};
30
+
31
+ for (const [configKey, value] of Object.entries(input.scopeConfig ?? {})) {
32
+ if (configKey.startsWith('__')) continue;
33
+
34
+ const key = stripRuntimeConfigScopePrefix(input.scopeId, configKey, input.options);
35
+ if (key) assignDefined(values, key, value);
36
+ }
37
+
38
+ return values;
39
+ }
40
+
41
+ export function getRuntimeConfigScope<T extends RuntimeConfigSection = RuntimeConfigSection>(
42
+ runtimeConfig: Partial<Record<ConfigVisibility, RuntimeConfigSection | undefined>>,
43
+ scopeId: string,
44
+ options: GetRuntimeConfigScopeOptions = {},
45
+ ): T {
46
+ const {
47
+ contextId,
48
+ contextOutputKey = '__contexts',
49
+ keys,
50
+ visibility = 'public',
51
+ ...keyOptions
52
+ } = options;
53
+ const scopeConfig = runtimeConfig[visibility];
54
+
55
+ if (keys) {
56
+ return Object.fromEntries(
57
+ keys
58
+ .map((key) => {
59
+ const value = resolveRuntimeConfigValue(scopeConfig, scopeId, key, {
60
+ ...keyOptions,
61
+ ...(contextId ? { contextId } : {}),
62
+ contextOutputKey,
63
+ });
64
+
65
+ return [key, value] as const;
66
+ })
67
+ .filter(([, value]) => value !== undefined),
68
+ ) as T;
69
+ }
70
+
71
+ const values = collectRuntimeConfigScopeValues({
72
+ scopeConfig,
73
+ scopeId,
74
+ options: keyOptions,
75
+ });
76
+
77
+ if (!contextId) return values as T;
78
+
79
+ const contexts = scopeConfig?.[contextOutputKey] as
80
+ | Record<string, RuntimeConfigSection>
81
+ | undefined;
82
+ const contextValues = collectRuntimeConfigScopeValues({
83
+ scopeConfig: contexts?.[contextId],
84
+ scopeId,
85
+ options: keyOptions,
86
+ });
87
+
88
+ return {
89
+ ...values,
90
+ ...contextValues,
91
+ } as T;
92
+ }
93
+
94
+ export function getPublicRuntimeConfigScope<T extends RuntimeConfigSection = RuntimeConfigSection>(
95
+ runtimeConfig: Partial<Record<ConfigVisibility, RuntimeConfigSection | undefined>>,
96
+ scopeId: string,
97
+ options: Omit<GetRuntimeConfigScopeOptions, 'visibility'> = {},
98
+ ): T {
99
+ return getRuntimeConfigScope<T>(runtimeConfig, scopeId, {
100
+ ...options,
101
+ visibility: 'public',
102
+ });
103
+ }
104
+
105
+ export function getPrivateRuntimeConfigScope<T extends RuntimeConfigSection = RuntimeConfigSection>(
106
+ runtimeConfig: Partial<Record<ConfigVisibility, RuntimeConfigSection | undefined>>,
107
+ scopeId: string,
108
+ options: Omit<GetRuntimeConfigScopeOptions, 'visibility'> = {},
109
+ ): T {
110
+ return getRuntimeConfigScope<T>(runtimeConfig, scopeId, {
111
+ ...options,
112
+ visibility: 'private',
113
+ });
114
+ }
115
+
116
+ export function getRuntimeConfigFragment(
117
+ runtimeConfig: Partial<Record<ConfigVisibility, RuntimeConfigSection | undefined>>,
118
+ scopeId: string,
119
+ options: GetRuntimeConfigFragmentOptions = {},
120
+ ): RuntimeConfigContext {
121
+ const { keys, ...scopeOptions } = options;
122
+ const publicOptions = keys?.public ? { ...scopeOptions, keys: keys.public } : scopeOptions;
123
+ const privateOptions = keys?.private ? { ...scopeOptions, keys: keys.private } : scopeOptions;
124
+
125
+ return {
126
+ public: getPublicRuntimeConfigScope(runtimeConfig, scopeId, publicOptions),
127
+ private: getPrivateRuntimeConfigScope(runtimeConfig, scopeId, privateOptions),
128
+ };
129
+ }
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ export type ConfigVisibility = 'public' | 'private';
2
+
3
+ export type RuntimeConfigSection = Record<string, unknown>;
4
+
5
+ export type RuntimeConfigContext = {
6
+ public?: RuntimeConfigSection;
7
+ private?: RuntimeConfigSection;
8
+ };
9
+
10
+ export type RuntimeConfigFragment = RuntimeConfigContext & {
11
+ contexts?: Record<string, RuntimeConfigContext>;
12
+ };
13
+
14
+ export type RuntimeConfigFragmentInput = RuntimeConfigFragment & Record<string, unknown>;
15
+
16
+ export type NamedRuntimeConfigFragment = {
17
+ scopeId: string;
18
+ config: RuntimeConfigFragmentInput;
19
+ };
20
+
21
+ export type RuntimeConfigFragmentMap = Map<string, RuntimeConfigFragment>;
22
+
23
+ export type SectionedRuntimeConfig = {
24
+ public: RuntimeConfigSection;
25
+ private: RuntimeConfigSection;
26
+ };
27
+
28
+ export type RuntimeConfigNamespaceProjection = RuntimeConfigSection & {
29
+ public: RuntimeConfigSection;
30
+ };
31
+
32
+ export type RuntimeEnvVars = Record<string, unknown>;
33
+
34
+ export const runtimeConfigVisibilities: ConfigVisibility[] = ['public', 'private'];
@@ -0,0 +1,126 @@
1
+ import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv';
2
+ import type { RuntimeConfigContext } from './types';
3
+
4
+ export type RuntimeConfigValidationMode = 'none' | 'optional' | 'startup' | 'onUse';
5
+
6
+ export type RuntimeConfigValidationPolicy = {
7
+ validation?: RuntimeConfigValidationMode;
8
+ };
9
+
10
+ export type RuntimeConfigValidationSchemaRegistry = Record<string, object>;
11
+
12
+ export type RuntimeConfigValidationErrorTarget = {
13
+ scopeId: string;
14
+ };
15
+
16
+ export type RuntimeConfigValidationErrorFormatter = (
17
+ target: RuntimeConfigValidationErrorTarget,
18
+ validationError: ErrorObject,
19
+ ) => Error;
20
+
21
+ export type RuntimeConfigValidationPolicyInput =
22
+ | RuntimeConfigValidationMode
23
+ | RuntimeConfigValidationPolicy
24
+ | undefined;
25
+
26
+ export const defaultRuntimeConfigValidationMode: RuntimeConfigValidationMode = 'optional';
27
+
28
+ export function resolveRuntimeConfigValidationMode(
29
+ input: RuntimeConfigValidationPolicyInput,
30
+ ): RuntimeConfigValidationMode {
31
+ return typeof input === 'string'
32
+ ? input
33
+ : (input?.validation ?? defaultRuntimeConfigValidationMode);
34
+ }
35
+
36
+ export function shouldRegisterRuntimeConfigValidationSchema(
37
+ input: RuntimeConfigValidationPolicyInput,
38
+ ): boolean {
39
+ return resolveRuntimeConfigValidationMode(input) !== 'none';
40
+ }
41
+
42
+ export function shouldValidateRuntimeConfigAtStartup(
43
+ input: RuntimeConfigValidationPolicyInput,
44
+ ): boolean {
45
+ const mode = resolveRuntimeConfigValidationMode(input);
46
+
47
+ return mode !== 'none' && mode !== 'onUse';
48
+ }
49
+
50
+ export function shouldRequireRuntimeConfigAtStartup(
51
+ input: RuntimeConfigValidationPolicyInput,
52
+ ): boolean {
53
+ return resolveRuntimeConfigValidationMode(input) === 'startup';
54
+ }
55
+
56
+ function formatDefaultRuntimeConfigValidationError(
57
+ target: RuntimeConfigValidationErrorTarget,
58
+ validationError: ErrorObject,
59
+ ): Error {
60
+ const jsonPath = validationError.instancePath || '/';
61
+ const schemaError = `${validationError.keyword}${validationError.message ? `: ${validationError.message}` : ''}`;
62
+
63
+ return new Error(
64
+ [
65
+ 'RuntimeConfig schema validation failed.',
66
+ `Scope: ${target.scopeId}`,
67
+ `JSON path: ${jsonPath}`,
68
+ `Schema error: ${schemaError}`,
69
+ ].join('\n'),
70
+ );
71
+ }
72
+
73
+ export class RuntimeConfigValidatorRegistry {
74
+ readonly #cache = new Map<string, ValidateFunction>();
75
+ readonly #formatError: RuntimeConfigValidationErrorFormatter;
76
+ readonly #schemas: RuntimeConfigValidationSchemaRegistry;
77
+
78
+ constructor(
79
+ schemas: RuntimeConfigValidationSchemaRegistry,
80
+ options: {
81
+ formatError?: RuntimeConfigValidationErrorFormatter;
82
+ } = {},
83
+ ) {
84
+ this.#formatError = options.formatError ?? formatDefaultRuntimeConfigValidationError;
85
+ this.#schemas = schemas;
86
+ }
87
+
88
+ get(scopeId: string): ValidateFunction {
89
+ const schema = this.#schemas[scopeId];
90
+
91
+ if (!schema) {
92
+ throw new Error(`RuntimeConfig validation schema not registered for scope "${scopeId}".`);
93
+ }
94
+
95
+ const cached = this.#cache.get(scopeId);
96
+ if (cached) return cached;
97
+
98
+ const validate = new Ajv({ strict: false, allErrors: false }).compile(schema);
99
+ this.#cache.set(scopeId, validate);
100
+
101
+ return validate;
102
+ }
103
+
104
+ assert(scopeId: string, fragment: RuntimeConfigContext): void {
105
+ const validate = this.get(scopeId);
106
+
107
+ if (validate(fragment)) return;
108
+
109
+ const validationError = validate.errors?.[0];
110
+
111
+ if (!validationError) {
112
+ throw new Error(`RuntimeConfig schema validation failed for scope "${scopeId}".`);
113
+ }
114
+
115
+ throw this.#formatError({ scopeId }, validationError);
116
+ }
117
+ }
118
+
119
+ export function createRuntimeConfigValidatorRegistry(
120
+ schemas: RuntimeConfigValidationSchemaRegistry,
121
+ options: {
122
+ formatError?: RuntimeConfigValidationErrorFormatter;
123
+ } = {},
124
+ ): RuntimeConfigValidatorRegistry {
125
+ return new RuntimeConfigValidatorRegistry(schemas, options);
126
+ }