feat(core): Evaluate primitive reads off $now and $today natively (no-changelog) (#40945)

This commit is contained in:
Mike Repeć 2026-10-13 13:31:32 +00:00 committed by GitHub
parent b97fe7c7d4
commit de090cb2a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 246 additions and 6 deletions

View File

@ -1,5 +1,7 @@
import { ExpressionExtensionError } from '../../errors/expression-extension.error';
import { ExpressionError } from '../../errors/expression.error';
import { DateTime } from 'luxon';
import type { IWorkflowDataProxyData } from '../../interfaces';
import {
ARRAY_METHODS,
@ -10,6 +12,8 @@ import {
MAX_WORK,
NUMBER_METHODS,
STRING_METHODS,
TIME_MEMBERS,
TIME_METHODS,
hasOwn,
isArray,
isObj,
@ -118,6 +122,10 @@ const binaryOps: Record<BinaryOp, (l: any, r: any) => unknown> = Object.freeze({
/* eslint-enable @typescript-eslint/no-explicit-any */
function evalMember(node: Extract<SimpleNode, { kind: 'member' }>, env: Env): unknown {
if (node.object.kind === 'timeRoot') {
return evalTimeRead(env.data[node.object.name], TIME_MEMBERS.get(toStr(node.key)));
}
const object = evalNode(node.object, env);
if (node.optional && (object === null || object === undefined)) {
@ -358,6 +366,10 @@ function evalCall(node: Extract<SimpleNode, { kind: 'call' }>, env: Env): unknow
return evalNodeRefCall(receiver, node.method);
}
if (node.receiver.kind === 'timeRoot') {
return evalTimeRead(receiver, TIME_METHODS.get(node.method));
}
// An own property shadowing the method (an engine-resolved $parameter value
// can carry one) would take precedence in the engines.
if (hasOwn(receiver, node.method)) {
@ -429,6 +441,21 @@ function evalNodeRefCall(proxy: unknown, method: string): unknown {
return bounded(fn.call(proxy));
}
/**
* A captured Luxon getter or zero-argument method on `$now`/`$today`. The
* root is the proxy's own DateTime (anything else is the engine's), and the
* result is a primitive by allowlist; a non-primitive would mean a patched
* prototype, which is also the engine's.
*/
function evalTimeRead(root: unknown, fn: NativeMethod | undefined): unknown {
if (fn === undefined || !DateTime.isDateTime(root)) throw new EngineFallbackError();
const result = fn.call(root);
if (!isPrimitive(result)) throw new EngineFallbackError();
return result;
}
function evalNodeRef(node: Extract<SimpleNode, { kind: 'nodeRef' }>, env: Env): unknown {
if (node.ref === 'input') return env.data.$input;
if (node.ref === 'legacy') return env.data.$node[node.name];
@ -498,6 +525,8 @@ function evalNode(node: SimpleNode, env: Env): unknown {
return env.data[node.name];
case 'nodeRef':
return evalNodeRef(node, env);
case 'timeRoot':
return env.data[node.name];
case 'undefined':
return undefined;
case 'member':

View File

@ -1,4 +1,5 @@
import type { namedTypes } from 'ast-types';
import { DateTime } from 'luxon';
// The closed subset grammar, its operator and method allowlists, and the
// limits the parser and the evaluator share. See index.ts for the overview.
@ -33,6 +34,7 @@ export type SimpleNode =
| { kind: 'nodeRef'; ref: 'input' }
| { kind: 'nodeRef'; ref: 'node'; name: string }
| { kind: 'nodeRef'; ref: 'legacy'; name: string }
| { kind: 'timeRoot'; name: TimeRoot }
| { kind: 'undefined' }
| { kind: 'member'; object: SimpleNode; key: string | number; optional: boolean }
| { kind: 'chain'; expression: SimpleNode }
@ -53,9 +55,9 @@ export type SimpleNode =
export type Literal = Extract<SimpleNode, { kind: 'literal' }>;
// Data roots: plain reads off the data proxy. `$now`/`$today` are excluded on
// purpose: they are Luxon DateTimes whose methods would make every useful
// expression on them a CallExpression anyway.
// Data roots: plain reads off the data proxy. `$now`/`$today` are Luxon
// DateTimes, which this grammar cannot hold as a value; they are time roots
// below, readable only through an allowlisted getter or method.
//
// Note on $parameter: the proxy resolves nested `=` parameter values by
// re-entering resolveSimpleParameterValue - a nested non-simple value simply
@ -85,6 +87,18 @@ export const NODE_REF_MEMBERS = new Set(['item']);
export const LEGACY_NODE_REF_MEMBERS = new Set(['json', 'binary']);
export const NODE_REF_METHODS = new Set(['first', 'last', 'all']);
// Time roots: `$now` and `$today`, the proxy's Luxon DateTimes. Like a node
// reference, a time root is only ever built as the object of an allowlisted
// getter (TIME_MEMBERS) or the receiver of an allowlisted zero-argument
// method (TIME_METHODS), each of which yields a primitive; the DateTime
// itself never stands alone in the grammar. Both lists stay clear of Intl:
// the in-isolate Luxon copy formats and localises differently, so
// toFormat(), toLocaleString() and the locale-dependent getters are the
// engine's. The proxy builds both roots with the workflow timezone as
// Luxon's default zone, which is what the isolates set too.
export const TIME_ROOTS = ['$now', '$today'] as const;
export type TimeRoot = (typeof TIME_ROOTS)[number];
// Intrinsics are captured at import so a later patch to a prototype or a
// global (a polyfill, a community package) does not change what the native
// path runs; the isolates never see such a patch either.
@ -96,6 +110,15 @@ export const clone = structuredClone;
export type NativeMethod = (this: unknown, ...args: unknown[]) => unknown;
const captureGetters = (proto: object, names: string[]): ReadonlyMap<string, NativeMethod> => {
const getters = new Map<string, NativeMethod>();
for (const name of names) {
const getter = Object.getOwnPropertyDescriptor(proto, name)?.get;
if (typeof getter === 'function') getters.set(name, getter as NativeMethod);
}
return getters;
};
const captureMethods = (proto: object, names: string[]): ReadonlyMap<string, NativeMethod> => {
const methods = new Map<string, NativeMethod>();
for (const name of names) {
@ -167,7 +190,49 @@ export const ITERATOR_NATIVES = captureMethods(Array.prototype, [...ITERATOR_MET
// A callback parameter may not take one of these names: the body would then
// read the parameter where this grammar reads the root. `undefined` is a
// legal parameter name in JS; here it is a literal.
export const RESERVED_NAMES = new Set<string>([...DATA_ROOTS, '$input', '$node', '$', 'undefined']);
export const RESERVED_NAMES = new Set<string>([
...DATA_ROOTS,
...TIME_ROOTS,
'$input',
'$node',
'$',
'undefined',
]);
// A test that mocks luxon leaves DateTime without a prototype; the lists are
// then empty and every time root declines.
const capturedProto: unknown = Reflect.get(DateTime ?? {}, 'prototype');
const dateTimeProto: object =
typeof capturedProto === 'object' && capturedProto !== null ? capturedProto : {};
export const TIME_MEMBERS = captureGetters(dateTimeProto, [
'year',
'month',
'day',
'hour',
'minute',
'second',
'millisecond',
'weekday',
'weekNumber',
'quarter',
'daysInMonth',
'offset',
]);
// Zero-argument only: the one argument these take is an options object,
// which the grammar has no literal for. toString() is toISO(). Like
// CALLABLE_METHODS, the names stay disjoint from the expression extensions
// (plus/minus/format are extensions in the engines, not Luxon's).
export const TIME_METHODS = captureMethods(dateTimeProto, [
'toMillis',
'toSeconds',
'toUnixInteger',
'toISO',
'toISODate',
'toISOTime',
'toString',
]);
export const CALLABLE_METHODS = new Set([
...STRING_METHODS.keys(),

View File

@ -13,6 +13,7 @@ export {
MAX_RESULT_LENGTH,
MAX_STEPS,
MAX_WORK,
TIME_METHODS,
} from './grammar';
// Fast native evaluation: an in-process interpreter for a closed subset of
@ -21,6 +22,7 @@ export {
// The subset is data path access on the data roots (`$json`, `$parameter`,
// `$vars`, `$binary`, `$itemIndex`, `$runIndex`) and on node references
// (`$('Name').item`, `$('Name').first()`, `$input.item`, `$node['Name'].json`),
// primitive reads off the time roots (`$now.toMillis()`, `$today.year`),
// literals (array literals of literals included), a fixed set of operators,
// calls to a closed allowlist of native string/number/array methods, and the
// five array iterators (some/every/find/filter/map) with a one-parameter

View File

@ -12,6 +12,9 @@ import {
NODE_REF_MEMBERS,
NODE_REF_METHODS,
RESERVED_NAMES,
TIME_MEMBERS,
TIME_METHODS,
TIME_ROOTS,
UNARY_OPS,
isOneOf,
type Literal,
@ -141,6 +144,16 @@ function parseNodeRef(node: AstNode): NodeRef | null {
return null;
}
type TimeRef = Extract<SimpleNode, { kind: 'timeRoot' }>;
// `$now` or `$today` in object/receiver position. As with parseNodeRef, only
// the member and call parsers ask, so the DateTime never stands alone.
function parseTimeRoot(node: AstNode): TimeRef | null {
if (node.type !== 'Identifier' || !isOneOf(TIME_ROOTS, node.name)) return null;
return { kind: 'timeRoot', name: node.name };
}
// Only static keys are representable. Dynamic keys ($json[$json.key]) need
// the sandbox's PrototypeSanitizer, so they stay on the engine. String keys
// are vetted at parse time (isSafeObjectProperty), which keeps prototype
@ -160,6 +173,13 @@ function parseMember(node: namedTypes.MemberExpression, parse: ParseChild): Simp
return { kind: 'member', object: ref, key, optional: node.optional === true };
}
const time = parseTimeRoot(node.object);
if (time !== null) {
if (typeof key !== 'string' || !TIME_MEMBERS.has(key)) return null;
return { kind: 'member', object: time, key, optional: node.optional === true };
}
const object = parse(node.object);
if (object === null) return null;
@ -242,6 +262,16 @@ function parseCall(
return { kind: 'call', receiver: ref, method: property.name, args: [], optional };
}
// `$now.toMillis()` and friends: Luxon's own primitive-returning methods,
// no arguments.
const time = parseTimeRoot(callee.object);
if (time !== null) {
if (!TIME_METHODS.has(property.name) || node.arguments.length > 0) return null;
const optional = node.optional === true || callee.optional === true;
return { kind: 'call', receiver: time, method: property.name, args: [], optional };
}
const method = property.name;
const isIterator = isOneOf(ITERATOR_METHODS, method);
if (!isIterator && !CALLABLE_METHODS.has(method)) return null;

View File

@ -258,6 +258,18 @@ const CANDIDATES = [
'={{ $json.item.names?.map(n => n) }}',
'={{ $json.item.names.map(n => $json.item.count) }}',
'={{ $json.item.names.map(n => n.missing?.deep ?? "d") }}',
// time roots, by getter and zero-argument method
'={{ $now.toMillis() }}',
'={{ $now.toSeconds() }}',
'={{ $now.toUnixInteger() }}',
'={{ $now.toISO() }}',
'={{ $now.toISODate() }}',
'={{ $now.toISOTime() }}',
'={{ $now.toString() }}',
'={{ $now?.toMillis() }}',
'={{ $now.year }}',
'={{ $now?.year }}',
'={{ $today.toISODate() }}',
// compound shapes
'={{ $json.item.name.trim().toUpperCase() }}',
'={{ $json.item.name.toUpperCase().length }}',

View File

@ -330,9 +330,25 @@ export const DECLINED_CORPUS: string[] = [
'={{ $input.first().json.item.names.first() }}',
"={{ $('__proto__').item.json }}",
'={{ $node.constructor.json }}',
// Roots outside the data set.
// Roots outside the data set, and the time roots outside their allowlists.
'={{ $now }}',
'={{ $today }}',
'={{ [$now] }}',
'={{ $json.item.count + $now }}',
'={{ $now.toISO({ suppressMilliseconds: true }) }}',
'={{ $now.toFormat("yyyy") }}',
'={{ $now.toLocaleString() }}',
'={{ $now.zoneName }}',
'={{ $now.setZone("UTC").toISO() }}',
'={{ $now.plus(1, "day").toISO() }}',
'={{ $now.minus({ days: 1 }).toISO() }}',
'={{ $now.isWeekend() }}',
'={{ $now.format("yyyy") }}',
'={{ $now.c.year }}',
'={{ $now.constructor.name }}',
'={{ $now["__proto__"].year }}',
'={{ $json.item.names.map($now => $now.toMillis()) }}',
'={{ $json.item.names.map($today => 1) }}',
'={{ $env.HOME }}',
'={{ $execution.id }}',
'={{ $prevNode.name }}',
@ -438,8 +454,43 @@ export const ENGINE_DECIDED_CORPUS: string[] = [
'={{ $json.item.valueOf }}',
];
// Handled natively, compared to the engine with a clock tolerance: each side
// reads its own `DateTime.now()`.
export const TIME_CORPUS: string[] = [
'={{ $now.toMillis() }}',
'={{ $now.toSeconds() }}',
'={{ $now.toUnixInteger() }}',
'={{ $now.toISO() }}',
'={{ $now.toISODate() }}',
'={{ $now.toISOTime() }}',
'={{ $now.toString() }}',
'={{ $now?.toMillis() }}',
'={{ $now.toMillis?.() }}',
'={{ $now.toMillis() - 60000 }}',
'={{ $now.toISO().slice(0, 10) }}',
'={{ $now.year }}',
'={{ $now.month }}',
'={{ $now.day }}',
'={{ $now.hour }}',
'={{ $now.weekday }}',
'={{ $now.weekNumber }}',
'={{ $now.quarter }}',
'={{ $now.daysInMonth }}',
'={{ $now.offset }}',
'={{ $now?.year }}',
'={{ $now["year"] }}',
'={{ $today.toMillis() }}',
'={{ $today.toISODate() }}',
'={{ $today.toISO() }}',
'={{ $today.year }}',
'={{ $today.weekday > 5 ? "weekend" : "weekday" }}',
'={{ $today.toMillis() < $now.toMillis() }}',
'=since {{ $today.toISODate() }}',
];
export const ALL_CORPORA: string[] = [
...HANDLED_CORPUS,
...TIME_CORPUS,
...RUNTIME_BAILOUT_CORPUS,
...DECLINED_CORPUS,
...ERROR_CORPUS,

View File

@ -2,11 +2,13 @@
import * as Helpers from './helpers';
import { createRunExecutionData } from '../src';
import type { IWorkflowDataProxyData } from '../src/interfaces';
import { ExpressionExtensions } from '../src/extensions';
import {
evaluateNatively,
isNativelyEvaluable,
CALLABLE_METHODS,
TIME_METHODS,
ITERATOR_METHODS,
MAX_RESULT_LENGTH,
MAX_STEPS,
@ -22,6 +24,7 @@ import {
ERROR_CORPUS,
HANDLED_CORPUS,
RUNTIME_BAILOUT_CORPUS,
TIME_CORPUS,
} from './native-evaluation-corpus';
// The expression corpora live in native-evaluation-corpus.ts.
@ -205,6 +208,54 @@ describe('Expression - fast native evaluation parity', () => {
});
});
// Each side reads its own clock, so the values are compared as instants
// within a minute of each other; everything else must match exactly.
describe('time root expressions match the engine result', () => {
const asInstant = (value: unknown): number | null => {
if (typeof value === 'number') return value;
if (typeof value !== 'string') return null;
const instant = DateTime.fromISO(value.replace(/^since /, ''));
return instant.isValid ? instant.toMillis() : null;
};
test.each(TIME_CORPUS)('%s', (expr) => {
expect(isNativelyEvaluable(expr.slice(1))).toBe(true);
expect(nativeOutcome(expr).handled).toBe(true);
const engineResult = evaluate(expr, false);
const nativeResult = evaluate(expr, true);
expect(typeof nativeResult).toBe(typeof engineResult);
const engineInstant = asInstant(engineResult);
const nativeInstant = asInstant(nativeResult);
if (engineInstant === null || nativeInstant === null) {
expect(nativeResult).toStrictEqual(engineResult);
return;
}
// Milliseconds, seconds (toSeconds, toUnixInteger), or a calendar
// getter, which must match exactly.
const tolerance = engineInstant > 1e11 ? 60_000 : engineInstant > 1e8 ? 60 : 0;
expect(Math.abs(nativeInstant - engineInstant)).toBeLessThanOrEqual(tolerance);
});
test('a time root that is not a Luxon DateTime hands off to the engine', () => {
const dataWith = (value: unknown) => ({ $now: value }) as unknown as IWorkflowDataProxyData;
expect(evaluateNatively('{{ $now.toMillis() }}', dataWith(new Date()))).toEqual({
handled: false,
});
expect(evaluateNatively('{{ $now.year }}', dataWith({ year: 2026 }))).toEqual({
handled: false,
});
expect(evaluateNatively('{{ $now.year }}', dataWith(DateTime.now()))).toEqual({
handled: true,
value: DateTime.now().year,
});
});
});
describe('non-simple expressions are declined', () => {
test.each(DECLINED_CORPUS)('%s', (expr) => {
expect(isNativelyEvaluable(expr.slice(1))).toBe(false);
@ -598,7 +649,7 @@ describe('Expression - fast native evaluation parity', () => {
const extensionNames = new Set(
ExpressionExtensions.flatMap((extension) => Object.keys(extension.functions)),
);
for (const method of [...CALLABLE_METHODS, ...ITERATOR_METHODS]) {
for (const method of [...CALLABLE_METHODS, ...ITERATOR_METHODS, ...TIME_METHODS.keys()]) {
expect(extensionNames.has(method)).toBe(false);
}
});