From de090cb2a0aad475ea4e5bf10c4af3b1a32ed3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Repe=C4=87?= Date: Tue, 13 Oct 2026 13:31:32 +0000 Subject: [PATCH] feat(core): Evaluate primitive reads off $now and $today natively (no-changelog) (#40945) --- .../native-evaluation/evaluator.ts | 29 ++++++++ .../expressions/native-evaluation/grammar.ts | 73 ++++++++++++++++++- .../expressions/native-evaluation/index.ts | 2 + .../expressions/native-evaluation/parser.ts | 30 ++++++++ .../native-evaluation-corpus-coverage.test.ts | 12 +++ .../workflow/test/native-evaluation-corpus.ts | 53 +++++++++++++- .../test/native-evaluation-parity.test.ts | 53 +++++++++++++- 7 files changed, 246 insertions(+), 6 deletions(-) diff --git a/packages/workflow/src/expressions/native-evaluation/evaluator.ts b/packages/workflow/src/expressions/native-evaluation/evaluator.ts index 9198d764870..3d9bd4b320d 100644 --- a/packages/workflow/src/expressions/native-evaluation/evaluator.ts +++ b/packages/workflow/src/expressions/native-evaluation/evaluator.ts @@ -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 unknown> = Object.freeze({ /* eslint-enable @typescript-eslint/no-explicit-any */ function evalMember(node: Extract, 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, 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, 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': diff --git a/packages/workflow/src/expressions/native-evaluation/grammar.ts b/packages/workflow/src/expressions/native-evaluation/grammar.ts index 171ae4add8b..882628b66a6 100644 --- a/packages/workflow/src/expressions/native-evaluation/grammar.ts +++ b/packages/workflow/src/expressions/native-evaluation/grammar.ts @@ -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; -// 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 => { + const getters = new Map(); + 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 => { const methods = new Map(); 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([...DATA_ROOTS, '$input', '$node', '$', 'undefined']); +export const RESERVED_NAMES = new Set([ + ...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(), diff --git a/packages/workflow/src/expressions/native-evaluation/index.ts b/packages/workflow/src/expressions/native-evaluation/index.ts index 8ce0cd9a79c..54384d93518 100644 --- a/packages/workflow/src/expressions/native-evaluation/index.ts +++ b/packages/workflow/src/expressions/native-evaluation/index.ts @@ -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 diff --git a/packages/workflow/src/expressions/native-evaluation/parser.ts b/packages/workflow/src/expressions/native-evaluation/parser.ts index 5ea0946d37e..87439240b54 100644 --- a/packages/workflow/src/expressions/native-evaluation/parser.ts +++ b/packages/workflow/src/expressions/native-evaluation/parser.ts @@ -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; + +// `$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; diff --git a/packages/workflow/test/native-evaluation-corpus-coverage.test.ts b/packages/workflow/test/native-evaluation-corpus-coverage.test.ts index 9c676378e7d..d17530ddbab 100644 --- a/packages/workflow/test/native-evaluation-corpus-coverage.test.ts +++ b/packages/workflow/test/native-evaluation-corpus-coverage.test.ts @@ -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 }}', diff --git a/packages/workflow/test/native-evaluation-corpus.ts b/packages/workflow/test/native-evaluation-corpus.ts index 2fa04597a5f..84a80583248 100644 --- a/packages/workflow/test/native-evaluation-corpus.ts +++ b/packages/workflow/test/native-evaluation-corpus.ts @@ -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, diff --git a/packages/workflow/test/native-evaluation-parity.test.ts b/packages/workflow/test/native-evaluation-parity.test.ts index a2d5721d8f7..fe66bcc7599 100644 --- a/packages/workflow/test/native-evaluation-parity.test.ts +++ b/packages/workflow/test/native-evaluation-parity.test.ts @@ -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); } });