Skip to content

Deterministic Date override in the workflow VM breaks Date subclasses (e.g. TZDate from @date-fns/tz) #3371

Description

@ar-tama

Summary

createContext() replaces the VM's global Date with a plain function
(packages/core/src/vm/index.ts). A plain function has no [[Construct]]
behavior that forwards new.target, so when user code does
class X extends Date, super() returns a new plain Date object which
then becomes this. The subclass instance loses its identity and all of its
own methods.

This silently breaks every library that models a date by subclassing Date.
The one that bit us is TZDate from
@date-fns/tz, which is the officially recommended way to do time-zone-aware
math with date-fns v4. Inside a workflow body, a TZDate degrades to a plain
Date, so every zone-aware getter (getFullYear, getMonth, getDate, …)
falls back to the container's time zone instead of the requested one.

The failure is silent: the underlying epoch value is still correct, only the
zone-aware accessors are wrong. Code that computes period boundaries or
formats a date key gets an off-by-one day whenever the container time zone
differs from the requested one — which, on a typical UTC container with a
non-UTC business time zone, is most of the day.

The docs currently say Date is safe to use in workflow functions
(docs/content/docs/v4/api-reference/workflow-globals.mdx) with no mention of
this caveat.

Reproduction

import { createContext, runInContext } from 'node:vm'

// Mirrors packages/core/src/vm/index.ts
const context = createContext()
const g = runInContext('globalThis', context)
const Date_ = g.Date
const fixedTimestamp = Date_.parse('2026-08-06T03:38:14.993Z')

g.Date = function Date(...args) {
  if (args.length === 0) return new Date_(fixedTimestamp)
  return new Date_(...args)
}
g.Date.prototype = Date_.prototype
Object.setPrototypeOf(g.Date, Date_)
g.Date.now = () => fixedTimestamp

console.log(runInContext(`
  class Sub extends Date {
    constructor(...args) { super(...args); this.tag = 'sub' }
    label() { return 'sub' }
  }
  const sub = new Sub(2026, 6, 29)
  JSON.stringify({
    subIsSub: sub instanceof Sub,
    subKeepsMethods: typeof sub.label === 'function',
    subKeepsFields: sub.tag === 'sub',
  })
`, context))

Actual

{"subIsSub":false,"subKeepsMethods":false,"subKeepsFields":false}

Expected

{"subIsSub":true,"subKeepsMethods":true,"subKeepsFields":true}

Real-world impact

With TZDate, inside a workflow body and a container running TZ=UTC:

// organization time zone is Asia/Tokyo
const d = new TZDate(2026, 6, 29, 'Asia/Tokyo')  // 2026-07-29 00:00 JST
d.getDate()  // => 28  (should be 29; read in UTC, not Asia/Tokyo)

Any validation of the form getFullYear()/getMonth()/getDate() round-tripping
a yyyy-mm-dd key therefore rejects perfectly valid dates, and any period
boundary (start of week/month/quarter) lands on the wrong day.

This does not reproduce on a developer machine whose system time zone happens
to match the business time zone, so it presents as a production-only bug.

Proposed fix

Use a class so new.target is preserved:

g.Date = class Date extends Date_ {
  constructor(...args) {
    if (args.length === 0) super(fixedTimestamp)
    else super(...args)
  }
}
g.Date.now = () => fixedTimestamp

Verified in a real node:vm context — determinism is unchanged and subclassing
is fixed:

current (function): {"nowIsFixed":true,"subIsSub":false,"subKeepsMethods":false}
proposed (class)  : {"nowIsFixed":true,"subIsSub":true, "subKeepsMethods":true}

Both of the current fix-ups become unnecessary with the class form, because
extends already sets up the whole prototype chain:

  • g.Date.prototype = Date_.prototype — currently needed to make instanceof
    work at all; with extends the real chain is in place, and the assignment is
    illegal anyway (prototype is non-writable on a class).
  • Object.setPrototypeOf(g.Date, Date_) — currently needed to preserve the
    statics; extends already makes Object.getPrototypeOf(g.Date) === Date_,
    so Date.parse / Date.UTC are inherited. Only the Date.now override
    stays as an own property.

Happy to open a PR if the approach looks right.

Environment

  • workflow 4.6.2 / @workflow/core 4.6.2 (also reproduced against the
    current mainpackages/core/src/vm/index.ts is unchanged)
  • Node.js 24
  • Next.js 16 App Router

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions