Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .bumpy/timezone-data-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
varlock: minor
env-spec-language: patch
---

Add built-in `timeZone` data type that validates IANA time zone names. Rename the `simple-object` data type to `simpleObject` so all built-in type names are camelCase (`simple-object` still works but logs a deprecation warning).
17 changes: 13 additions & 4 deletions packages/varlock-website/src/content/docs/reference/data-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,15 @@ MY_DATE=2024-03-20T15:30:00Z
```
</div>

<div>
### `timeZone`
Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Names are case-sensitive, and UTC offsets like `+05:00` are rejected.
```env-spec
# @type=timeZone
APP_TIME_ZONE=Europe/Madrid
```
</div>

<div>
### `uuid`
Checks for valid [UUID](https://en.wikipedia.org/wiki/UUID) (versions 1-8 per [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562), including `NIL` and `MAX`).
Expand Down Expand Up @@ -327,10 +336,10 @@ MY_HASH=d41d8cd98f00b204e9800998ecf8427e
</div>

<div>
### `simple-object`
Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values.
### `simpleObject`
Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. Formerly named `simple-object`, which still works but is deprecated.
```env-spec
# @type=simple-object
# @type=simpleObject
MY_OBJECT={"key": "value"}
```
</div>
Expand Down Expand Up @@ -430,6 +439,6 @@ Validation errors are reported per entry (`"apac" is not a valid key - ...`).

In application code, `ENV.ENDPOINTS` is typed as `Record<string, string>` (enum-constrained keys narrow further). In `process.env` the value is JSON.

An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simple-object` type behaves the same as a bare `record`.
An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simpleObject` type behaves the same as a bare `record`.
</div>
</div>
2 changes: 1 addition & 1 deletion packages/varlock/BUNDLED_ICONS_LICENSES.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 56 additions & 1 deletion packages/varlock/src/env-graph/lib/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,7 +795,7 @@ const DomainDataType = createEnvGraphDataType(


const SimpleObjectDataType = createEnvGraphDataType({
name: 'simple-object',
name: 'simpleObject',
icon: 'tabler:code-dots', // curly brackets with nothing inside
coercedType: 'object',
coerce(val) {
Expand Down Expand Up @@ -973,6 +973,52 @@ const IsoDateDataType = createEnvGraphDataType({
});


// Validation leans on the runtime's own tz database via `Intl`, so we ship no zone list.
// Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match
// names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are
// case-sensitive, so we check both explicitly.
// NOTE: mirrored in packages/vscode-plugin/src/diagnostics-core.ts - keep them in sync.
let lowercaseTimeZoneLookup: Map<string, string> | undefined;
function findTimeZoneByCase(val: string): string | undefined {
if (!lowercaseTimeZoneLookup) {
lowercaseTimeZoneLookup = new Map();
const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array<string> | undefined;
for (const zone of zones ?? []) lowercaseTimeZoneLookup.set(zone.toLowerCase(), zone);
}
return lowercaseTimeZoneLookup.get(val.toLowerCase());
}
/** returns an error message, or undefined if `val` is a correctly cased IANA time zone name */
function checkTimeZoneName(val: string): string | undefined {
if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset';
let resolved: string;
try {
resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone;
} catch {
return `"${val}" is not a known IANA time zone`;
}
if (resolved === val) return;
const correctCase = findTimeZoneByCase(val)
?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined);
if (correctCase) {
return correctCase === val ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`;
}
// A legacy alias (e.g. `US/Eastern`) that the engine canonicalized to another zone. Engines
// do not expose alias casing, but every segment of every IANA name starts with an uppercase
// letter or digit, so a lowercase start is always wrong.
if (val.split('/').some((segment) => /^[a-z]/.test(segment))) {
return `Time zone names are case-sensitive, "${val}" is miscased (it is an alias of "${resolved}")`;
}
}
const TimeZoneDataType = createEnvGraphDataType({
name: 'timeZone',
icon: 'mdi:map-clock-outline',
typeDescription: 'IANA time zone name (e.g. `Europe/Madrid`)',
validate(val) {
const error = checkTimeZoneName(val);
return error ? new ValidationError(error) : true;
},
});

// RFC 9562 versions 1-8 (captures the version digit), plus the special NIL and MAX UUIDs
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
Expand Down Expand Up @@ -1384,6 +1430,14 @@ const RecordDataType = createEnvGraphDataType((settings?: RecordDataTypeSettings
};
});

/**
* Old type names still accepted (with a deprecation warning) and the name that replaced them.
* TODO: drop in the next major.
*/
export const DEPRECATED_DATA_TYPE_ALIASES: Record<string, string> = {
'simple-object': 'simpleObject',
};

export const BaseDataTypes: Array<EnvGraphDataTypeFactory> = [
StringDataType,
NumberDataType,
Expand All @@ -1397,6 +1451,7 @@ export const BaseDataTypes: Array<EnvGraphDataTypeFactory> = [
PortDataType,
SemverDataType,
IsoDateDataType,
TimeZoneDataType,
UuidDataType,
Md5DataType,
DurationDataType,
Expand Down
5 changes: 4 additions & 1 deletion packages/varlock/src/env-graph/lib/env-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './da
import { computeFilteredKeys, type ParsedItemFilter } from './item-filter';

import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver';
import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types';
import { BaseDataTypes, DEPRECATED_DATA_TYPE_ALIASES, type EnvGraphDataTypeFactory } from './data-types';
import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils';
import { ResolutionError, SchemaError, ValidationError } from './errors';
import {
Expand Down Expand Up @@ -491,6 +491,9 @@ export class EnvGraph {
for (const dataType of BaseDataTypes) {
this.registerDataType(dataType);
}
for (const [alias, name] of Object.entries(DEPRECATED_DATA_TYPE_ALIASES)) {
this.dataTypesRegistry[alias] = this.dataTypesRegistry[name];
}
// register base resolvers (concat, ref, exec, etc)
for (const resolverClass of BaseResolvers) {
this.registerResolver(resolverClass);
Expand Down
17 changes: 15 additions & 2 deletions packages/varlock/src/env-graph/lib/type-decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
ParsedEnvSpecStaticValue,
type ParsedEnvSpecDecorator,
} from '@env-spec/parser';
import type { CoercedType, EnvGraphDataType, EnvGraphDataTypeFactory } from './data-types';
import {
DEPRECATED_DATA_TYPE_ALIASES, type CoercedType, type EnvGraphDataType, type EnvGraphDataTypeFactory,
} from './data-types';
import {
assertUnwrappedRegexSource, convertParsedValueToResolvers, deprecatedRegexStringWarning,
type Resolver,
Expand Down Expand Up @@ -98,10 +100,19 @@ function unknownDataTypeError(name: string, context?: string): SchemaError {
return new SchemaError(`${context ? `${context} - ` : ''}unknown data type: ${name}`, {
// `object` is the natural guess for what varlock calls `record` (reserved for a
// possible future per-key shape type)
...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simple-object for untyped JSON objects' } : {},
...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simpleObject for untyped JSON objects' } : {},
});
}

function warnIfDeprecatedTypeName(ctx: TypeSpecContext, name: string, context?: string) {
const replacement = DEPRECATED_DATA_TYPE_ALIASES[name];
if (!replacement) return;
pushWarningOnce(ctx.warnings, new SchemaError(
`${context ? `${context} - ` : ''}data type "${name}" is deprecated, use "${replacement}" instead`,
{ isWarning: true, tip: 'it will be removed in the next major version' },
));
}

function typeSpecDisplayName(node: TypeSpecNode): string {
return node instanceof ParsedEnvSpecFunctionCall ? node.name : String(node.value);
}
Expand Down Expand Up @@ -191,6 +202,7 @@ function buildBareTypePlan(
context?: string,
): TypeSpecPlan {
if (!(name in ctx.registry)) throw unknownDataTypeError(name, context);
warnIfDeprecatedTypeName(ctx, name, context);
if (name === 'enum') throw emptyEnumError(context ?? '@type=enum');
return { deferred: [], build: () => ctx.registry[name]() };
}
Expand Down Expand Up @@ -505,6 +517,7 @@ function buildTypeCallPlan(
): TypeSpecPlan {
const name = fnCall.name;
if (!(name in ctx.registry)) throw unknownDataTypeError(name, context);
warnIfDeprecatedTypeName(ctx, name, context);

if (name === 'array') return buildArrayTypePlan(ctx, fnCall, context);
if (name === 'record') return buildRecordTypePlan(ctx, fnCall, context);
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ describe('runtime re-injection of composite values (initVarlockEnv)', () => {
});
});

describe('simple-object serialization (issue #900)', () => {
it('simple-object values serialize as JSON, not "[object Object]"', async () => {
describe('simpleObject serialization (issue #900)', () => {
it('simpleObject values serialize as JSON, not "[object Object]"', async () => {
const g = await loadAndResolve(outdent`
# @type=simple-object
# @type=simpleObject
MY_OBJECT='{"key": "value"}'
`);
expect(g.configSchema.MY_OBJECT.resolvedValue).toEqual({ key: 'value' });
Expand Down
71 changes: 71 additions & 0 deletions packages/varlock/src/env-graph/test/data-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,77 @@ describe('md5 data type', () => {
});
});

describe('deprecated data type names', () => {
it.each([
'@type=simple-object',
'@type=array(simple-object)',
])('%s still works but warns', async (decorator) => {
const g = await loadAndResolve(outdent`
# ${decorator}
OBJ='${decorator.includes('array') ? '[{"a": 1}]' : '{"a": 1}'}'
`);
const item = g.configSchema.OBJ;
expect(item.isValid).toBe(true);
expect(item.dataType?.name).toBe(decorator.includes('array') ? 'array' : 'simpleObject');
const warnings = item.errors.filter((e) => (e as any).isWarning);
expect(warnings.map((w) => w.message).join('\n')).toContain('use "simpleObject" instead');
});
});

describe('timeZone data type', () => {
it.each([
'Europe/Madrid',
'UTC',
'America/Argentina/Buenos_Aires',
'America/Buenos_Aires', // legacy alias
'US/Eastern', // legacy alias, canonicalized to America/New_York on Node
'Etc/UTC',
'Etc/GMT+5',
])('accepts %s', async (value) => {
const g = await loadAndResolve(outdent`
# @type=timeZone
TZ=${value}
`);
expect(g.configSchema.TZ.isValid).toBe(true);
expect(g.configSchema.TZ.resolvedValue).toBe(value);
});

it.each([
['typo', 'Europe/Madird'],
['UTC offset', '+05:00'],
['compact UTC offset', '-0800'],
['garbage', 'not a zone'],
])('rejects %s', async (_label, value) => {
const g = await loadAndResolve(outdent`
# @type=timeZone
TZ="${value}"
`);
expect(g.configSchema.TZ.isValid).toBe(false);
});

it.each([
['europe/madrid', 'Europe/Madrid'],
['utc', 'UTC'],
])('rejects wrong casing (%s) and suggests the correct name', async (value, suggestion) => {
const g = await loadAndResolve(outdent`
# @type=timeZone
TZ=${value}
`);
expect(g.configSchema.TZ.isValid).toBe(false);
expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`);
});

// Node canonicalizes these to a different zone, so no casing suggestion is available
it.each(['us/eastern', 'etc/utc'])('rejects wrong-cased legacy alias %s', async (value) => {
const g = await loadAndResolve(outdent`
# @type=timeZone
TZ=${value}
`);
expect(g.configSchema.TZ.isValid).toBe(false);
expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('case-sensitive');
});
});

describe('domain data type', () => {
it('accepts a basic domain', async () => {
const g = await loadAndResolve(outdent`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,7 @@ describe('type generation', () => {
FLAG=true # @type=boolean @public
# @type=enum(dev, staging, prod)
APP_ENV=dev # @public
# @type=simple-object
# @type=simpleObject
CONFIG={} # @public
`,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const NON_STRING_TYPE_FIXTURE = outdent`
DEBUG=false # @optional @public
# @type=enum(dev, staging, prod)
APP_ENV=dev # @required @public
# @type=simple-object
# @type=simpleObject
CONFIG={} # @optional @public
API_KEY= # @required @sensitive
`;
Expand Down
28 changes: 28 additions & 0 deletions packages/vscode-plugin/src/diagnostics-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,32 @@ export function getStaticValueWarning(typeInfo: TypeInfo, value: string) {
return undefined;
}

// Mirrors the runtime `timeZone` validator in packages/varlock/src/env-graph/lib/data-types.ts.
let lowercaseTimeZoneLookup: Map<string, string> | undefined;
function checkTimeZoneName(value: string): string | undefined {
if (/^[+-]\d/.test(value)) return 'Value must be an IANA time zone name, not a UTC offset.';
let resolved: string;
try {
resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
} catch {
return 'Value must be a known IANA time zone.';
}
if (resolved === value) return undefined;
if (!lowercaseTimeZoneLookup) {
const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array<string> | undefined;
lowercaseTimeZoneLookup = new Map((zones ?? []).map((zone) => [zone.toLowerCase(), zone]));
}
const correctCase = lowercaseTimeZoneLookup.get(value.toLowerCase())
?? (resolved.toLowerCase() === value.toLowerCase() ? resolved : undefined);
if (correctCase) {
return correctCase === value ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`;
}
if (value.split('/').some((segment) => /^[a-z]/.test(segment))) {
return `Time zone names are case-sensitive, "${value}" is miscased (it is an alias of "${resolved}").`;
}
return undefined;
}

export function validateStaticValue(typeInfo: TypeInfo, value: string) {
switch (typeInfo.name) {
case 'string':
Expand Down Expand Up @@ -676,6 +702,8 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) {
return /^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Z]*)?$/.test(value) && !Number.isNaN(Date.parse(value))
? undefined
: 'Value must be a valid ISO date.';
case 'timeZone':
return checkTimeZoneName(value);
case 'uuid': {
const version = typeInfo.options.version;
const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1];
Expand Down
Loading
Loading