Repro (MySQL-free, no Fastify, 12 lines)
Drop into /tmp/repro.ts, perry compile repro.ts -o repro && ./repro:
function show(label: string, d: Date) {
console.log(label, "getTime=", d.getTime(), " iso=", d.toISOString());
}
function makeDT(y: number) {
return { toDate(): Date {
return new Date(Date.UTC(y, 4, 15, 17, 29, 35, 402)); // captured `y` reads as 0 inside this method
} };
}
const d = makeDT(2026).toDate();
console.log("INLINE:", d.getTime());
show("HELPER:", d);
Observed (perry 0.5.914)
INLINE: -62155492224598
HELPER: getTime= -62155492224598 iso= 0000-05-15T17:29:36.598Z
Expected (tsx / node — same source)
INLINE: 1778866175402
HELPER: getTime= 1778866175402 iso= 2026-05-15T17:29:35.402Z
The reconstructed milliseconds -62155492224598 is exactly Date.UTC(0, 4, 15, 17, 29, 35, 402) — i.e. the captured y is read as 0 inside toDate() regardless of the value passed to makeDT(...). All other Date.UTC args are literals and arrive intact; only the closure-captured numeric y is corrupt.
Adding a NaN-guard branch (if (y===0) return new Date(NaN);) confirms it from the other side — makeDT(2026).toDate() returns Invalid Date because the guard sees y === 0.
Where this surfaces in real code
node_modules/@perryts/mysql/src/types/datetime.ts::makeMyDateTime is structurally identical:
function makeMyDateTime(y, mo, d, h, mi, sec, micros, raw) {
return { year:y, ..., toDate(): Date {
if (y===0 && mo===0 && d===0) return new Date(NaN);
return new Date(Date.UTC(y, mo-1, d, h, mi, sec, Math.round(micros/1000)));
}};
}
So every DATETIME column decoded by @perryts/mysql produces a MyDateTime whose .toDate() returns either Invalid Date (the all-zero guard wins because every captured field reads as 0) or a year-0 garbage Date. Consumers like skelpo-shop-admin's toUser(row) then either crash in downstream formatters or silently store wrong timestamps. This is the proximate cause of the new SIGBUS in the shop-admin signup path at v0.5.914 (filed separately).
Notes from bisecting
- The bug only fires in certain file contexts — a tighter single-function file (
function makeDT(...); console.log(makeDT(2026).toDate().getTime()) with no other declarations) compiles correctly. Adding a second function that declares a : Date parameter type, or a few other shapes, makes it manifest reliably. So it's a type-analysis interaction across the module, not a property of the makeDT definition alone.
- Object-literal method with
: Date return annotation + closure capture of a numeric outer param is the consistent shape.
- The captured non-numeric references (
raw: string in the real makeMyDateTime) appear unaffected — only numeric captures zero out.
- Counterpart shapes that DO work:
toDate: (): Date => new Date(Date.UTC(y, ...)) (arrow-property), toDate: function(): Date { ... } (fn-prop), bare function f(y): Date { ... } (standalone fn). The bug is specific to the shorthand-method-in-object-literal-with-: Date-return-and-closure-captured-numeric-params combination.
Context
Surfaced while verifying the v0.5.912 / v0.5.914 #748 fixes against the shop-admin native server. The wait_for_promise condvar fix and the Invalid-Date sentinel both landed correctly — confirmed via the previous minimal repros. This is a separate downstream bug in the same : Date / closure-capture region that's exposed once the Invalid-Date path is correct.
Reproduces deterministically at perry 0.5.914 (HEAD e18cdb07). Happy to add PERRY_DEBUG_* traces if useful — the dynamic-dispatch path for Date.UTC(...) inside the method's body is where I'd start looking (whether captured-arg slots get the f64 they should at the call site).
Repro (MySQL-free, no Fastify, 12 lines)
Drop into
/tmp/repro.ts,perry compile repro.ts -o repro && ./repro:Observed (perry 0.5.914)
Expected (tsx / node — same source)
The reconstructed milliseconds
-62155492224598is exactlyDate.UTC(0, 4, 15, 17, 29, 35, 402)— i.e. the capturedyis read as0insidetoDate()regardless of the value passed tomakeDT(...). All otherDate.UTCargs are literals and arrive intact; only the closure-captured numericyis corrupt.Adding a NaN-guard branch (
if (y===0) return new Date(NaN);) confirms it from the other side —makeDT(2026).toDate()returnsInvalid Datebecause the guard seesy === 0.Where this surfaces in real code
node_modules/@perryts/mysql/src/types/datetime.ts::makeMyDateTimeis structurally identical:So every
DATETIMEcolumn decoded by@perryts/mysqlproduces aMyDateTimewhose.toDate()returns either Invalid Date (the all-zero guard wins because every captured field reads as 0) or a year-0 garbage Date. Consumers like skelpo-shop-admin'stoUser(row)then either crash in downstream formatters or silently store wrong timestamps. This is the proximate cause of the new SIGBUS in the shop-admin signup path at v0.5.914 (filed separately).Notes from bisecting
function makeDT(...); console.log(makeDT(2026).toDate().getTime())with no other declarations) compiles correctly. Adding a second function that declares a: Dateparameter type, or a few other shapes, makes it manifest reliably. So it's a type-analysis interaction across the module, not a property of themakeDTdefinition alone.: Datereturn annotation + closure capture of a numeric outer param is the consistent shape.raw: stringin the realmakeMyDateTime) appear unaffected — only numeric captures zero out.toDate: (): Date => new Date(Date.UTC(y, ...))(arrow-property),toDate: function(): Date { ... }(fn-prop), barefunction f(y): Date { ... }(standalone fn). The bug is specific to the shorthand-method-in-object-literal-with-: Date-return-and-closure-captured-numeric-params combination.Context
Surfaced while verifying the v0.5.912 / v0.5.914 #748 fixes against the shop-admin native server. The
wait_for_promisecondvar fix and the Invalid-Date sentinel both landed correctly — confirmed via the previous minimal repros. This is a separate downstream bug in the same: Date/ closure-capture region that's exposed once the Invalid-Date path is correct.Reproduces deterministically at perry 0.5.914 (HEAD
e18cdb07). Happy to addPERRY_DEBUG_*traces if useful — the dynamic-dispatch path forDate.UTC(...)inside the method's body is where I'd start looking (whether captured-arg slots get the f64 they should at the call site).