@ayepi/env 0.4.1 → 0.5.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/README.md CHANGED
@@ -100,6 +100,18 @@ env({
100
100
  })
101
101
  ```
102
102
 
103
+ ## Slices, prefixes and `.env.example`
104
+
105
+ ```ts
106
+ import { env, envExample, mergeEnvs } from '@ayepi/env'
107
+
108
+ const http = env({ PORT: z.number().default(3000).describe('Where to listen.') }, { prefix: 'APP_' }) // reads APP_PORT
109
+ const db = env({ DB_FILE: z.string().default('app.sqlite') }, { prefix: 'APP_' })
110
+ const settings = mergeEnvs(http, db) // one typed view; a field in two slices fails to compile
111
+ settings.parse() // one EnvError listing every bad field
112
+ envExample(settings.describe()) // '# Where to listen.\nAPP_PORT=3000\n\nAPP_DB_FILE=app.sqlite\n'
113
+ ```
114
+
103
115
  ## Reactivity — `on(...)`
104
116
 
105
117
  Subscribe to the changes a `set(...)` (or a provider, in `asyncEnv`) causes. Computed fields update
package/ayepi-env.md CHANGED
@@ -27,8 +27,9 @@ pnpm add @ayepi/env zod
27
27
  ## Two entry points
28
28
 
29
29
  - **`@ayepi/env`** — `env` (sync) and `asyncEnv` (async + providers); the `dynamic` / `pollProvider`
30
- / `staticProvider` providers; `alias` (+ `varsOf`); the `coerce` primitive; `parseDotenv` (pure
31
- string parser); `EnvError` and source helpers.
30
+ / `staticProvider` providers; `alias` (+ `varsOf`, `sourceKeyOf`); `mergeEnvs` (one view over
31
+ several slices); `envExample`; the `coerce` primitive; `parseDotenv` (pure string parser);
32
+ `EnvError` and source helpers.
32
33
  - **`@ayepi/env/load`** — `loadEnv` / `readEnvFile` (read `.env` + `.json` files into a source;
33
34
  the only `node:fs` user).
34
35
 
@@ -41,8 +42,18 @@ function env(input: EnvInput, options?: EnvOptions): Env<T>
41
42
 
42
43
  type EnvInput<Inherited> = Record<string, z.ZodType | ((inherited: Inherited) => z.ZodType | any)>
43
44
 
44
- interface Env<T> {
45
+ interface EnvOptions {
46
+ prefix?: string // field PORT reads `${prefix}PORT` (aliases are read as written)
47
+ booleans?: { true?: string[]; false?: string[] }
48
+ }
49
+
50
+ interface Env<T> extends EnvView<T> {
45
51
  add<A>(input: A): Env<T & EnvOutput<…>> // widen with more fields (factories see earlier fields)
52
+ with(fn: (env: Env<T>) => void): void // run a side effect with the env
53
+ map<R>(fn: (env: Env<T>) => R): R // run a fn with the env, return its result
54
+ }
55
+
56
+ interface EnvView<T> { // what Env and a mergeEnvs view share
46
57
  get<K extends keyof T>(key: K): T[K] // resolve one field (lazy); throws EnvError if invalid
47
58
  set<K extends keyof T>(key: K, value: T[K] | string): void
48
59
  set(updates: { [K in keyof T]?: T[K] | string } & Record<string, unknown>): void
@@ -50,8 +61,7 @@ interface Env<T> {
50
61
  on(listener: (key, value) => void, opts?): () => void // any field
51
62
  on<K>(key: K, listener: (value) => void, opts?): () => void // one field
52
63
  on<K>(keys: K[], listener: (value) => void, opts?): () => void // several fields
53
- with(fn: (env: Env<T>) => void): void // run a side effect with the env
54
- map<R>(fn: (env: Env<T>) => R): R // run a fn with the env, return its result
64
+ describe(): readonly EnvFieldInfo[] // every field: keys read, required, description, default, JSON Schema
55
65
  }
56
66
  ```
57
67
 
@@ -96,11 +106,15 @@ resolves every field and throws a single `EnvError` aggregating **all** failures
96
106
  ## `set(...)` and the source
97
107
 
98
108
  The live source is `process.env` (or `{}` off-Node) with your `set(...)` overrides merged **on top**
99
- (overrides win). `set(key, value)` and `set(updates)` both write into the override layer.
109
+ (overrides win). `set(key, value)` and `set(updates)` both write into the override layer, which is
110
+ keyed by **source key**.
100
111
 
101
112
  - Pass a **typed value** or a **raw string** — strings are coerced, typed values pass through.
102
- - `set(updates)` accepts known fields *and* arbitrary source keys (e.g. pass the whole `process.env`
103
- or a loaded `.env`), so it composes with aliasing.
113
+ - `set(key, value)` with a field's name writes under the first key that field reads (its prefixed
114
+ name, or its first alias — `sourceKeyOf(schema, key, prefix)`), so it always reaches the field.
115
+ A key that names no field is written as given.
116
+ - `set(updates)` takes source keys as given (e.g. pass the whole `process.env` or a loaded
117
+ `.env`), so it composes with prefixes and aliasing.
104
118
 
105
119
  ```ts
106
120
  ENV.set('PORT', 8080) // typed
@@ -121,7 +135,62 @@ env({
121
135
  })
122
136
  ```
123
137
 
124
- `varsOf(schema, key)` (exported) returns the resolved key list.
138
+ `varsOf(schema, key, prefix?)` (exported) returns the resolved key list.
139
+
140
+ ## Prefix — one namespace for every key
141
+
142
+ `env(input, { prefix: 'APP_' })` makes field `PORT` read `APP_PORT`: the TypeScript name stays short
143
+ and the namespace is written once. Keys named by `vars`/`var` metadata are read exactly as written
144
+ (so an alias can reach an unprefixed key such as `PORT` from a platform). `asyncEnv` takes the same
145
+ option.
146
+
147
+ ## `describe()` and `envExample(fields)`
148
+
149
+ `describe()` lists every field in declaration order as an `EnvFieldInfo`:
150
+
151
+ ```ts
152
+ interface EnvFieldInfo {
153
+ key: string // the field name you get()
154
+ vars: readonly string[] // the source keys it reads (prefix and aliases applied); [] when computed
155
+ computed: boolean // a factory that returned a plain value — reads no key
156
+ required: boolean // the schema rejects a missing value (no default, not optional)
157
+ description?: string // .describe(...) / .meta({ description })
158
+ default?: unknown // the schema's default
159
+ schema?: Record<string, unknown> // JSON Schema of the accepted input (zod's toJSONSchema, io: 'input')
160
+ }
161
+ ```
162
+
163
+ A factory field is run to find its schema; if it throws (an earlier field it reads is invalid) it is
164
+ reported as required with no `schema`. `envExample(fields)` renders the list as a commented
165
+ `.env.example` — the description as `#` lines, `KEY=default`, `KEY=` marked `(required)`, or
166
+ `# KEY=` when optional; computed fields are left out. A settings page can render the same list.
167
+
168
+ ```ts
169
+ writeFileSync('.env.example', envExample(ENV.describe()))
170
+ ```
171
+
172
+ ## `mergeEnvs(...slices)` — one view over several configs
173
+
174
+ A large app configures each module with its own slice. `mergeEnvs(a, b, …)` joins them into one
175
+ `EnvView` of every field; each slice stays the source of truth for its fields:
176
+
177
+ - `get` and single-field `set` go to the slice that owns the field; a bulk `set(updates)` (and a
178
+ single `set` of a key no field has) reaches every slice.
179
+ - `parse()` resolves every slice and throws **one** `EnvError` with every slice's issues.
180
+ - `on(key | keys, …)` subscribes in the owning slices; `on(listener)` in every slice (a `once`
181
+ listener fires once per slice). The returned function unsubscribes from all of them.
182
+ - `describe()` lists every field, slice by slice.
183
+ - Two slices declaring the same field name is a **compile-time error** naming it (for argument
184
+ tuples) and throws at runtime in every case.
185
+
186
+ ```ts
187
+ import { env, mergeEnvs } from '@ayepi/env'
188
+
189
+ const http = env({ PORT: z.number().default(3000) }, { prefix: 'APP_' })
190
+ const db = env({ DB_FILE: z.string().default('app.sqlite') }, { prefix: 'APP_' })
191
+ export const settings = mergeEnvs(http, db) // EnvView<{ PORT: number; DB_FILE: string }>
192
+ settings.parse()
193
+ ```
125
194
 
126
195
  ## Reactivity — `on(...)`
127
196
 
@@ -289,6 +358,8 @@ off(); ENV.close()
289
358
  calls, never its own group. Split into `add(...)` steps to express dependencies.
290
359
  - **Lazy.** Values resolve on `get`/`parse`, not at declaration. `get` throws for its one field;
291
360
  `parse` aggregates all failures.
361
+ - **`set(updates)` is source-keyed.** With a `prefix`, `set({ PORT: '1' })` sets the source key
362
+ `PORT`, not the field; write `set('PORT', 1)` or `set({ APP_PORT: '1' })`.
292
363
  - **`set(...)` wins over `process.env`.** The source is `{ ...process.env, ...overrides }`. To make
293
364
  files lower precedence than `process.env`, spread `process.env` last into `set(...)`.
294
365
  - **Computed value fields aren't re-validated.** A factory returning a plain value is trusted as-is;
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_dotenv = require("./dotenv.cjs");
3
+ let zod = require("zod");
3
4
  //#region src/coerce.ts
4
5
  /** Read a schema's internal `_zod.def` (zod v4 stores its definition there). */
5
6
  function def(schema) {
@@ -99,12 +100,23 @@ function coerce(schema, value, words) {
99
100
  function readMeta(schema) {
100
101
  return schema.meta?.();
101
102
  }
102
- /** The source keys a field reads, in precedence order — its `vars`/`var` meta, else `[key]`. */
103
- function varsOf(schema, key) {
103
+ /**
104
+ * The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
105
+ * `[prefix + key]`.
106
+ */
107
+ function varsOf(schema, key, prefix = "") {
104
108
  const meta = readMeta(schema);
105
109
  if (meta?.vars && meta.vars.length > 0) return meta.vars;
106
110
  if (meta?.var) return [meta.var];
107
- return [key];
111
+ return [prefix + key];
112
+ }
113
+ /**
114
+ * The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
115
+ * a factory field, whose schema is only known once it runs; it is written under its prefixed name.
116
+ */
117
+ function sourceKeyOf(schema, key, prefix = "") {
118
+ const meta = schema === void 0 ? void 0 : readMeta(schema);
119
+ return meta?.vars?.[0] ?? meta?.var ?? prefix + key;
108
120
  }
109
121
  /** Attach aliasing metadata: read `schema` from the first present of `keys`. */
110
122
  function alias(schema, ...keys) {
@@ -186,6 +198,32 @@ function changed(prev, next, deep) {
186
198
  }
187
199
  //#endregion
188
200
  //#region src/env.ts
201
+ /**
202
+ * # `env(...)` — the typed, lazy, reactive config builder
203
+ *
204
+ * Declare a config as a record of fields and get back a typed {@link Env}. A field is either:
205
+ *
206
+ * - a **zod schema** — read from a source key (its name, or its `vars` meta for aliasing),
207
+ * coerced from its string form and validated; or
208
+ * - a **factory** `(inherited) => …` — called with the already-resolved fields from *earlier*
209
+ * `add(...)` groups. Return another **zod schema** (a schema that depends on other values),
210
+ * or return a **plain value** (a computed field derived purely from other fields).
211
+ *
212
+ * Resolution is **lazy**: nothing is parsed until you `get(...)` or `parse()`. Values come from
213
+ * `process.env` by default; `set(...)` layers overrides on top. `on(...)` subscribes to changes
214
+ * that `set(...)` causes; the keys a factory reads are auto-tracked so computed fields update too.
215
+ *
216
+ * ```ts
217
+ * const ENV = env({ PORT: z.coerce.number().default(3000) })
218
+ * .add({ IS_PROD: (e) => e.NODE_ENV === 'production' }) // factory: computed value
219
+ *
220
+ * ENV.set(process.env)
221
+ * ENV.get('PORT') // typed, throws if invalid
222
+ * ENV.parse() // resolve everything; throws an aggregated EnvError
223
+ * ```
224
+ *
225
+ * @module
226
+ */
189
227
  /** Normalize {@link EnvOptions} into the lowercased {@link BooleanWords} the coercer consumes. */
190
228
  function toWords(options) {
191
229
  const b = options?.booleans;
@@ -206,9 +244,11 @@ var EnvImpl = class {
206
244
  globalLs = /* @__PURE__ */ new Set();
207
245
  prev = /* @__PURE__ */ new Map();
208
246
  words;
247
+ prefix;
209
248
  groupCount = 0;
210
249
  constructor(input, options) {
211
250
  this.words = toWords(options);
251
+ this.prefix = options?.prefix ?? "";
212
252
  this.define(input);
213
253
  }
214
254
  /** Register a group of fields under the next group index. */
@@ -234,13 +274,12 @@ var EnvImpl = class {
234
274
  /** A proxy over fields from groups *earlier* than `group`, recording which keys a factory reads. */
235
275
  inheritedFor(group) {
236
276
  const reads = /* @__PURE__ */ new Set();
237
- const self = this;
238
- const visible = (prop) => typeof prop === "string" && (self.defs.get(prop)?.group ?? Infinity) < group;
277
+ const visible = (prop) => typeof prop === "string" && (this.defs.get(prop)?.group ?? Infinity) < group;
239
278
  return {
240
279
  proxy: new Proxy(Object.create(null), {
241
- get: (_t, prop) => visible(prop) ? (reads.add(prop), self.resolve(prop)) : void 0,
280
+ get: (_t, prop) => visible(prop) ? (reads.add(prop), this.resolve(prop)) : void 0,
242
281
  has: (_t, prop) => visible(prop),
243
- ownKeys: () => [...self.defs.values()].filter((f) => f.group < group).map((f) => f.key),
282
+ ownKeys: () => [...this.defs.values()].filter((f) => f.group < group).map((f) => f.key),
244
283
  getOwnPropertyDescriptor: () => ({
245
284
  enumerable: true,
246
285
  configurable: true
@@ -249,34 +288,32 @@ var EnvImpl = class {
249
288
  reads
250
289
  };
251
290
  }
291
+ /**
292
+ * What a field is made of: its schema, or the value its factory computed. A factory is run against the fields of
293
+ * earlier groups (recording what it reads); a throwing factory becomes an {@link EnvError} scoped to the field.
294
+ */
295
+ produce(def) {
296
+ if (def.kind === "schema") return { schema: def.schema };
297
+ const { proxy, reads } = this.inheritedFor(def.group);
298
+ let produced;
299
+ try {
300
+ produced = def.factory(proxy);
301
+ } catch (err) {
302
+ throw new EnvError([customIssue(def.key, errMessage(err))]);
303
+ } finally {
304
+ this.deps.set(def.key, reads);
305
+ }
306
+ return isZodType(produced) ? { schema: produced } : { value: produced };
307
+ }
252
308
  /** Resolve `key` to its value (cached). Throws an {@link EnvError} (scoped to `key`) on failure. */
253
309
  resolve(key) {
254
310
  if (this.cache.has(key)) return this.cache.get(key);
255
- const def = this.defs.get(key);
256
- let schema;
257
- let computed;
258
- let isComputed = false;
259
- if (def.kind === "schema") schema = def.schema;
260
- else {
261
- const { proxy, reads } = this.inheritedFor(def.group);
262
- let produced;
263
- try {
264
- produced = def.factory(proxy);
265
- } catch (err) {
266
- this.deps.set(key, reads);
267
- throw new EnvError([customIssue(key, errMessage(err))]);
268
- }
269
- this.deps.set(key, reads);
270
- if (isZodType(produced)) schema = produced;
271
- else {
272
- computed = produced;
273
- isComputed = true;
274
- }
275
- }
311
+ const made = this.produce(this.defs.get(key));
276
312
  let result;
277
- if (isComputed) result = computed;
313
+ if ("value" in made) result = made.value;
278
314
  else {
279
- const raw = resolveRaw(this.source(), varsOf(schema, key));
315
+ const { schema } = made;
316
+ const raw = resolveRaw(this.source(), varsOf(schema, key, this.prefix));
280
317
  const res = schema.safeParse(raw !== void 0 ? coerce(schema, raw, this.words) : void 0);
281
318
  if (!res.success) throw new EnvError(keyed(key, res.error.issues));
282
319
  result = res.data;
@@ -284,6 +321,28 @@ var EnvImpl = class {
284
321
  this.cache.set(key, result);
285
322
  return result;
286
323
  }
324
+ describe() {
325
+ return [...this.defs.values()].map((def) => {
326
+ let made;
327
+ try {
328
+ made = this.produce(def);
329
+ } catch {
330
+ return {
331
+ key: def.key,
332
+ vars: [this.prefix + def.key],
333
+ computed: false,
334
+ required: true
335
+ };
336
+ }
337
+ if ("value" in made) return {
338
+ key: def.key,
339
+ vars: [],
340
+ computed: true,
341
+ required: false
342
+ };
343
+ return describeSchema(def.key, made.schema, this.prefix);
344
+ });
345
+ }
287
346
  get(key) {
288
347
  return this.resolve(key);
289
348
  }
@@ -299,10 +358,16 @@ var EnvImpl = class {
299
358
  return out;
300
359
  }
301
360
  set(keyOrUpdates, value) {
302
- const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
361
+ const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
303
362
  Object.assign(this.overrides, updates);
304
363
  this.invalidate();
305
364
  }
365
+ /** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
366
+ sourceKey(key) {
367
+ const def = this.defs.get(key);
368
+ if (def === void 0) return key;
369
+ return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
370
+ }
306
371
  add(input) {
307
372
  this.define(input);
308
373
  this.cache.clear();
@@ -413,11 +478,118 @@ var EnvImpl = class {
413
478
  }
414
479
  }
415
480
  };
481
+ /** Describe a field read through `schema`: the keys it reads, whether it is required, and its documented shape. */
482
+ function describeSchema(key, schema, prefix) {
483
+ const { $schema: _dialect, ...shape } = zod.z.toJSONSchema(schema, {
484
+ io: "input",
485
+ unrepresentable: "any"
486
+ });
487
+ return {
488
+ key,
489
+ vars: varsOf(schema, key, prefix),
490
+ computed: false,
491
+ required: !schema.safeParse(void 0).success,
492
+ ...schema.description === void 0 ? {} : { description: schema.description },
493
+ ..."default" in shape ? { default: shape.default } : {},
494
+ schema: shape
495
+ };
496
+ }
416
497
  /** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
417
498
  function env(input, options) {
418
499
  return new EnvImpl(input, options);
419
500
  }
420
501
  //#endregion
502
+ //#region src/merge.ts
503
+ /** The engine behind {@link mergeEnvs}: each call goes to the slice that owns the field, or to every slice. */
504
+ var MergedEnv = class {
505
+ slices;
506
+ owner = /* @__PURE__ */ new Map();
507
+ constructor(slices) {
508
+ this.slices = slices;
509
+ for (const slice of slices) for (const { key } of slice.describe()) {
510
+ if (this.owner.has(key)) throw new Error(`mergeEnvs: field "${key}" is declared by more than one env`);
511
+ this.owner.set(key, slice);
512
+ }
513
+ }
514
+ sliceOf(key) {
515
+ const slice = this.owner.get(key);
516
+ if (slice === void 0) throw new Error(`mergeEnvs: no env declares field "${key}"`);
517
+ return slice;
518
+ }
519
+ get(key) {
520
+ return this.sliceOf(key).get(key);
521
+ }
522
+ set(keyOrUpdates, value) {
523
+ if (typeof keyOrUpdates === "string") {
524
+ const slice = this.owner.get(keyOrUpdates);
525
+ if (slice !== void 0) return slice.set(keyOrUpdates, value);
526
+ for (const one of this.slices) one.set({ [keyOrUpdates]: value });
527
+ return;
528
+ }
529
+ for (const one of this.slices) one.set(keyOrUpdates);
530
+ }
531
+ parse() {
532
+ const out = {};
533
+ const issues = [];
534
+ for (const slice of this.slices) try {
535
+ Object.assign(out, slice.parse());
536
+ } catch (err) {
537
+ if (!(err instanceof EnvError)) throw err;
538
+ issues.push(...err.issues);
539
+ }
540
+ if (issues.length > 0) throw new EnvError(issues);
541
+ return out;
542
+ }
543
+ on(arg1, arg2, arg3) {
544
+ let offs;
545
+ if (typeof arg1 === "function") {
546
+ const listener = arg1;
547
+ offs = this.slices.map((slice) => slice.on(listener, arg2));
548
+ } else {
549
+ const keys = Array.isArray(arg1) ? arg1 : [String(arg1)];
550
+ const listener = arg2;
551
+ offs = keys.map((key) => this.sliceOf(key).on(key, listener, arg3));
552
+ }
553
+ return () => {
554
+ for (const off of offs) off();
555
+ };
556
+ }
557
+ describe() {
558
+ return this.slices.flatMap((slice) => slice.describe());
559
+ }
560
+ };
561
+ /**
562
+ * Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
563
+ * fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
564
+ * {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
565
+ * in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
566
+ * A `once` listener subscribed to all fields fires once per slice.
567
+ */
568
+ function mergeEnvs(...envs) {
569
+ return new MergedEnv(envs);
570
+ }
571
+ //#endregion
572
+ //#region src/example.ts
573
+ /** The text a value has in a `.env` file: strings as written, anything else as JSON. */
574
+ function dotenvText(value) {
575
+ return typeof value === "string" ? value : JSON.stringify(value);
576
+ }
577
+ /**
578
+ * A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
579
+ * description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
580
+ * commented out when it is optional. Computed fields read no key and are left out.
581
+ */
582
+ function envExample(fields) {
583
+ const blocks = fields.filter((field) => !field.computed).map((field) => {
584
+ const key = field.vars[0] ?? field.key;
585
+ const notes = [field.description, field.required ? "(required)" : void 0].filter((note) => note !== void 0);
586
+ const comment = notes.length === 0 ? [] : notes.join(" ").split("\n").map((text) => `# ${text}`);
587
+ const line = "default" in field ? `${key}=${dotenvText(field.default)}` : field.required ? `${key}=` : `# ${key}=`;
588
+ return [...comment, line].join("\n");
589
+ });
590
+ return blocks.length === 0 ? "" : `${blocks.join("\n\n")}\n`;
591
+ }
592
+ //#endregion
421
593
  //#region src/provider.ts
422
594
  /** Bind a field to a live {@link EnvProvider}; values are validated against `schema`. */
423
595
  function dynamic(provider, schema) {
@@ -467,9 +639,11 @@ var AsyncEnvImpl = class {
467
639
  started = /* @__PURE__ */ new Set();
468
640
  watchers = /* @__PURE__ */ new Map();
469
641
  words;
642
+ prefix;
470
643
  groupCount = 0;
471
644
  constructor(input, options) {
472
645
  this.words = toWords(options);
646
+ this.prefix = options?.prefix ?? "";
473
647
  this.define(input);
474
648
  }
475
649
  define(input) {
@@ -525,7 +699,7 @@ var AsyncEnvImpl = class {
525
699
  /** Do the work of resolving one field (no memoization — see {@link resolve}). */
526
700
  async compute(key) {
527
701
  const def = this.defs.get(key);
528
- if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key)));
702
+ if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key, this.prefix)));
529
703
  if (def.kind === "dynamic") {
530
704
  await this.ensureStarted(def);
531
705
  return this.validate(def.schema, key, this.dynamicRaw.get(def.key));
@@ -544,7 +718,7 @@ var AsyncEnvImpl = class {
544
718
  throw new EnvError([customIssue(key, errMessage(err))]);
545
719
  }
546
720
  this.deps.set(key, reads);
547
- if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key)));
721
+ if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key, this.prefix)));
548
722
  return produced;
549
723
  }
550
724
  /** Resolve `key` (memoized per snapshot). Cleared by `set`/provider updates. */
@@ -581,10 +755,16 @@ var AsyncEnvImpl = class {
581
755
  return out;
582
756
  }
583
757
  set(keyOrUpdates, value) {
584
- const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
758
+ const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
585
759
  Object.assign(this.overrides, updates);
586
760
  this.emitChange();
587
761
  }
762
+ /** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
763
+ sourceKey(key) {
764
+ const def = this.defs.get(key);
765
+ if (def === void 0) return key;
766
+ return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
767
+ }
588
768
  add(input) {
589
769
  this.define(input);
590
770
  this.cache.clear();
@@ -730,10 +910,13 @@ exports.defaultSource = defaultSource;
730
910
  exports.dynamic = dynamic;
731
911
  exports.effectiveType = effectiveType;
732
912
  exports.env = env;
913
+ exports.envExample = envExample;
733
914
  exports.isDynamic = isDynamic;
915
+ exports.mergeEnvs = mergeEnvs;
734
916
  exports.mergeSources = mergeSources;
735
917
  exports.parseDotenv = require_dotenv.parseDotenv;
736
918
  exports.pollProvider = pollProvider;
737
919
  exports.resolveRaw = resolveRaw;
920
+ exports.sourceKeyOf = sourceKeyOf;
738
921
  exports.staticProvider = staticProvider;
739
922
  exports.varsOf = varsOf;
package/dist/index.d.cts CHANGED
@@ -27,7 +27,7 @@ declare function coerce(schema: z.ZodType, value: unknown, words?: BooleanWords)
27
27
  //#region src/env.d.ts
28
28
 
29
29
  /** A field definition: a zod schema, or a factory taking the inherited (earlier-group) values. */
30
- type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) => z.ZodType | any);
30
+ type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) => unknown);
31
31
  /** The fields passed to {@link env} / {@link Env.add}. `Inherited` is the type resolved so far. */
32
32
  type EnvInput<Inherited> = Record<string, EnvFieldDef<Inherited>>;
33
33
  /** The resolved output type of an {@link EnvInput} (zod outputs, computed schema outputs, or plain values). */
@@ -38,6 +38,11 @@ type EnvSet<T extends object> = { [K in keyof T]?: T[K] | string } & {
38
38
  };
39
39
  /** Build-time options for {@link env} / {@link asyncEnv}. */
40
40
  interface EnvOptions {
41
+ /**
42
+ * Prepended to each field's name to make the source key it reads: with `prefix: 'APP_'`, field `PORT` reads
43
+ * `APP_PORT`. Keys named by `vars`/`var` metadata are read as written.
44
+ */
45
+ readonly prefix?: string;
41
46
  /**
42
47
  * Customize which strings coerce to booleans (case-insensitive). Each side you provide
43
48
  * **replaces** its default set; an omitted side keeps the default. E.g.
@@ -59,15 +64,33 @@ interface EnvOnOptions {
59
64
  /** Fire immediately on subscribe with the current value(s). */
60
65
  immediate?: boolean;
61
66
  }
62
- /** A typed, lazy, reactive config object built by {@link env}. */
63
- interface Env<T extends object> {
64
- /** Add more fields; their factories receive the fields resolved so far. Returns a wider {@link Env}. */
65
- add<A extends EnvInput<T>>(input: A): Env<T & EnvOutput<T, A>>;
67
+ /** What {@link EnvView.describe} reports about one field — enough for a settings page or a `.env.example`. */
68
+ interface EnvFieldInfo {
69
+ /** The field's name (the key you `get`). */
70
+ readonly key: string;
71
+ /** The source keys it reads, in precedence order (prefix and aliases applied); none for a computed field. */
72
+ readonly vars: readonly string[];
73
+ /** A value computed from other fields; it reads no source key and has no schema. */
74
+ readonly computed: boolean;
75
+ /** Whether a value must be given: the schema rejects a missing one (no default, not optional). */
76
+ readonly required: boolean;
77
+ /** The schema's description (`.describe(...)` or `.meta({ description })`). */
78
+ readonly description?: string;
79
+ /** The schema's default, when it has one. */
80
+ readonly default?: unknown;
81
+ /** The JSON Schema of the accepted input (unrepresentable parts become `{}`). */
82
+ readonly schema?: Readonly<Record<string, unknown>>;
83
+ }
84
+ /** The reading surface of a config: {@link Env} has it, and so does a {@link mergeEnvs} view over several. */
85
+ interface EnvView<T extends object> {
66
86
  /** Resolve a single field (lazily). Throws an {@link EnvError} if that field is missing/invalid. */
67
87
  get<K extends keyof T>(key: K): T[K];
68
- /** Override a single field/source key (typed value or raw string). */
88
+ /**
89
+ * Override a single field (typed value or raw string), written under the field's source key (its prefixed name,
90
+ * or its first alias). A key that names no field is written as a source key, as given.
91
+ */
69
92
  set<K extends keyof T>(key: K, value: T[K] | string): void;
70
- /** Override several fields/source keys at once (e.g. pass `process.env` or a loaded `.env`). */
93
+ /** Override several source keys at once (e.g. pass `process.env` or a loaded `.env`); keys are used as given. */
71
94
  set(updates: EnvSet<T>): void;
72
95
  /** Resolve every field. Throws an aggregated {@link EnvError} listing all missing/invalid fields. */
73
96
  parse(): T;
@@ -77,6 +100,16 @@ interface Env<T extends object> {
77
100
  on<K extends keyof T>(key: K, listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
78
101
  /** Subscribe to several fields' changes with one listener. */
79
102
  on<K extends keyof T>(keys: readonly K[], listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
103
+ /**
104
+ * Describe every field, in declaration order. A factory field is run to find its schema; one that throws (an
105
+ * earlier field it reads is invalid) is reported as required, without `schema`, `default` or `description`.
106
+ */
107
+ describe(): readonly EnvFieldInfo[];
108
+ }
109
+ /** A typed, lazy, reactive config object built by {@link env}. */
110
+ interface Env<T extends object> extends EnvView<T> {
111
+ /** Add more fields; their factories receive the fields resolved so far. Returns a wider {@link Env}. */
112
+ add<A extends EnvInput<T>>(input: A): Env<T & EnvOutput<T, A>>;
80
113
  /** Run `fn` with this env (for grouped setup); returns nothing. */
81
114
  with(fn: (env: Env<T>) => void): void;
82
115
  /** Run `fn` with this env and return its result. */
@@ -85,6 +118,54 @@ interface Env<T extends object> {
85
118
  /** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
86
119
  declare function env<const T extends EnvInput<{}>>(input: T, options?: EnvOptions): Env<EnvOutput<{}, T>>;
87
120
  //#endregion
121
+ //#region src/merge.d.ts
122
+ /**
123
+ * Any config view: every {@link EnvView} member, with field names erased so a view of any fields fits (methods take
124
+ * `never` parameters, so this is the supertype of every `EnvView<T>`). Its fields are read from what `parse` returns.
125
+ */
126
+ interface AnyEnvView {
127
+ get(key: never): unknown;
128
+ set(key: never, value: never): void;
129
+ parse(): object;
130
+ on(...args: never[]): () => void;
131
+ describe(): readonly EnvFieldInfo[];
132
+ }
133
+ /** The fields of a config view. */
134
+ type FieldsOf<E> = E extends {
135
+ parse(): infer T;
136
+ } ? T : never;
137
+ /** The field names of a config view. */
138
+ type NamesOf<E> = keyof FieldsOf<E> & string;
139
+ /** The positions of a tuple (`'0' | '1' | …`); `never` for a plain array. */
140
+ type Positions<T extends readonly unknown[]> = Exclude<keyof T, keyof unknown[]>;
141
+ /** Field names that two different views of `T` both declare. */
142
+ type SharedNames<T extends readonly unknown[]> = { [I in Positions<T>]: { [J in Positions<T>]: J extends I ? never : NamesOf<T[I]> & NamesOf<T[J]> }[Positions<T>] }[Positions<T>];
143
+ /** Turn a union into the intersection of its members. */
144
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
145
+ /**
146
+ * `T` when no field name is declared by more than one of its views; otherwise a tuple whose only element names the
147
+ * clash, so the call fails to compile with that message.
148
+ */
149
+ type DisjointEnvs<T extends readonly unknown[]> = [SharedNames<T>] extends [never] ? T : readonly [`fields declared by more than one env: ${SharedNames<T>}`];
150
+ /** The fields of every view in `T`, as one object type. */
151
+ type MergedFields<T extends readonly unknown[]> = { [K in keyof UnionToIntersection<FieldsOf<T[number]>>]: UnionToIntersection<FieldsOf<T[number]>>[K] };
152
+ /**
153
+ * Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
154
+ * fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
155
+ * {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
156
+ * in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
157
+ * A `once` listener subscribed to all fields fires once per slice.
158
+ */
159
+ declare function mergeEnvs<const T extends readonly [AnyEnvView, ...AnyEnvView[]]>(...envs: T & DisjointEnvs<T>): EnvView<MergedFields<T>>;
160
+ //#endregion
161
+ //#region src/example.d.ts
162
+ /**
163
+ * A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
164
+ * description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
165
+ * commented out when it is optional. Computed fields read no key and are left out.
166
+ */
167
+ declare function envExample(fields: readonly EnvFieldInfo[]): string;
168
+ //#endregion
88
169
  //#region src/provider.d.ts
89
170
  /** A value, or a promise of one. */
90
171
  type MaybePromise<T> = T | Promise<T>;
@@ -115,7 +196,7 @@ declare function staticProvider(value: string | undefined): EnvProvider;
115
196
  //#endregion
116
197
  //#region src/async.d.ts
117
198
  /** A field for {@link asyncEnv}: zod schema, dynamic binding, or a (possibly async) factory. */
118
- type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<any> | ((inherited: Inherited) => MaybePromise<z.ZodType | any>);
199
+ type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<unknown> | ((inherited: Inherited) => unknown);
119
200
  /** The fields passed to {@link asyncEnv} / {@link AsyncEnv.add}. */
120
201
  type AsyncEnvInput<Inherited> = Record<string, AsyncEnvFieldDef<Inherited>>;
121
202
  /** The resolved output type of an {@link AsyncEnvInput}. */
@@ -126,9 +207,9 @@ interface AsyncEnv<T extends object> {
126
207
  add<A extends AsyncEnvInput<T>>(input: A): AsyncEnv<T & AsyncEnvOutput<T, A>>;
127
208
  /** Resolve a single field (lazily, async). Rejects with an {@link EnvError} if missing/invalid. */
128
209
  get<K extends keyof T>(key: K): Promise<T[K]>;
129
- /** Override a single field/source key (typed value or raw string). */
210
+ /** Override a single field (typed value or raw string), under its source key (prefixed name or first alias); another key is used as given. */
130
211
  set<K extends keyof T>(key: K, value: T[K] | string): void;
131
- /** Override several fields/source keys at once. */
212
+ /** Override several source keys at once; keys are used as given. */
132
213
  set(updates: EnvSet<T>): void;
133
214
  /** Resolve every field. Rejects with an aggregated {@link EnvError} listing all failures. */
134
215
  parse(): Promise<T>;
@@ -158,9 +239,17 @@ interface EnvMeta {
158
239
  /** Several source keys to read this field from — first present wins. */
159
240
  readonly vars?: readonly string[];
160
241
  }
161
- /** The source keys a field reads, in precedence order — its `vars`/`var` meta, else `[key]`. */
162
- declare function varsOf(schema: z.ZodType, key: string): readonly string[];
242
+ /**
243
+ * The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
244
+ * `[prefix + key]`.
245
+ */
246
+ declare function varsOf(schema: z.ZodType, key: string, prefix?: string): readonly string[];
247
+ /**
248
+ * The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
249
+ * a factory field, whose schema is only known once it runs; it is written under its prefixed name.
250
+ */
251
+ declare function sourceKeyOf(schema: z.ZodType | undefined, key: string, prefix?: string): string;
163
252
  /** Attach aliasing metadata: read `schema` from the first present of `keys`. */
164
253
  declare function alias<S extends z.ZodType>(schema: S, ...keys: string[]): S;
165
254
  //#endregion
166
- export { type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type MaybePromise, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, isDynamic, mergeSources, parseDotenv, pollProvider, resolveRaw, staticProvider, varsOf };
255
+ export { type AnyEnvView, type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DisjointEnvs, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvFieldInfo, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type EnvView, type MaybePromise, type MergedFields, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, envExample, isDynamic, mergeEnvs, mergeSources, parseDotenv, pollProvider, resolveRaw, sourceKeyOf, staticProvider, varsOf };
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ declare function coerce(schema: z.ZodType, value: unknown, words?: BooleanWords)
27
27
  //#region src/env.d.ts
28
28
 
29
29
  /** A field definition: a zod schema, or a factory taking the inherited (earlier-group) values. */
30
- type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) => z.ZodType | any);
30
+ type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) => unknown);
31
31
  /** The fields passed to {@link env} / {@link Env.add}. `Inherited` is the type resolved so far. */
32
32
  type EnvInput<Inherited> = Record<string, EnvFieldDef<Inherited>>;
33
33
  /** The resolved output type of an {@link EnvInput} (zod outputs, computed schema outputs, or plain values). */
@@ -38,6 +38,11 @@ type EnvSet<T extends object> = { [K in keyof T]?: T[K] | string } & {
38
38
  };
39
39
  /** Build-time options for {@link env} / {@link asyncEnv}. */
40
40
  interface EnvOptions {
41
+ /**
42
+ * Prepended to each field's name to make the source key it reads: with `prefix: 'APP_'`, field `PORT` reads
43
+ * `APP_PORT`. Keys named by `vars`/`var` metadata are read as written.
44
+ */
45
+ readonly prefix?: string;
41
46
  /**
42
47
  * Customize which strings coerce to booleans (case-insensitive). Each side you provide
43
48
  * **replaces** its default set; an omitted side keeps the default. E.g.
@@ -59,15 +64,33 @@ interface EnvOnOptions {
59
64
  /** Fire immediately on subscribe with the current value(s). */
60
65
  immediate?: boolean;
61
66
  }
62
- /** A typed, lazy, reactive config object built by {@link env}. */
63
- interface Env<T extends object> {
64
- /** Add more fields; their factories receive the fields resolved so far. Returns a wider {@link Env}. */
65
- add<A extends EnvInput<T>>(input: A): Env<T & EnvOutput<T, A>>;
67
+ /** What {@link EnvView.describe} reports about one field — enough for a settings page or a `.env.example`. */
68
+ interface EnvFieldInfo {
69
+ /** The field's name (the key you `get`). */
70
+ readonly key: string;
71
+ /** The source keys it reads, in precedence order (prefix and aliases applied); none for a computed field. */
72
+ readonly vars: readonly string[];
73
+ /** A value computed from other fields; it reads no source key and has no schema. */
74
+ readonly computed: boolean;
75
+ /** Whether a value must be given: the schema rejects a missing one (no default, not optional). */
76
+ readonly required: boolean;
77
+ /** The schema's description (`.describe(...)` or `.meta({ description })`). */
78
+ readonly description?: string;
79
+ /** The schema's default, when it has one. */
80
+ readonly default?: unknown;
81
+ /** The JSON Schema of the accepted input (unrepresentable parts become `{}`). */
82
+ readonly schema?: Readonly<Record<string, unknown>>;
83
+ }
84
+ /** The reading surface of a config: {@link Env} has it, and so does a {@link mergeEnvs} view over several. */
85
+ interface EnvView<T extends object> {
66
86
  /** Resolve a single field (lazily). Throws an {@link EnvError} if that field is missing/invalid. */
67
87
  get<K extends keyof T>(key: K): T[K];
68
- /** Override a single field/source key (typed value or raw string). */
88
+ /**
89
+ * Override a single field (typed value or raw string), written under the field's source key (its prefixed name,
90
+ * or its first alias). A key that names no field is written as a source key, as given.
91
+ */
69
92
  set<K extends keyof T>(key: K, value: T[K] | string): void;
70
- /** Override several fields/source keys at once (e.g. pass `process.env` or a loaded `.env`). */
93
+ /** Override several source keys at once (e.g. pass `process.env` or a loaded `.env`); keys are used as given. */
71
94
  set(updates: EnvSet<T>): void;
72
95
  /** Resolve every field. Throws an aggregated {@link EnvError} listing all missing/invalid fields. */
73
96
  parse(): T;
@@ -77,6 +100,16 @@ interface Env<T extends object> {
77
100
  on<K extends keyof T>(key: K, listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
78
101
  /** Subscribe to several fields' changes with one listener. */
79
102
  on<K extends keyof T>(keys: readonly K[], listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
103
+ /**
104
+ * Describe every field, in declaration order. A factory field is run to find its schema; one that throws (an
105
+ * earlier field it reads is invalid) is reported as required, without `schema`, `default` or `description`.
106
+ */
107
+ describe(): readonly EnvFieldInfo[];
108
+ }
109
+ /** A typed, lazy, reactive config object built by {@link env}. */
110
+ interface Env<T extends object> extends EnvView<T> {
111
+ /** Add more fields; their factories receive the fields resolved so far. Returns a wider {@link Env}. */
112
+ add<A extends EnvInput<T>>(input: A): Env<T & EnvOutput<T, A>>;
80
113
  /** Run `fn` with this env (for grouped setup); returns nothing. */
81
114
  with(fn: (env: Env<T>) => void): void;
82
115
  /** Run `fn` with this env and return its result. */
@@ -85,6 +118,54 @@ interface Env<T extends object> {
85
118
  /** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
86
119
  declare function env<const T extends EnvInput<{}>>(input: T, options?: EnvOptions): Env<EnvOutput<{}, T>>;
87
120
  //#endregion
121
+ //#region src/merge.d.ts
122
+ /**
123
+ * Any config view: every {@link EnvView} member, with field names erased so a view of any fields fits (methods take
124
+ * `never` parameters, so this is the supertype of every `EnvView<T>`). Its fields are read from what `parse` returns.
125
+ */
126
+ interface AnyEnvView {
127
+ get(key: never): unknown;
128
+ set(key: never, value: never): void;
129
+ parse(): object;
130
+ on(...args: never[]): () => void;
131
+ describe(): readonly EnvFieldInfo[];
132
+ }
133
+ /** The fields of a config view. */
134
+ type FieldsOf<E> = E extends {
135
+ parse(): infer T;
136
+ } ? T : never;
137
+ /** The field names of a config view. */
138
+ type NamesOf<E> = keyof FieldsOf<E> & string;
139
+ /** The positions of a tuple (`'0' | '1' | …`); `never` for a plain array. */
140
+ type Positions<T extends readonly unknown[]> = Exclude<keyof T, keyof unknown[]>;
141
+ /** Field names that two different views of `T` both declare. */
142
+ type SharedNames<T extends readonly unknown[]> = { [I in Positions<T>]: { [J in Positions<T>]: J extends I ? never : NamesOf<T[I]> & NamesOf<T[J]> }[Positions<T>] }[Positions<T>];
143
+ /** Turn a union into the intersection of its members. */
144
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
145
+ /**
146
+ * `T` when no field name is declared by more than one of its views; otherwise a tuple whose only element names the
147
+ * clash, so the call fails to compile with that message.
148
+ */
149
+ type DisjointEnvs<T extends readonly unknown[]> = [SharedNames<T>] extends [never] ? T : readonly [`fields declared by more than one env: ${SharedNames<T>}`];
150
+ /** The fields of every view in `T`, as one object type. */
151
+ type MergedFields<T extends readonly unknown[]> = { [K in keyof UnionToIntersection<FieldsOf<T[number]>>]: UnionToIntersection<FieldsOf<T[number]>>[K] };
152
+ /**
153
+ * Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
154
+ * fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
155
+ * {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
156
+ * in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
157
+ * A `once` listener subscribed to all fields fires once per slice.
158
+ */
159
+ declare function mergeEnvs<const T extends readonly [AnyEnvView, ...AnyEnvView[]]>(...envs: T & DisjointEnvs<T>): EnvView<MergedFields<T>>;
160
+ //#endregion
161
+ //#region src/example.d.ts
162
+ /**
163
+ * A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
164
+ * description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
165
+ * commented out when it is optional. Computed fields read no key and are left out.
166
+ */
167
+ declare function envExample(fields: readonly EnvFieldInfo[]): string;
168
+ //#endregion
88
169
  //#region src/provider.d.ts
89
170
  /** A value, or a promise of one. */
90
171
  type MaybePromise<T> = T | Promise<T>;
@@ -115,7 +196,7 @@ declare function staticProvider(value: string | undefined): EnvProvider;
115
196
  //#endregion
116
197
  //#region src/async.d.ts
117
198
  /** A field for {@link asyncEnv}: zod schema, dynamic binding, or a (possibly async) factory. */
118
- type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<any> | ((inherited: Inherited) => MaybePromise<z.ZodType | any>);
199
+ type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<unknown> | ((inherited: Inherited) => unknown);
119
200
  /** The fields passed to {@link asyncEnv} / {@link AsyncEnv.add}. */
120
201
  type AsyncEnvInput<Inherited> = Record<string, AsyncEnvFieldDef<Inherited>>;
121
202
  /** The resolved output type of an {@link AsyncEnvInput}. */
@@ -126,9 +207,9 @@ interface AsyncEnv<T extends object> {
126
207
  add<A extends AsyncEnvInput<T>>(input: A): AsyncEnv<T & AsyncEnvOutput<T, A>>;
127
208
  /** Resolve a single field (lazily, async). Rejects with an {@link EnvError} if missing/invalid. */
128
209
  get<K extends keyof T>(key: K): Promise<T[K]>;
129
- /** Override a single field/source key (typed value or raw string). */
210
+ /** Override a single field (typed value or raw string), under its source key (prefixed name or first alias); another key is used as given. */
130
211
  set<K extends keyof T>(key: K, value: T[K] | string): void;
131
- /** Override several fields/source keys at once. */
212
+ /** Override several source keys at once; keys are used as given. */
132
213
  set(updates: EnvSet<T>): void;
133
214
  /** Resolve every field. Rejects with an aggregated {@link EnvError} listing all failures. */
134
215
  parse(): Promise<T>;
@@ -158,9 +239,17 @@ interface EnvMeta {
158
239
  /** Several source keys to read this field from — first present wins. */
159
240
  readonly vars?: readonly string[];
160
241
  }
161
- /** The source keys a field reads, in precedence order — its `vars`/`var` meta, else `[key]`. */
162
- declare function varsOf(schema: z.ZodType, key: string): readonly string[];
242
+ /**
243
+ * The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
244
+ * `[prefix + key]`.
245
+ */
246
+ declare function varsOf(schema: z.ZodType, key: string, prefix?: string): readonly string[];
247
+ /**
248
+ * The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
249
+ * a factory field, whose schema is only known once it runs; it is written under its prefixed name.
250
+ */
251
+ declare function sourceKeyOf(schema: z.ZodType | undefined, key: string, prefix?: string): string;
163
252
  /** Attach aliasing metadata: read `schema` from the first present of `keys`. */
164
253
  declare function alias<S extends z.ZodType>(schema: S, ...keys: string[]): S;
165
254
  //#endregion
166
- export { type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type MaybePromise, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, isDynamic, mergeSources, parseDotenv, pollProvider, resolveRaw, staticProvider, varsOf };
255
+ export { type AnyEnvView, type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DisjointEnvs, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvFieldInfo, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type EnvView, type MaybePromise, type MergedFields, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, envExample, isDynamic, mergeEnvs, mergeSources, parseDotenv, pollProvider, resolveRaw, sourceKeyOf, staticProvider, varsOf };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { t as parseDotenv } from "./dotenv.js";
2
+ import { z } from "zod";
2
3
  //#region src/coerce.ts
3
4
  /** Read a schema's internal `_zod.def` (zod v4 stores its definition there). */
4
5
  function def(schema) {
@@ -98,12 +99,23 @@ function coerce(schema, value, words) {
98
99
  function readMeta(schema) {
99
100
  return schema.meta?.();
100
101
  }
101
- /** The source keys a field reads, in precedence order — its `vars`/`var` meta, else `[key]`. */
102
- function varsOf(schema, key) {
102
+ /**
103
+ * The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
104
+ * `[prefix + key]`.
105
+ */
106
+ function varsOf(schema, key, prefix = "") {
103
107
  const meta = readMeta(schema);
104
108
  if (meta?.vars && meta.vars.length > 0) return meta.vars;
105
109
  if (meta?.var) return [meta.var];
106
- return [key];
110
+ return [prefix + key];
111
+ }
112
+ /**
113
+ * The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
114
+ * a factory field, whose schema is only known once it runs; it is written under its prefixed name.
115
+ */
116
+ function sourceKeyOf(schema, key, prefix = "") {
117
+ const meta = schema === void 0 ? void 0 : readMeta(schema);
118
+ return meta?.vars?.[0] ?? meta?.var ?? prefix + key;
107
119
  }
108
120
  /** Attach aliasing metadata: read `schema` from the first present of `keys`. */
109
121
  function alias(schema, ...keys) {
@@ -185,6 +197,32 @@ function changed(prev, next, deep) {
185
197
  }
186
198
  //#endregion
187
199
  //#region src/env.ts
200
+ /**
201
+ * # `env(...)` — the typed, lazy, reactive config builder
202
+ *
203
+ * Declare a config as a record of fields and get back a typed {@link Env}. A field is either:
204
+ *
205
+ * - a **zod schema** — read from a source key (its name, or its `vars` meta for aliasing),
206
+ * coerced from its string form and validated; or
207
+ * - a **factory** `(inherited) => …` — called with the already-resolved fields from *earlier*
208
+ * `add(...)` groups. Return another **zod schema** (a schema that depends on other values),
209
+ * or return a **plain value** (a computed field derived purely from other fields).
210
+ *
211
+ * Resolution is **lazy**: nothing is parsed until you `get(...)` or `parse()`. Values come from
212
+ * `process.env` by default; `set(...)` layers overrides on top. `on(...)` subscribes to changes
213
+ * that `set(...)` causes; the keys a factory reads are auto-tracked so computed fields update too.
214
+ *
215
+ * ```ts
216
+ * const ENV = env({ PORT: z.coerce.number().default(3000) })
217
+ * .add({ IS_PROD: (e) => e.NODE_ENV === 'production' }) // factory: computed value
218
+ *
219
+ * ENV.set(process.env)
220
+ * ENV.get('PORT') // typed, throws if invalid
221
+ * ENV.parse() // resolve everything; throws an aggregated EnvError
222
+ * ```
223
+ *
224
+ * @module
225
+ */
188
226
  /** Normalize {@link EnvOptions} into the lowercased {@link BooleanWords} the coercer consumes. */
189
227
  function toWords(options) {
190
228
  const b = options?.booleans;
@@ -205,9 +243,11 @@ var EnvImpl = class {
205
243
  globalLs = /* @__PURE__ */ new Set();
206
244
  prev = /* @__PURE__ */ new Map();
207
245
  words;
246
+ prefix;
208
247
  groupCount = 0;
209
248
  constructor(input, options) {
210
249
  this.words = toWords(options);
250
+ this.prefix = options?.prefix ?? "";
211
251
  this.define(input);
212
252
  }
213
253
  /** Register a group of fields under the next group index. */
@@ -233,13 +273,12 @@ var EnvImpl = class {
233
273
  /** A proxy over fields from groups *earlier* than `group`, recording which keys a factory reads. */
234
274
  inheritedFor(group) {
235
275
  const reads = /* @__PURE__ */ new Set();
236
- const self = this;
237
- const visible = (prop) => typeof prop === "string" && (self.defs.get(prop)?.group ?? Infinity) < group;
276
+ const visible = (prop) => typeof prop === "string" && (this.defs.get(prop)?.group ?? Infinity) < group;
238
277
  return {
239
278
  proxy: new Proxy(Object.create(null), {
240
- get: (_t, prop) => visible(prop) ? (reads.add(prop), self.resolve(prop)) : void 0,
279
+ get: (_t, prop) => visible(prop) ? (reads.add(prop), this.resolve(prop)) : void 0,
241
280
  has: (_t, prop) => visible(prop),
242
- ownKeys: () => [...self.defs.values()].filter((f) => f.group < group).map((f) => f.key),
281
+ ownKeys: () => [...this.defs.values()].filter((f) => f.group < group).map((f) => f.key),
243
282
  getOwnPropertyDescriptor: () => ({
244
283
  enumerable: true,
245
284
  configurable: true
@@ -248,34 +287,32 @@ var EnvImpl = class {
248
287
  reads
249
288
  };
250
289
  }
290
+ /**
291
+ * What a field is made of: its schema, or the value its factory computed. A factory is run against the fields of
292
+ * earlier groups (recording what it reads); a throwing factory becomes an {@link EnvError} scoped to the field.
293
+ */
294
+ produce(def) {
295
+ if (def.kind === "schema") return { schema: def.schema };
296
+ const { proxy, reads } = this.inheritedFor(def.group);
297
+ let produced;
298
+ try {
299
+ produced = def.factory(proxy);
300
+ } catch (err) {
301
+ throw new EnvError([customIssue(def.key, errMessage(err))]);
302
+ } finally {
303
+ this.deps.set(def.key, reads);
304
+ }
305
+ return isZodType(produced) ? { schema: produced } : { value: produced };
306
+ }
251
307
  /** Resolve `key` to its value (cached). Throws an {@link EnvError} (scoped to `key`) on failure. */
252
308
  resolve(key) {
253
309
  if (this.cache.has(key)) return this.cache.get(key);
254
- const def = this.defs.get(key);
255
- let schema;
256
- let computed;
257
- let isComputed = false;
258
- if (def.kind === "schema") schema = def.schema;
259
- else {
260
- const { proxy, reads } = this.inheritedFor(def.group);
261
- let produced;
262
- try {
263
- produced = def.factory(proxy);
264
- } catch (err) {
265
- this.deps.set(key, reads);
266
- throw new EnvError([customIssue(key, errMessage(err))]);
267
- }
268
- this.deps.set(key, reads);
269
- if (isZodType(produced)) schema = produced;
270
- else {
271
- computed = produced;
272
- isComputed = true;
273
- }
274
- }
310
+ const made = this.produce(this.defs.get(key));
275
311
  let result;
276
- if (isComputed) result = computed;
312
+ if ("value" in made) result = made.value;
277
313
  else {
278
- const raw = resolveRaw(this.source(), varsOf(schema, key));
314
+ const { schema } = made;
315
+ const raw = resolveRaw(this.source(), varsOf(schema, key, this.prefix));
279
316
  const res = schema.safeParse(raw !== void 0 ? coerce(schema, raw, this.words) : void 0);
280
317
  if (!res.success) throw new EnvError(keyed(key, res.error.issues));
281
318
  result = res.data;
@@ -283,6 +320,28 @@ var EnvImpl = class {
283
320
  this.cache.set(key, result);
284
321
  return result;
285
322
  }
323
+ describe() {
324
+ return [...this.defs.values()].map((def) => {
325
+ let made;
326
+ try {
327
+ made = this.produce(def);
328
+ } catch {
329
+ return {
330
+ key: def.key,
331
+ vars: [this.prefix + def.key],
332
+ computed: false,
333
+ required: true
334
+ };
335
+ }
336
+ if ("value" in made) return {
337
+ key: def.key,
338
+ vars: [],
339
+ computed: true,
340
+ required: false
341
+ };
342
+ return describeSchema(def.key, made.schema, this.prefix);
343
+ });
344
+ }
286
345
  get(key) {
287
346
  return this.resolve(key);
288
347
  }
@@ -298,10 +357,16 @@ var EnvImpl = class {
298
357
  return out;
299
358
  }
300
359
  set(keyOrUpdates, value) {
301
- const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
360
+ const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
302
361
  Object.assign(this.overrides, updates);
303
362
  this.invalidate();
304
363
  }
364
+ /** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
365
+ sourceKey(key) {
366
+ const def = this.defs.get(key);
367
+ if (def === void 0) return key;
368
+ return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
369
+ }
305
370
  add(input) {
306
371
  this.define(input);
307
372
  this.cache.clear();
@@ -412,11 +477,118 @@ var EnvImpl = class {
412
477
  }
413
478
  }
414
479
  };
480
+ /** Describe a field read through `schema`: the keys it reads, whether it is required, and its documented shape. */
481
+ function describeSchema(key, schema, prefix) {
482
+ const { $schema: _dialect, ...shape } = z.toJSONSchema(schema, {
483
+ io: "input",
484
+ unrepresentable: "any"
485
+ });
486
+ return {
487
+ key,
488
+ vars: varsOf(schema, key, prefix),
489
+ computed: false,
490
+ required: !schema.safeParse(void 0).success,
491
+ ...schema.description === void 0 ? {} : { description: schema.description },
492
+ ..."default" in shape ? { default: shape.default } : {},
493
+ schema: shape
494
+ };
495
+ }
415
496
  /** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
416
497
  function env(input, options) {
417
498
  return new EnvImpl(input, options);
418
499
  }
419
500
  //#endregion
501
+ //#region src/merge.ts
502
+ /** The engine behind {@link mergeEnvs}: each call goes to the slice that owns the field, or to every slice. */
503
+ var MergedEnv = class {
504
+ slices;
505
+ owner = /* @__PURE__ */ new Map();
506
+ constructor(slices) {
507
+ this.slices = slices;
508
+ for (const slice of slices) for (const { key } of slice.describe()) {
509
+ if (this.owner.has(key)) throw new Error(`mergeEnvs: field "${key}" is declared by more than one env`);
510
+ this.owner.set(key, slice);
511
+ }
512
+ }
513
+ sliceOf(key) {
514
+ const slice = this.owner.get(key);
515
+ if (slice === void 0) throw new Error(`mergeEnvs: no env declares field "${key}"`);
516
+ return slice;
517
+ }
518
+ get(key) {
519
+ return this.sliceOf(key).get(key);
520
+ }
521
+ set(keyOrUpdates, value) {
522
+ if (typeof keyOrUpdates === "string") {
523
+ const slice = this.owner.get(keyOrUpdates);
524
+ if (slice !== void 0) return slice.set(keyOrUpdates, value);
525
+ for (const one of this.slices) one.set({ [keyOrUpdates]: value });
526
+ return;
527
+ }
528
+ for (const one of this.slices) one.set(keyOrUpdates);
529
+ }
530
+ parse() {
531
+ const out = {};
532
+ const issues = [];
533
+ for (const slice of this.slices) try {
534
+ Object.assign(out, slice.parse());
535
+ } catch (err) {
536
+ if (!(err instanceof EnvError)) throw err;
537
+ issues.push(...err.issues);
538
+ }
539
+ if (issues.length > 0) throw new EnvError(issues);
540
+ return out;
541
+ }
542
+ on(arg1, arg2, arg3) {
543
+ let offs;
544
+ if (typeof arg1 === "function") {
545
+ const listener = arg1;
546
+ offs = this.slices.map((slice) => slice.on(listener, arg2));
547
+ } else {
548
+ const keys = Array.isArray(arg1) ? arg1 : [String(arg1)];
549
+ const listener = arg2;
550
+ offs = keys.map((key) => this.sliceOf(key).on(key, listener, arg3));
551
+ }
552
+ return () => {
553
+ for (const off of offs) off();
554
+ };
555
+ }
556
+ describe() {
557
+ return this.slices.flatMap((slice) => slice.describe());
558
+ }
559
+ };
560
+ /**
561
+ * Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
562
+ * fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
563
+ * {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
564
+ * in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
565
+ * A `once` listener subscribed to all fields fires once per slice.
566
+ */
567
+ function mergeEnvs(...envs) {
568
+ return new MergedEnv(envs);
569
+ }
570
+ //#endregion
571
+ //#region src/example.ts
572
+ /** The text a value has in a `.env` file: strings as written, anything else as JSON. */
573
+ function dotenvText(value) {
574
+ return typeof value === "string" ? value : JSON.stringify(value);
575
+ }
576
+ /**
577
+ * A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
578
+ * description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
579
+ * commented out when it is optional. Computed fields read no key and are left out.
580
+ */
581
+ function envExample(fields) {
582
+ const blocks = fields.filter((field) => !field.computed).map((field) => {
583
+ const key = field.vars[0] ?? field.key;
584
+ const notes = [field.description, field.required ? "(required)" : void 0].filter((note) => note !== void 0);
585
+ const comment = notes.length === 0 ? [] : notes.join(" ").split("\n").map((text) => `# ${text}`);
586
+ const line = "default" in field ? `${key}=${dotenvText(field.default)}` : field.required ? `${key}=` : `# ${key}=`;
587
+ return [...comment, line].join("\n");
588
+ });
589
+ return blocks.length === 0 ? "" : `${blocks.join("\n\n")}\n`;
590
+ }
591
+ //#endregion
420
592
  //#region src/provider.ts
421
593
  /** Bind a field to a live {@link EnvProvider}; values are validated against `schema`. */
422
594
  function dynamic(provider, schema) {
@@ -466,9 +638,11 @@ var AsyncEnvImpl = class {
466
638
  started = /* @__PURE__ */ new Set();
467
639
  watchers = /* @__PURE__ */ new Map();
468
640
  words;
641
+ prefix;
469
642
  groupCount = 0;
470
643
  constructor(input, options) {
471
644
  this.words = toWords(options);
645
+ this.prefix = options?.prefix ?? "";
472
646
  this.define(input);
473
647
  }
474
648
  define(input) {
@@ -524,7 +698,7 @@ var AsyncEnvImpl = class {
524
698
  /** Do the work of resolving one field (no memoization — see {@link resolve}). */
525
699
  async compute(key) {
526
700
  const def = this.defs.get(key);
527
- if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key)));
701
+ if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key, this.prefix)));
528
702
  if (def.kind === "dynamic") {
529
703
  await this.ensureStarted(def);
530
704
  return this.validate(def.schema, key, this.dynamicRaw.get(def.key));
@@ -543,7 +717,7 @@ var AsyncEnvImpl = class {
543
717
  throw new EnvError([customIssue(key, errMessage(err))]);
544
718
  }
545
719
  this.deps.set(key, reads);
546
- if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key)));
720
+ if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key, this.prefix)));
547
721
  return produced;
548
722
  }
549
723
  /** Resolve `key` (memoized per snapshot). Cleared by `set`/provider updates. */
@@ -580,10 +754,16 @@ var AsyncEnvImpl = class {
580
754
  return out;
581
755
  }
582
756
  set(keyOrUpdates, value) {
583
- const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
757
+ const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
584
758
  Object.assign(this.overrides, updates);
585
759
  this.emitChange();
586
760
  }
761
+ /** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
762
+ sourceKey(key) {
763
+ const def = this.defs.get(key);
764
+ if (def === void 0) return key;
765
+ return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
766
+ }
587
767
  add(input) {
588
768
  this.define(input);
589
769
  this.cache.clear();
@@ -719,4 +899,4 @@ function asyncEnv(input, options) {
719
899
  return new AsyncEnvImpl(input, options);
720
900
  }
721
901
  //#endregion
722
- export { DEFAULT_FALSE, DEFAULT_TRUE, EnvError, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, isDynamic, mergeSources, parseDotenv, pollProvider, resolveRaw, staticProvider, varsOf };
902
+ export { DEFAULT_FALSE, DEFAULT_TRUE, EnvError, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, envExample, isDynamic, mergeEnvs, mergeSources, parseDotenv, pollProvider, resolveRaw, sourceKeyOf, staticProvider, varsOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/env",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Typed, lazy, reactive environment/config on zod — env({...}) reads process.env, records, .env/JSON files; coerces scalars and JSON-decodes complex types; computed fields, aliasing, and async/dynamic (live, subscribable) values",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -48,14 +48,14 @@
48
48
  "node": ">=18"
49
49
  },
50
50
  "peerDependencies": {
51
- "zod": "^4"
51
+ "zod": "^4.6.7"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@vitest/coverage-v8": "^2.1.8",
55
55
  "publint": "^0.3.0",
56
56
  "tsdown": "^0.12.0",
57
57
  "vitest": "^2.1.8",
58
- "zod": "^4.4.3"
58
+ "zod": "^4.6.7"
59
59
  },
60
60
  "keywords": [
61
61
  "ayepi",