Skip to content

Commit cbe9b52

Browse files
committed
Add @dynamic filter selector; metadata-first scoped resolution for decorator filters; register new reserved env vars; docs
1 parent a1670ec commit cbe9b52

15 files changed

Lines changed: 447 additions & 118 deletions

packages/varlock-website/src/content/docs/guides/dynamic-config.mdx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,39 @@ See framework-specific recipes:
8282

8383
## Prerender/build guardrails
8484

85-
Dynamic config should not be consumed in static prerender/build contexts unless explicitly designed for it.
85+
Baking a public+dynamic value into prerendered output defeats its purpose: you marked it `@dynamic` because the build-time value should not be frozen. Varlock catches this per framework:
8686

87-
Varlock runtime can detect this and warn/error when dynamic keys are accessed during prerender/build phases, helping catch accidental usage early.
87+
- **Next.js**: accessing a public+dynamic key during server rendering marks the route dynamic (it will not be statically prerendered), including access from nested components.
88+
- **Vite-based frameworks** (Astro, SvelteKit, etc.): accessing a public+dynamic key while a build/prerender is running throws an error. Set `_VARLOCK_DYNAMIC_BUILD_ACCESS_MODE=warn` to downgrade it to a warning (e.g. while migrating an existing app).
89+
90+
Sensitive values are not subject to this guard. Reading a sensitive value server-side during a static build (e.g. using an API key to fetch data while generating pages) is fine; leak detection separately errors if the value itself ends up in the built output.
91+
92+
## Filtering by static/dynamic
93+
94+
The [`--filter` selector language](/reference/cli-commands/#filtering-items) supports a `@dynamic` selector (negate it for static items), so you can scope a load or a generated file to one side of the split:
95+
96+
```bash
97+
varlock load --filter="!@dynamic" # only build-time-inlineable items
98+
varlock load --filter="@dynamic" # only runtime-resolved items
99+
varlock run --filter="@dynamic" -- node app # inject only runtime values
100+
```
101+
102+
```env-spec title=".env.schema"
103+
# generate a module covering only runtime public values
104+
# @generateTsTypes(path=public-runtime-env.d.ts, filter="@dynamic,!@sensitive")
105+
```
106+
107+
Like all filters, `@dynamic` filters scope **resolution and validation**, not just output: varlock resolves each item's decorator metadata first (cheap), then only resolves and validates the items the filter selects. So a build-time load can skip runtime-only vars entirely, including their `@required` checks and any value resolvers they use:
108+
109+
```bash
110+
# runtime-only vars (e.g. platform-injected at runtime) don't exist yet at build
111+
# time - exclude them so their @required checks don't fail the build
112+
varlock load --filter='!@dynamic'
113+
```
88114

89115
## Guidance
90116

91-
- Start with `@defaultDynamic=inferFromSensitive` for predictable defaults.
117+
- The default (`@defaultDynamic=inferFromSensitive`) keeps today's behavior: sensitive stays out of bundles, public gets inlined. You only need decorators when you want something different.
92118
- Mark intentional runtime public values with `@dynamic`.
93119
- Keep dynamic+public loading scoped to only the parts of your app that need it.
94120
- Prefer one app-level endpoint/payload shape per app unless you have a strong reason to split.

packages/varlock-website/src/content/docs/reference/cli-commands.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Several commands share these flags. They behave identically wherever they appear
6262

6363
- a key name or glob, e.g. `STRIPE_*` (matches `*` and `?`)
6464
- `!selector` to negate any of the below, e.g. `!STRIPE_DEBUG_KEY`
65-
- `@sensitive` / `@required` to select by decorator
65+
- `@sensitive` / `@required` / `@dynamic` to select by decorator (negate for the opposite, e.g. `!@dynamic` selects static items)
6666
- `#tagname` to select items tagged via [`@tag(tagname)`](/reference/item-decorators/#tag)
6767

6868
**How selectors combine:** every non-negated selector is OR'd together into one inclusion set, regardless of kind: mixing a glob, a `@decorator`, and a `#tag` in the same filter just widens that set. Anything matching a negated (`!`) selector is then subtracted from that set, again regardless of kind. If a filter has no non-negated selectors at all, the inclusion set starts as "everything" before negations are subtracted.
@@ -72,6 +72,8 @@ varlock load --filter="KEY1,!NOT_THIS,STRIPE_*" # KEY1 and STRIPE_* keys, excep
7272
varlock load --filter="@sensitive" # only items marked @sensitive
7373
varlock load --filter="@required" # only required items
7474
varlock load --filter="#billing" # only items tagged @tag(billing)
75+
varlock load --filter="@dynamic" # only runtime-resolved (dynamic) items
76+
varlock load --filter="!@dynamic,!@sensitive" # inlineable public items only
7577
varlock load --filter="@sensitive,#billing" # sensitive items OR billing-tagged items
7678
varlock load --filter="STRIPE_*,!@sensitive" # STRIPE_* keys, minus any that are sensitive
7779
varlock load --filter="!#debug" # everything except items tagged @tag(debug)
@@ -87,7 +89,9 @@ Can also be set via the [`_VARLOCK_FILTER`](/reference/reserved-variables/#_varl
8789

8890
A filter that matches no items (e.g. a typo'd key or tag) prints a warning to stderr. The command still succeeds, with empty output on `load` or no schema vars injected on `run`.
8991

90-
**A key name/glob/tag-only filter also scopes resolution and validation**, not just output: only items it selects (plus their dependencies) are resolved, so an unrelated broken item outside the filter won't block `load`/`run`. This is useful for scoping validation differently across contexts, e.g. a build step that only needs `--filter="#frontend"` shouldn't fail because an unrelated backend-only var is misconfigured. `@sensitive`/`@required` selectors can't be scoped this way (which items match isn't knowable until the graph is already resolved), so a filter using either falls back to resolving and validating everything, same as no `--filter` at all.
92+
**A `--filter` also scopes resolution and validation**, not just output: only items it selects (plus their dependencies) are resolved, so an unrelated broken item outside the filter won't block `load`/`run`, and excluded items' value resolvers (exec commands, secrets managers, etc.) never run. This is useful for scoping validation differently across contexts, e.g. a build step that only needs `--filter="#frontend"` shouldn't fail because an unrelated backend-only var is misconfigured, and `--filter="!@dynamic"` at build time skips runtime-only vars (e.g. platform-injected values that don't exist yet at build time) including their `@required` checks.
93+
94+
Decorator selectors match on *computed* state, which can be value-dependent (e.g. `@required=forEnv(prod)`), so for those varlock resolves each candidate item's decorator metadata first (cheap - no value resolvers run), then matches exactly and only resolves values for selected items. Values that decorator functions themselves reference (e.g. `@required=eq($OTHER, x)` needs `OTHER`) are true dependencies of evaluating the filter and do get resolved.
9195

9296
## Commands reference
9397

packages/varlock-website/src/content/docs/reference/item-decorators.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ Sets whether the item is _dynamic_ - meaning integrations should avoid replacing
158158

159159
By default, dynamic behavior follows sensitivity (sensitive items are dynamic, non-sensitive items are static), but this can be overridden globally with [`@defaultDynamic`](/reference/root-decorators/#defaultdynamic).
160160

161+
Items can also be selected by this state via the `@dynamic` selector (or `!@dynamic` for static items) in the [`--filter` language](/reference/cli-commands/#filtering-items). See the [Static vs Dynamic Config guide](/guides/dynamic-config/) for the full model.
162+
161163
```env-spec
162164
# @dynamic
163165
PUBLIC_RUNTIME_FLAG=

packages/varlock-website/src/content/docs/reference/reserved-variables.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Fallback for the [`--filter`](/reference/cli-commands/#filtering-items) flag on
4444
_VARLOCK_FILTER="#billing" varlock load --format json
4545
```
4646

47+
### `_VARLOCK_DYNAMIC_BUILD_ACCESS_MODE`
48+
49+
Set to `warn` to downgrade the build/prerender-time guard on [public+dynamic](/guides/dynamic-config/) config access from an error to a one-time warning per key. Useful while migrating an existing app that still reads dynamic public values in prerendered pages.
50+
4751
### `_VARLOCK_THROW_ON_LOAD_ERROR`
4852

4953
When set (`1` / `true`), [`varlock/auto-load`](/integrations/javascript/#reporting-load-failures) throws the error on a load failure instead of exiting, so an already-initialized error tracker (e.g. Sentry) can capture it via its `uncaughtException` handler. Setting a `globalThis._varlockOnLoadError` hook enables the same throw behavior. See [Reporting load failures](/integrations/javascript/#reporting-load-failures).
@@ -67,3 +71,7 @@ The serialized env graph (resolved config values plus metadata) injected by [`va
6771
### `__VARLOCK_RUN`
6872

6973
A marker set so a child process can detect that it is running under `varlock run`.
74+
75+
### `__VARLOCK_EXECUTION_PHASE`
76+
77+
Set to `build` by build-time integrations (e.g. the Vite plugin during `vite build`) so the runtime can detect app code executing during build/prerender and apply the [public+dynamic access guard](/guides/dynamic-config/#prerenderbuild-guardrails).

packages/varlock-website/src/content/docs/reference/root-decorators.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,7 @@ Every `@generate*` decorator below is a root decorator that _can be called multi
537537
- `path`: Relative filepath to write the generated file to.
538538
- `auto`: Controls whether generation runs automatically on every load (defaults to `true`). Set to `false` to generate only when you run [`varlock codegen`](/reference/cli-commands/#codegen) explicitly, useful in a CI pipeline or a dedicated build step.
539539
- `executeWhenImported`: overrides the default of not executing when the containing file is imported (defaults to `false`).
540-
- `filter`: Restrict this generated file to a subset of items, using the same selector language as the CLI [`--filter` flag](/reference/cli-commands/#filtering-items): key names/globs, `!negations`, `@sensitive`/`@required`, and `#tagname` (set via [`@tag()`](/reference/item-decorators/#tag)). Quote the value if it has more than one comma-separated selector (the decorator parser splits args on unquoted commas), e.g. `filter="STRIPE_*,!STRIPE_DEBUG_KEY"`. Call the same decorator multiple times with different `path`/`filter` pairs to emit several subset files from one schema.
540+
- `filter`: Restrict this generated file to a subset of items, using the same selector language as the CLI [`--filter` flag](/reference/cli-commands/#filtering-items): key names/globs, `!negations`, `@sensitive`/`@required`/`@dynamic`, and `#tagname` (set via [`@tag()`](/reference/item-decorators/#tag)). Quote the value if it has more than one comma-separated selector (the decorator parser splits args on unquoted commas), e.g. `filter="STRIPE_*,!STRIPE_DEBUG_KEY"`. Call the same decorator multiple times with different `path`/`filter` pairs to emit several subset files from one schema.
541541

542542
```env-spec
543543
# only ship billing-tagged keys to the billing package's generated types

packages/varlock/src/cli/commands/load.command.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,12 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
155155
// Generate types before resolving values — uses only non-env-specific schema info
156156
await envGraph.runCodeGeneratorsIfNeeded();
157157

158-
// A --filter using only keys/globs/tags scopes resolution (and validation) to what it
159-
// selects plus dependencies — an unrelated broken item outside the filter won't block this
160-
// load. @sensitive/@required selectors can't be scoped this way (see getResolveKeys), so
161-
// those fall back to resolving everything, same as an unset --filter.
162-
await envGraph.resolveEnvValues(itemFilter?.getResolveKeys(envGraph));
158+
// A --filter scopes resolution (and validation) to what it selects plus dependencies — an
159+
// unrelated broken item outside the filter won't block this load, and excluded items'
160+
// value resolvers never run. Decorator selectors resolve item metadata first, then match
161+
// exactly (see EnvGraph.resolveEnvValuesForFilter).
162+
if (itemFilter) await itemFilter.resolveScoped(envGraph);
163+
else await envGraph.resolveEnvValues();
163164

164165
if (outputFormat === 'json-full') {
165166
checkForConfigErrors(envGraph, { showAll, noThrow: true });

packages/varlock/src/cli/commands/run.command.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,13 @@ export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) =
186186
// Generate types before resolving values — uses only non-env-specific schema info
187187
await envGraph.runCodeGeneratorsIfNeeded();
188188

189-
// A --filter using only keys/globs/tags scopes resolution (and validation) to what it
190-
// selects plus dependencies — an unrelated broken item outside the filter won't block this
191-
// run. @sensitive/@required selectors can't be scoped this way (see getResolveKeys), so
192-
// those fall back to resolving everything, same as an unset --filter.
189+
// A --filter scopes resolution (and validation) to what it selects plus dependencies — an
190+
// unrelated broken item outside the filter won't block this run, and excluded items'
191+
// value resolvers never run. Decorator selectors resolve item metadata first, then match
192+
// exactly (see EnvGraph.resolveEnvValuesForFilter).
193193
const itemFilter = getCliItemFilter(ctx.values.filter);
194-
await envGraph.resolveEnvValues(itemFilter?.getResolveKeys(envGraph));
194+
if (itemFilter) await itemFilter.resolveScoped(envGraph);
195+
else await envGraph.resolveEnvValues();
195196
checkForConfigErrors(envGraph);
196197

197198
// will fail above if there are any errors

packages/varlock/src/cli/helpers/item-filter.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ import { CliExitError } from './exit-error';
66

77
export type CliItemFilter = {
88
/**
9-
* Keys to pass to `resolveEnvValues()` (already includes transitive deps), so `load`/`run` can
10-
* skip resolving (and validating) items outside the filter entirely — e.g. a build step scoped
11-
* to `--filter="#frontend"` doesn't need an unrelated broken backend-only var to be valid.
12-
* Returns `undefined` (= resolve everything) for filters using `@sensitive`/`@required`: their
13-
* matches aren't knowable until the graph is resolved (see `usesDecoratorSelector`), so there's
14-
* nothing to scope down to.
9+
* Resolve only what the filter selects (see `EnvGraph.resolveEnvValuesForFilter()`), so
10+
* `load`/`run` skip resolving (and validating) items outside the filter entirely — e.g. a
11+
* build step scoped to `--filter="#frontend"` doesn't need an unrelated broken backend-only
12+
* var to be valid, and `--filter="!@dynamic"` at build time skips runtime-only vars whose
13+
* values (and `@required` checks) only make sense at runtime. Decorator selectors resolve
14+
* item metadata first (cheap), then match exactly — excluded items' value resolvers never run.
1515
*/
16-
getResolveKeys(graph: EnvGraph): Array<string> | undefined;
17-
/** the keys passing the filter — call after `resolveEnvValues()`, when decorator getters are accurate */
16+
resolveScoped(graph: EnvGraph): Promise<void>;
17+
/** the keys passing the filter — call after resolution, when decorator getters are accurate */
1818
getFilterKeys(items: Array<ConfigItem>): Set<string>;
1919
};
2020

@@ -44,10 +44,8 @@ export function getCliItemFilter(flagValue: string | undefined): CliItemFilter |
4444
}
4545

4646
return {
47-
getResolveKeys(graph) {
48-
if (parsed.usesDecoratorSelector) return undefined;
49-
const matchedKeys = parsed.computeKeys(Object.values(graph.configSchema));
50-
return [...graph.expandKeysWithTransitiveDeps(matchedKeys)];
47+
async resolveScoped(graph) {
48+
await graph.resolveEnvValuesForFilter(parsed);
5149
},
5250
getFilterKeys(items) {
5351
const keys = parsed.computeKeys(items);

0 commit comments

Comments
 (0)