Date parse and UTC return an object not milliseconds

Bug Resolved
Fixed in production on 29 September 2026: Date.parse and Date.UTC now return a number of milliseconds, and Date.parse reads ISO 8601 strings with Z, an offset or milliseconds. Before that, both returned a date object and Date.parse returned null for everything toISOString() produces - so code written before the fix may contain workarounds that test for null or call date methods on the result. Those no longer work. Check old cleanup and expiry code.
Applies to: JavaScriptAPIHash templates

What you'll see

A date comparison never fires, or fires every time. A cleanup job never deletes anything, a cache is never considered fresh, a link never expires — and nothing throws. The code reads like ordinary JavaScript:

let t = Date.parse(record.created);          // record.created = "2026-08-11T08:52:59.146Z"
if (t && (Date.now() - t) > 7 * DAY) docly.deleteFile(path);   // never runs

let age = Date.now() - Date.parse(cached.updated);                // not a number of ms
if (Date.parse(expires) > Date.now()) { /* ... */ }              // compares an object with a number

Measured in an API endpoint:

CallECMAScriptHashJS before 29 September 2026
Date.parse("2026-11-22T13:44:17")number (ms)date object, local time
Date.parse("2026-11-22T13:44:17Z")1795355057000null
Date.parse("2026-11-22T13:44:17.146Z")number (ms)null
Date.UTC(2026, 10, 22, 13, 44, 17)1795355057000date object
new Date("2026-11-22T13:44:17Z").getTime()17953550570001795355057000 — correct

What's actually happening

Date.parse is the template function cdate under another name. It was registered as an alias for cdate, which returns a date object, and which only accepts the two exact patterns yyyy-MM-dd and yyyy-MM-ddTHH:mm:ss. Anything with a zone suffix (Z, +02:00) or with milliseconds does not match, and comes back as null. That rules out exactly the strings JavaScript itself produces: new Date().toISOString() always ends in .sssZ.

Date.UTC builds the right UTC date, but returns the date object rather than its millisecond value.

The failure is silent because both wrong values are falsy or plausible. null makes a guard like t && … skip quietly — so a job that deletes expired records simply never deletes. A date object in arithmetic produces a value no comparison against a millisecond count can meaningfully satisfy. Nothing throws, so nothing is logged.

Since the fix (in production since 29 September 2026):

  • Date.parse returns milliseconds as a number, and NaN for input it cannot parse or no argument.
  • Strings with Z, an offset or milliseconds are parsed, so Date.parse("2026-11-22T13:44:17Z") === 1795355057000.
  • Strings without a zone are read as local server time, as before. That includes a date alone ("2026-11-22") — which ECMAScript reads as UTC. That difference remains.
  • Date.UTC returns milliseconds; Date.UTC() is NaN, and Date.UTC(2020) is 1 January 2020.
  • cdate is unchanged and still returns a date object.

Standard code such as the examples above starts working after the fix without changes. Code that relies on the defect — calling .getTime() on the result of Date.parse, or checking it for null — breaks.

What to do

1. new Date(value).getTime() remains a safe way to convert. It handled Z, offsets and milliseconds correctly before the fix, and still does:

let t = new Date(record.created).getTime();   // 1786438379146, not null

2. Guard the input first. new Date("") and new Date("garbage") throw in HashJS, while new Date(undefined) and new Date(null) both return the current time rather than an invalid date — so a missing field silently looks like "just now". Wrap it once and use the wrapper:

function toMs(value) {
    if (value === null || value === undefined || value === "") return NaN;
    try { return new Date(value).getTime(); } catch (e) { return NaN; }
}

let t = toMs(record.created);
if (!isNaN(t) && (Date.now() - t) > 7 * DAY) docly.deleteFile(path);

3. Do not build on the defect. Never call a date method on the result of Date.parse or Date.UTC, and never test either for null. Code like that stopped working when the fix was deployed. Treat the result as a number and test with isNaN.

4. Store the number next to the text. Saving expiresMs: expMs alongside expires: new Date(expMs).toISOString() means comparisons never need to parse at all. It is the sturdiest option, before and after the fix.

5. Say which zone a date-only string is in. If "2026-11-22" means midnight UTC, write "2026-11-22T00:00:00Z". HashJS reads a bare date as local time.

6. Check cleanup and expiry code you already have. A job guarded by t && … has likely never acted on ISO strings with Z. When the fix is deployed it will catch up on the whole backlog in its first run — count what it will delete before that happens.

7. Verify on the instance you run on. One throwaway page shows whether typeof Date.parse("2020-01-01T00:00:00Z") is "number" yet — see Use a scratch hash file to test Docly functions.