@ontrails/config 0.2.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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * App config factory — declare a config contract once, discover and validate at runtime.
3
+ */
4
+
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { NotFoundError, Result, ValidationError } from '@ontrails/core';
8
+ import type { z } from 'zod';
9
+
10
+ import type { ConfigReport } from './doctor.js';
11
+ import { checkConfig } from './doctor.js';
12
+ import type { FieldDescription } from './derive-fields.js';
13
+ import { deriveConfigFields } from './derive-fields.js';
14
+ import type {
15
+ DeriveConfigProvenanceOptions,
16
+ ProvenanceEntry,
17
+ } from './derive-provenance.js';
18
+ import { deriveConfigProvenance } from './derive-provenance.js';
19
+ import type { ConfigRef } from './ref.js';
20
+ import { configRef } from './ref.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Supported config file formats. */
27
+ export type ConfigFormat = 'toml' | 'json' | 'jsonc' | 'yaml';
28
+
29
+ /** Options for creating an app config. */
30
+ export interface AppConfigOptions<T extends z.ZodType> {
31
+ readonly schema: T;
32
+ readonly formats?: readonly ConfigFormat[];
33
+ readonly dotfile?: boolean;
34
+ }
35
+
36
+ /** Options for resolving (discovering + parsing) a config file. */
37
+ export interface ResolveOptions {
38
+ /** Working directory for discovery. Defaults to `process.cwd()`. */
39
+ readonly cwd?: string;
40
+ /** Explicit file path — skips discovery when provided. */
41
+ readonly path?: string;
42
+ }
43
+
44
+ /** Options for the `explain()` method on AppConfig, excluding schema. */
45
+ export type AppConfigDeriveProvenanceOptions = Omit<
46
+ DeriveConfigProvenanceOptions<z.ZodType>,
47
+ 'schema'
48
+ >;
49
+
50
+ /** The resolved config contract returned by `appConfig()`. */
51
+ export interface AppConfig<T extends z.ZodType> {
52
+ readonly name: string;
53
+ readonly schema: T;
54
+ readonly formats: readonly ConfigFormat[];
55
+ readonly dotfile: boolean;
56
+ resolve(options?: ResolveOptions): Promise<Result<z.infer<T>, Error>>;
57
+
58
+ /** Describe all fields in the schema without needing values. */
59
+ describe(): readonly FieldDescription[];
60
+
61
+ /** Check a config object against the schema and return field reports. */
62
+ check(
63
+ values: Record<string, unknown>,
64
+ options?: { readonly env?: Record<string, string | undefined> }
65
+ ): ConfigReport;
66
+
67
+ /** Show which source won for each config field. */
68
+ explain(
69
+ options: AppConfigDeriveProvenanceOptions
70
+ ): readonly ProvenanceEntry[];
71
+
72
+ /** Create a lazy reference to a config field for use as a trail input default. */
73
+ ref(fieldPath: string): ConfigRef;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Default values
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const DEFAULT_FORMATS: readonly ConfigFormat[] = [
81
+ 'toml',
82
+ 'json',
83
+ 'jsonc',
84
+ 'yaml',
85
+ ];
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Internal helpers (defined before consumers — no-use-before-define)
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** Build the config filename for a given format. */
92
+ const configFileName = (
93
+ name: string,
94
+ format: ConfigFormat,
95
+ dotfile: boolean
96
+ ): string => (dotfile ? `.${name}rc.${format}` : `${name}.config.${format}`);
97
+
98
+ /** Check whether a file exists at the given path. */
99
+ const fileExists = (filePath: string): Promise<boolean> =>
100
+ Bun.file(filePath).exists();
101
+
102
+ /** Known config suffixes, ordered to avoid `.json` matching `.jsonc`. */
103
+ const FORMAT_SUFFIXES: readonly [ConfigFormat, `.${string}`][] = [
104
+ ['jsonc', '.jsonc'],
105
+ ['json', '.json'],
106
+ ['toml', '.toml'],
107
+ ['yaml', '.yaml'],
108
+ ];
109
+
110
+ /** Detect the declared config format from a file path. */
111
+ const detectFormat = (filePath: string): ConfigFormat | undefined => {
112
+ for (const [format, suffix] of FORMAT_SUFFIXES) {
113
+ if (filePath.endsWith(suffix)) {
114
+ return format;
115
+ }
116
+ }
117
+ return undefined;
118
+ };
119
+
120
+ /** Parse config file text with Bun's native format parsers. */
121
+ const parseConfigText = (
122
+ filePath: string,
123
+ text: string
124
+ ): Result<unknown, Error> => {
125
+ const format = detectFormat(filePath);
126
+
127
+ try {
128
+ switch (format) {
129
+ case 'json': {
130
+ return Result.ok(JSON.parse(text));
131
+ }
132
+ case 'jsonc': {
133
+ return Result.ok(Bun.JSONC.parse(text));
134
+ }
135
+ case 'toml': {
136
+ return Result.ok(Bun.TOML.parse(text));
137
+ }
138
+ case 'yaml': {
139
+ return Result.ok(Bun.YAML.parse(text));
140
+ }
141
+ default: {
142
+ return Result.err(
143
+ new ValidationError(`Unsupported config file format: ${filePath}`, {
144
+ context: { path: filePath },
145
+ })
146
+ );
147
+ }
148
+ }
149
+ } catch (error) {
150
+ return Result.err(
151
+ new ValidationError(`Failed to parse config file: ${filePath}`, {
152
+ cause: error instanceof Error ? error : new Error(String(error)),
153
+ context: { path: filePath },
154
+ })
155
+ );
156
+ }
157
+ };
158
+
159
+ /** Read and parse a config file, always reflecting the latest on-disk content. */
160
+ const readConfigFile = async (
161
+ filePath: string
162
+ ): Promise<Result<unknown, Error>> => {
163
+ const exists = await fileExists(filePath);
164
+ if (!exists) {
165
+ return Result.err(
166
+ new NotFoundError(`Config file not found: ${filePath}`, {
167
+ context: { path: filePath },
168
+ })
169
+ );
170
+ }
171
+
172
+ const text = await Bun.file(filePath).text();
173
+ return parseConfigText(filePath, text);
174
+ };
175
+
176
+ /** Validate parsed data against a Zod schema. */
177
+ const validateConfig = <T extends z.ZodType>(
178
+ schema: T,
179
+ data: unknown,
180
+ filePath: string
181
+ ): Result<z.infer<T>, Error> => {
182
+ const parsed = schema.safeParse(data);
183
+ if (parsed.success) {
184
+ return Result.ok(parsed.data as z.infer<T>);
185
+ }
186
+ return Result.err(
187
+ new ValidationError(`Config validation failed: ${filePath}`, {
188
+ context: {
189
+ issues: parsed.error.issues,
190
+ path: filePath,
191
+ },
192
+ })
193
+ );
194
+ };
195
+
196
+ /** Check all format candidates in a single directory. */
197
+ const findInDir = async (
198
+ dir: string,
199
+ name: string,
200
+ formats: readonly ConfigFormat[],
201
+ dotfile: boolean
202
+ ): Promise<string | undefined> => {
203
+ for (const format of formats) {
204
+ const candidate = join(dir, configFileName(name, format, dotfile));
205
+ if (await fileExists(candidate)) {
206
+ return candidate;
207
+ }
208
+ }
209
+ return undefined;
210
+ };
211
+
212
+ /** Walk up from `startDir` looking for any matching config filename. */
213
+ const discoverConfigFile = async (
214
+ name: string,
215
+ formats: readonly ConfigFormat[],
216
+ dotfile: boolean,
217
+ startDir: string
218
+ ): Promise<string | undefined> => {
219
+ let dir = startDir;
220
+
221
+ for (let depth = 0; depth < 64; depth += 1) {
222
+ const found = await findInDir(dir, name, formats, dotfile);
223
+ if (found !== undefined) {
224
+ return found;
225
+ }
226
+ const parent = dirname(dir);
227
+ // Reached filesystem root
228
+ if (parent === dir) {
229
+ break;
230
+ }
231
+ dir = parent;
232
+ }
233
+
234
+ return undefined;
235
+ };
236
+
237
+ /** Resolve a config file — either from an explicit path or via discovery. */
238
+ const resolveAppConfigFile = async <T extends z.ZodType>(
239
+ name: string,
240
+ schema: T,
241
+ formats: readonly ConfigFormat[],
242
+ dotfile: boolean,
243
+ options?: ResolveOptions
244
+ ): Promise<Result<z.infer<T>, Error>> => {
245
+ const filePath =
246
+ options?.path ??
247
+ (await discoverConfigFile(
248
+ name,
249
+ formats,
250
+ dotfile,
251
+ options?.cwd ?? process.cwd()
252
+ ));
253
+
254
+ if (filePath === undefined) {
255
+ return Result.err(
256
+ new NotFoundError(`No config file found for "${name}"`, {
257
+ context: { dotfile, formats: [...formats], name },
258
+ })
259
+ );
260
+ }
261
+
262
+ const readResult = await readConfigFile(filePath);
263
+ if (readResult.isErr()) {
264
+ return readResult;
265
+ }
266
+
267
+ return validateConfig(schema, readResult.value, filePath);
268
+ };
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Factory
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Declare a config contract for an app.
276
+ *
277
+ * The returned `AppConfig` exposes `resolve()` to discover, parse, and validate
278
+ * a config file matching the app name and format conventions.
279
+ *
280
+ * @example
281
+ * ```ts
282
+ * const config = appConfig('myapp', {
283
+ * schema: z.object({
284
+ * output: z.string().default('./output'),
285
+ * verbose: z.boolean().default(false),
286
+ * }),
287
+ * });
288
+ *
289
+ * const result = await config.resolve();
290
+ * if (result.isOk()) console.log(result.value.output);
291
+ * ```
292
+ */
293
+ export const appConfig = <T extends z.ZodType>(
294
+ name: string,
295
+ options: AppConfigOptions<T>
296
+ ): AppConfig<T> => {
297
+ const formats = options.formats ?? DEFAULT_FORMATS;
298
+ const dotfile = options.dotfile ?? false;
299
+
300
+ const { schema } = options;
301
+
302
+ return {
303
+ check: (values, checkOpts) => checkConfig(schema, values, checkOpts),
304
+ describe: () =>
305
+ deriveConfigFields(
306
+ schema as unknown as z.ZodObject<Record<string, z.ZodType>>
307
+ ),
308
+ dotfile,
309
+ explain: (explainOpts) =>
310
+ deriveConfigProvenance({ ...explainOpts, schema }),
311
+ formats,
312
+ name,
313
+ ref: (fieldPath) => configRef(fieldPath),
314
+ resolve: (resolveOptions?: ResolveOptions) =>
315
+ resolveAppConfigFile(name, schema, formats, dotfile, resolveOptions),
316
+ schema,
317
+ };
318
+ };
package/src/collect.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { globalRegistry } from 'zod';
2
+ import type { z } from 'zod';
3
+
4
+ import type { ConfigFieldMeta } from './extensions.js';
5
+ import { isZodObject, unwrapToBase } from './zod-utils.js';
6
+
7
+ /** Config meta keys we look for in Zod registry entries. */
8
+ const META_EXTRACTORS: readonly {
9
+ test: (raw: Record<string, unknown>) => boolean;
10
+ extract: (raw: Record<string, unknown>) => Partial<ConfigFieldMeta>;
11
+ }[] = [
12
+ {
13
+ extract: (r) => ({ env: r['env'] as string }),
14
+ test: (r) => typeof r['env'] === 'string',
15
+ },
16
+ {
17
+ extract: () => ({ secret: true }),
18
+ test: (r) => r['secret'] === true,
19
+ },
20
+ {
21
+ extract: (r) => ({ deprecated: r['deprecationMessage'] as string }),
22
+ test: (r) => typeof r['deprecationMessage'] === 'string',
23
+ },
24
+ ];
25
+
26
+ /**
27
+ * Pick only `ConfigFieldMeta` keys from a raw registry entry.
28
+ * Uses a lookup table to stay under the max-statements limit.
29
+ */
30
+ const pickConfigMeta = (
31
+ raw: Record<string, unknown> | undefined
32
+ ): ConfigFieldMeta | undefined => {
33
+ if (!raw) {
34
+ return undefined;
35
+ }
36
+
37
+ const parts = META_EXTRACTORS.filter((e) => e.test(raw)).map((e) =>
38
+ e.extract(raw)
39
+ );
40
+ return parts.length > 0
41
+ ? (Object.assign({}, ...parts) as ConfigFieldMeta)
42
+ : undefined;
43
+ };
44
+
45
+ /**
46
+ * Extract `ConfigFieldMeta` from a schema, unwrapping through
47
+ * `.default()`, `.optional()`, `.nullable()` wrappers as needed.
48
+ */
49
+ const extractConfigMeta = (schema: z.ZodType): ConfigFieldMeta | undefined => {
50
+ let current: z.ZodType | undefined = schema;
51
+
52
+ while (current) {
53
+ const meta = pickConfigMeta(globalRegistry.get(current));
54
+ if (meta) {
55
+ return meta;
56
+ }
57
+
58
+ const def = current.def as unknown as Record<string, unknown>;
59
+ current = def['innerType'] as z.ZodType | undefined;
60
+ }
61
+
62
+ return undefined;
63
+ };
64
+
65
+ /** Entry in the iterative work queue for schema walking. */
66
+ interface WalkEntry {
67
+ readonly schema: z.ZodObject<Record<string, z.ZodType>>;
68
+ readonly prefix: string;
69
+ }
70
+
71
+ /** Process one level of an object schema, queuing nested objects. */
72
+ const walkObjectShape = (
73
+ schema: z.ZodObject<Record<string, z.ZodType>>,
74
+ prefix: string,
75
+ result: Map<string, ConfigFieldMeta>,
76
+ queue: WalkEntry[]
77
+ ): void => {
78
+ const shape = schema.shape as Record<string, z.ZodType>;
79
+
80
+ for (const [key, fieldSchema] of Object.entries(shape)) {
81
+ const path = prefix ? `${prefix}.${key}` : key;
82
+ const meta = extractConfigMeta(fieldSchema);
83
+ if (meta) {
84
+ result.set(path, meta);
85
+ }
86
+
87
+ if (isZodObject(fieldSchema)) {
88
+ queue.push({
89
+ prefix: path,
90
+ schema: unwrapToBase(fieldSchema) as z.ZodObject<
91
+ Record<string, z.ZodType>
92
+ >,
93
+ });
94
+ }
95
+ }
96
+ };
97
+
98
+ /**
99
+ * Walk a Zod object schema and collect `ConfigFieldMeta` for each field.
100
+ *
101
+ * Handles unwrapping `.default()`, `.optional()`, `.nullable()` wrappers
102
+ * that don't carry inner metadata forward. Recurses into nested `ZodObject`
103
+ * fields using dot-separated paths.
104
+ */
105
+ export const collectConfigMeta = (
106
+ schema: z.ZodObject<Record<string, z.ZodType>>,
107
+ prefix = ''
108
+ ): Map<string, ConfigFieldMeta> => {
109
+ const result = new Map<string, ConfigFieldMeta>();
110
+ const queue: WalkEntry[] = [{ prefix, schema }];
111
+
112
+ for (let entry = queue.pop(); entry; entry = queue.pop()) {
113
+ walkObjectShape(entry.schema, entry.prefix, result, queue);
114
+ }
115
+
116
+ return result;
117
+ };
package/src/compose.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Config composition utilities for resources.
3
+ *
4
+ * Collects config schemas from resource declarations so they can be
5
+ * composed into a unified config structure via `defineConfig`.
6
+ */
7
+
8
+ import type { z } from 'zod';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** A resource config schema entry extracted from a resource declaration. */
15
+ export interface ResourceConfigEntry {
16
+ readonly resourceId: string;
17
+ readonly schema: z.ZodType;
18
+ }
19
+
20
+ /** Minimal shape needed to extract config from a resource-like object. */
21
+ interface ResourceWithOptionalConfig {
22
+ readonly id: string;
23
+ readonly config?: z.ZodType | undefined;
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public API
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Collect config schemas from resources that declare them.
32
+ *
33
+ * Returns entries keyed by resource ID for composition into `defineConfig`.
34
+ * Resources without a `config` schema are excluded.
35
+ */
36
+ export const collectResourceConfigs = (
37
+ resources: readonly ResourceWithOptionalConfig[]
38
+ ): ResourceConfigEntry[] =>
39
+ resources
40
+ .filter(
41
+ (
42
+ resource
43
+ ): resource is ResourceWithOptionalConfig & {
44
+ readonly config: z.ZodType;
45
+ } => resource.config !== undefined
46
+ )
47
+ .map((resource) => ({ resourceId: resource.id, schema: resource.config }));
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Config resource — manages resolved config lifecycle.
3
+ *
4
+ * The config is resolved during bootstrap (two-phase init per ADR-010)
5
+ * and registered via `registerConfigState`. This resource reads from the
6
+ * global registry so trails can access it through `configResource.from(ctx)`.
7
+ */
8
+ import { InternalError, Result, resource } from '@ontrails/core';
9
+ import { z } from 'zod';
10
+
11
+ import type { ConfigState } from './registry.js';
12
+ import { getConfigState } from './registry.js';
13
+
14
+ export const configResource = resource<ConfigState>('config', {
15
+ create: () => {
16
+ const state = getConfigState();
17
+ if (state === undefined) {
18
+ return Result.err(
19
+ new InternalError(
20
+ 'Config state not registered — call registerConfigState at bootstrap'
21
+ )
22
+ );
23
+ }
24
+ return Result.ok(state);
25
+ },
26
+ description: 'Resolved application configuration',
27
+ meta: { category: 'infrastructure' },
28
+ mock: (): ConfigState => ({
29
+ resolved: {},
30
+ schema: z.object({}),
31
+ }),
32
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Trails-specific config wrapper — `appConfig('trails', ...)` with
3
+ * framework conventions for profile selection and local overrides.
4
+ */
5
+
6
+ import type { z } from 'zod';
7
+
8
+ import { appConfig } from './app-config.js';
9
+ import { deriveConfig } from './resolve.js';
10
+ import { loadTrailsLocalConfigValue } from './trails-config-file.js';
11
+ import type { TrailsWorkspaceConfig } from './workspace-config.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Options for defining a Trails app config. */
18
+ export interface DefineConfigOptions<T extends z.ZodType> {
19
+ readonly schema: T;
20
+ readonly base?: Partial<z.infer<T>>;
21
+ readonly profiles?: Record<string, Partial<z.infer<T>>>;
22
+ /** When true, fall back to `NODE_ENV` when `TRAILS_ENV` is unset. */
23
+ readonly envFromNodeEnv?: boolean;
24
+ /** Static workspace identity. This is never included in runtime resolution. */
25
+ readonly workspace?: TrailsWorkspaceConfig | undefined;
26
+ }
27
+
28
+ /** Options passed to `resolve()` on a defined config. */
29
+ interface DefineConfigResolveOptions {
30
+ readonly profile?: string;
31
+ readonly env?: Record<string, string | undefined>;
32
+ /** Working directory for local overrides discovery. Defaults to `process.cwd()`. */
33
+ readonly cwd?: string;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Local overrides discovery
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Discover and synchronously import a `trails.config.local.*` file.
42
+ *
43
+ * Skipped when `TRAILS_ENV=test` for hermetic test environments.
44
+ */
45
+ const discoverLocalOverrides = async (
46
+ cwd: string,
47
+ envRecord: Record<string, string | undefined>
48
+ ): Promise<Record<string, unknown> | undefined> => {
49
+ if (envRecord['TRAILS_ENV'] === 'test') {
50
+ return undefined;
51
+ }
52
+
53
+ const loaded = await loadTrailsLocalConfigValue(cwd);
54
+ return loaded.value === undefined
55
+ ? undefined
56
+ : (loaded.value as Record<string, unknown>);
57
+ };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Public API
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Define Trails app config.
65
+ *
66
+ * This is `appConfig('trails', ...)` with the framework's own conventions:
67
+ * `TRAILS_ENV` selects the profile. When `envFromNodeEnv` is true,
68
+ * `NODE_ENV` is used as a fallback when `TRAILS_ENV` is unset.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const config = defineConfig({
73
+ * schema: z.object({
74
+ * port: z.number().default(3000),
75
+ * debug: z.boolean().default(false),
76
+ * }),
77
+ * base: { port: 8080 },
78
+ * profiles: {
79
+ * production: { debug: false },
80
+ * test: { debug: true, port: 0 },
81
+ * },
82
+ * });
83
+ *
84
+ * const result = config.resolve();
85
+ * ```
86
+ */
87
+ export const defineConfig = <T extends z.ZodType>(
88
+ options: DefineConfigOptions<T>
89
+ ) => {
90
+ const config = appConfig('trails', { schema: options.schema });
91
+
92
+ return {
93
+ ...config,
94
+ base: options.base,
95
+ profiles: options.profiles,
96
+ resolve: async (resolveOpts?: DefineConfigResolveOptions) => {
97
+ const envRecord = {
98
+ ...(resolveOpts?.env ?? process.env),
99
+ } as Record<string, string | undefined>;
100
+
101
+ if (
102
+ options.envFromNodeEnv &&
103
+ envRecord['TRAILS_ENV'] === undefined &&
104
+ envRecord['NODE_ENV'] !== undefined
105
+ ) {
106
+ envRecord['TRAILS_ENV'] = envRecord['NODE_ENV'];
107
+ }
108
+
109
+ const cwd = resolveOpts?.cwd ?? process.cwd();
110
+ const localOverrides = await discoverLocalOverrides(cwd, envRecord);
111
+
112
+ return deriveConfig({
113
+ base: options.base as Record<string, unknown> | undefined,
114
+ env: envRecord,
115
+ localOverrides,
116
+ profile: resolveOpts?.profile ?? envRecord['TRAILS_ENV'],
117
+ profiles: options.profiles as
118
+ | Record<string, Record<string, unknown>>
119
+ | undefined,
120
+ schema: options.schema,
121
+ });
122
+ },
123
+ schema: options.schema,
124
+ workspace: options.workspace,
125
+ };
126
+ };