92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
// A JS Date bound to a MySQL DATETIME and read back must be the same instant
|
|||
|
|
// regardless of the process timezone. Encode breaks the Date's epoch-ms into
|
||
|
|
// Y/M/D h:m:s via pure-UTC arithmetic, so decode has to treat those components
|
||
|
|
// as UTC too — if it interprets them as local time, the round-trip silently
|
||
|
|
// shifts by the machine's UTC offset. This must hold on BOTH the prepared
|
||
|
|
// (binary) and simple (text) protocols, and MySQL zero-dates must surface as
|
||
|
|
// Invalid Date on both.
|
||
|
|
//
|
||
|
|
// The driving test spawns this fixture under several TZ values against a real
|
||
|
|
// MySQL server and asserts the identity holds for each.
|
||
|
|
|
||
|
|
import { SQL, randomUUIDv7 } from "bun";
|
||
|
|
|
||
|
|
const tls = process.env.CA_PATH ? { ca: Bun.file(process.env.CA_PATH) } : undefined;
|
||
|
|
await using sql = new SQL({
|
||
|
|
url: process.env.MYSQL_URL,
|
||
|
|
tls,
|
||
|
|
max: 1,
|
||
|
|
allowPublicKeyRetrieval: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
const t = "dt_tz_" + randomUUIDv7("hex").replaceAll("-", "");
|
||
|
|
await sql`CREATE TEMPORARY TABLE ${sql(t)} (id INT PRIMARY KEY, dt DATETIME)`;
|
||
|
|
// Signal a live connection so the driving test can tell "no MySQL here"
|
||
|
|
// (soft-skip in local/sandboxed runs) apart from an actual decode failure.
|
||
|
|
console.log("CONNECTED");
|
||
|
|
|
||
|
|
const inputs = [
|
||
|
|
new Date("2024-06-15T12:00:00.000Z"), // summer (DST active in zones that observe it)
|
||
|
|
new Date("2024-01-15T00:30:00.000Z"), // winter, near midnight UTC — local-time misread crosses the day boundary
|
||
|
|
new Date("2024-12-31T23:45:00.000Z"), // year boundary
|
||
|
|
];
|
||
|
|
|
||
|
|
for (let i = 0; i < inputs.length; i++) {
|
||
|
|
await sql`INSERT INTO ${sql(t)} (id, dt) VALUES (${i}, ${inputs[i]})`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const failures: string[] = [];
|
||
|
|
|
||
|
|
function checkRoundTrip(protocol: string, rows: Array<{ dt: Date }>) {
|
||
|
|
for (let i = 0; i < inputs.length; i++) {
|
||
|
|
const got: Date = rows[i].dt;
|
||
|
|
if (!(got instanceof Date)) {
|
||
|
|
failures.push(`${protocol} id=${i}: expected Date, got ${Object.prototype.toString.call(got)}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const want = inputs[i].getTime();
|
||
|
|
const have = got.getTime();
|
||
|
|
if (want !== have) {
|
||
|
|
const diffMin = (have - want) / 60000;
|
||
|
|
failures.push(`${protocol} id=${i}: in=${inputs[i].toISOString()} out=${got.toISOString()} diffMin=${diffMin}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Prepared (binary) and simple (text) protocols must agree — both decode the
|
||
|
|
// naive wall-clock as UTC.
|
||
|
|
checkRoundTrip("binary", await sql`SELECT id, dt FROM ${sql(t)} ORDER BY id`);
|
||
|
|
checkRoundTrip("text", await sql`SELECT id, dt FROM ${sql(t)} ORDER BY id`.simple());
|
||
|
|
|
||
|
|
// MySQL's permissive sql_mode stores "0000-00-00 00:00:00"; it must read back
|
||
|
|
// as Invalid Date (not the Unix epoch / a wrapped date) on both protocols.
|
||
|
|
// ALLOW_INVALID_DATES additionally lets MySQL store a non-zero day past its
|
||
|
|
// month length ("2024-02-31") verbatim; that must also read back as Invalid
|
||
|
|
// Date instead of being normalized to March 2 by the decoder.
|
||
|
|
const zt = "dt_zero_" + randomUUIDv7("hex").replaceAll("-", "");
|
||
|
|
await sql`SET SESSION sql_mode='ALLOW_INVALID_DATES'`.simple();
|
||
|
|
await sql`CREATE TEMPORARY TABLE ${sql(zt)} (id INT PRIMARY KEY, dt DATETIME)`.simple();
|
||
|
|
await sql.unsafe(`INSERT INTO ${zt} (id, dt) VALUES (1, '0000-00-00 00:00:00'), (2, '2024-02-31 00:00:00')`);
|
||
|
|
for (const [protocol, rows] of [
|
||
|
|
["binary", await sql`SELECT id, dt FROM ${sql(zt)} ORDER BY id`],
|
||
|
|
["text", await sql`SELECT id, dt FROM ${sql(zt)} ORDER BY id`.simple()],
|
||
|
|
] as const) {
|
||
|
|
for (const [id, label] of [
|
||
|
|
[1, "zero-date"],
|
||
|
|
[2, "impossible-date"],
|
||
|
|
] as const) {
|
||
|
|
const got: Date = rows[id - 1].dt;
|
||
|
|
if (!(got instanceof Date) || !Number.isNaN(got.getTime())) {
|
||
|
|
failures.push(`${protocol} ${label}: expected Invalid Date, got ${String(got)}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (failures.length) {
|
||
|
|
console.error(`FAIL TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`);
|
||
|
|
for (const f of failures) console.error(" " + f);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`OK TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`);
|