fix(email): read naive SQLite timestamps as UTC, and page the candidates
Codex review round 2 on #1273. Both findings restore the false all-clear that round 1 set out to remove, by different routes. Both timestamp columns default to CURRENT_TIMESTAMP, which SQLite renders as a zone-less 'YYYY-MM-DD HH:MM:SS' in UTC -- and Date.parse reads that shape as LOCAL time. On a TZ=America/New_York deployment a row due now looked four hours away and never reached the waiting list; nine hours the other way, fresh mail read as long overdue. The parser now stamps the zone the value actually carries. That parser moved to utils/queueTimestamps so it can be tested honestly. This suite runs in UTC, where reading a zone-less value as local and as UTC give the same answer, and process.env.TZ does not reliably re-bind mid-process -- my first attempt at these tests passed against the broken code for exactly that reason. They now force TZ in a child process, so they fail on any host. The candidate rows are paged rather than cut off with one LIMIT. The time filter runs in JS, so a queue holding more than a page of future-scheduled rows -- split-payment invoices are exactly that shape -- filled the window with rows that all got filtered out and hid the due row behind them, reporting nothing waiting. Paging also drops the dependency on ORDER BY created_at meaning anything, which it does not on SQLite once numeric and text timestamps mix. Bounded at 10k scanned; past that the response is a sample, which the 200-row cap already made it. 12 more tests. The paging one fails before this commit, and all four naive-timestamp ones fail against the old parsing on any host.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Reading email_queue timestamps back, whatever shape the engine stored them in.
|
||||
*
|
||||
* The columns are written three different ways and read back three different
|
||||
* ways, and a comparison that assumes one of them is wrong for the other two:
|
||||
*
|
||||
* - Postgres hands back a Date.
|
||||
* - SQLite stores what queueEmail writes -- a JS Date, which the native
|
||||
* binding turns into epoch ms -- and hands back that number.
|
||||
* - Both columns also default to CURRENT_TIMESTAMP, which on SQLite is a
|
||||
* zone-less 'YYYY-MM-DD HH:MM:SS' string in UTC, and older rows plus test
|
||||
* fixtures carry ISO strings.
|
||||
*
|
||||
* Extracted from adminSystemHealth so the parsing can be tested under a forced
|
||||
* TZ in a child process, which is the only way to pin the naive-timestamp case
|
||||
* from a test suite that itself runs in UTC (#1262).
|
||||
*/
|
||||
|
||||
/** 'YYYY-MM-DD HH:MM:SS[.sss]' with no zone — SQLite's CURRENT_TIMESTAMP shape. */
|
||||
const SQLITE_NAIVE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/;
|
||||
|
||||
/**
|
||||
* @param {Date|number|string|null|undefined} value
|
||||
* @returns {number|null} epoch ms, or null when the value cannot be read
|
||||
*/
|
||||
function toMillis(value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Date) return value.getTime();
|
||||
if (typeof value === 'number') return value;
|
||||
const text = String(value).trim();
|
||||
if (text === '') return null;
|
||||
// A numeric string is epoch ms; anything else goes through Date.parse.
|
||||
const numeric = Number(text);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
// Date.parse reads the zone-less shape as LOCAL time. On a
|
||||
// TZ=America/New_York deployment that puts a row due now four hours in the
|
||||
// future, so it never reaches the waiting list -- the false all-clear again,
|
||||
// arrived at via the clock. Stamp the zone the value actually carries.
|
||||
const stamped = SQLITE_NAIVE_TIMESTAMP.test(text) ? `${text.replace(' ', 'T')}Z` : text;
|
||||
const parsed = Date.parse(stamped);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
module.exports = { toMillis, SQLITE_NAIVE_TIMESTAMP };
|
||||
Reference in New Issue
Block a user