|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The DRIVER axis of the shared conformance matrices (ADR-0053 D-A3: the matrix |
| 5 | + * is `driver {SQLite, Postgres at minimum}` × …), plus the SERVER-TIMEZONE axis |
| 6 | + * D-B3 added to it — one definition of "which backends does a matrix consumer |
| 7 | + * run on, and what makes that run non-vacuous", so every consumer spells it the |
| 8 | + * same way instead of hard-coding `client: 'better-sqlite3'` (#4245). |
| 9 | + * |
| 10 | + * Why this exists as a helper rather than a literal per suite: a hard-coded |
| 11 | + * client is invisible. `sql-driver-temporal-conformance.test.ts` carried four of |
| 12 | + * them while its own head note claimed it ran "against real Postgres and MySQL |
| 13 | + * too" — declared ≠ enforced, and the ADR's `Postgres at minimum` never |
| 14 | + * executed for the matrix at all. A cell list you have to *opt out of* fails |
| 15 | + * loudly the moment a new sweep forgets a dialect. |
| 16 | + * |
| 17 | + * ## The non-vacuity contract |
| 18 | + * |
| 19 | + * A live cell proves nothing unless the three clocks actually disagree, which is |
| 20 | + * exactly the configuration D-B2 measured the dialect divergence under: |
| 21 | + * |
| 22 | + * - the SERVER's timezone (CI: PG `Asia/Shanghai`, MySQL `+08:00`), |
| 23 | + * - the PROCESS's timezone (CI: `TZ=America/New_York`), |
| 24 | + * - UTC, which is what the canon says every stored instant and every comparand |
| 25 | + * denotes. |
| 26 | + * |
| 27 | + * {@link assertThreeWayZoneSkew} asserts all three are pairwise different. On a |
| 28 | + * UTC server, or a UTC process, the identical answers a green matrix reports are |
| 29 | + * answers no timezone could have perturbed — a pass that means nothing. The |
| 30 | + * guard turns that into a red with the fix in the message. |
| 31 | + * |
| 32 | + * ## Skips are visible, and can be made fatal |
| 33 | + * |
| 34 | + * Without `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` a live cell is reported |
| 35 | + * as a named SKIP (never a silent pass). A runner that *knows* it provisioned |
| 36 | + * the servers — the `Temporal Conformance (live PG + MySQL)` CI job — sets |
| 37 | + * `OS_EXPECT_LIVE_DIALECT_MATRIX=1`, which turns a missing URL into a failure: |
| 38 | + * without it, dropping the `env:` block from that job would silently return the |
| 39 | + * whole matrix to SQLite-only coverage and stay green, which is the same |
| 40 | + * vacuous-pass hole the job's own process-zone assertion closes. |
| 41 | + * |
| 42 | + * Test-only: not exported from `index.ts`. |
| 43 | + */ |
| 44 | + |
| 45 | +import { expect } from 'vitest'; |
| 46 | +import type { SqlDriver, SqlDriverConfig } from './sql-driver.js'; |
| 47 | + |
| 48 | +/** The dialects `driver-sql` speaks that the matrices are run across. */ |
| 49 | +export type DialectId = 'sqlite' | 'pg' | 'mysql'; |
| 50 | + |
| 51 | +export interface DialectCell { |
| 52 | + id: DialectId; |
| 53 | + /** Human label, used in suite names. */ |
| 54 | + label: string; |
| 55 | + /** The env var that provisions this cell — `null` for the embedded SQLite one. */ |
| 56 | + env: string | null; |
| 57 | + /** Provisioned connection string, when this cell needs one. */ |
| 58 | + url?: string; |
| 59 | + /** Can this cell run right now? (SQLite always can.) */ |
| 60 | + available: boolean; |
| 61 | + /** Does the cell talk to a separate server that carries its own timezone? */ |
| 62 | + live: boolean; |
| 63 | + /** |
| 64 | + * Can rows in a PRE-canonical storage form still exist on this dialect — i.e. |
| 65 | + * does the driver keep a read-side repair for them? |
| 66 | + * |
| 67 | + * SQLite only, and not by convention: SQLite has no temporal type, so a |
| 68 | + * pre-#3912/#3994 database really does hold INTEGER epoch ms next to |
| 69 | + * zone-naive TEXT in one column, and `needsLegacyDatetimeRepair` / |
| 70 | + * `needsLegacyTimeRepair` gate the repair on `isSqlite`. Postgres and MySQL |
| 71 | + * store a real `timestamptz` / `DATETIME(3)` / `TIME(3)`, so their rows are |
| 72 | + * already one shape and there is nothing on disk to repair — see |
| 73 | + * `backfillCanonicalDatetimes`, which says exactly this and returns early. |
| 74 | + * |
| 75 | + * This flag is what a legacy sweep selects cells by, and consumers must ASSERT |
| 76 | + * the driver agrees with it (see `LegacyStorageDriver.legacyDatetimeRepairApplies`) |
| 77 | + * rather than trusting the constant — otherwise it decays into the same |
| 78 | + * unverified claim the hard-coded client was. |
| 79 | + */ |
| 80 | + hasLegacyStorageForm: boolean; |
| 81 | + /** Fresh driver config for this cell. */ |
| 82 | + config(): SqlDriverConfig; |
| 83 | +} |
| 84 | + |
| 85 | +const PG_URL = process.env.OS_TEST_POSTGRES_URL; |
| 86 | +const MYSQL_URL = process.env.OS_TEST_MYSQL_URL; |
| 87 | + |
| 88 | +/** |
| 89 | + * `1` when the runner has provisioned the live servers and a missing URL is |
| 90 | + * therefore a defect in the runner, not a developer running without Docker. |
| 91 | + */ |
| 92 | +export const EXPECT_LIVE_DIALECTS = process.env.OS_EXPECT_LIVE_DIALECT_MATRIX === '1'; |
| 93 | + |
| 94 | +/** |
| 95 | + * Every cell of the driver axis, available or not — a consumer iterates the |
| 96 | + * whole list so an unprovisioned dialect is *reported*, not omitted. |
| 97 | + */ |
| 98 | +export const DIALECT_CELLS: readonly DialectCell[] = [ |
| 99 | + { |
| 100 | + id: 'sqlite', |
| 101 | + label: 'sqlite', |
| 102 | + env: null, |
| 103 | + available: true, |
| 104 | + live: false, |
| 105 | + hasLegacyStorageForm: true, |
| 106 | + config: () => ({ |
| 107 | + client: 'better-sqlite3', |
| 108 | + connection: { filename: ':memory:' }, |
| 109 | + useNullAsDefault: true, |
| 110 | + }), |
| 111 | + }, |
| 112 | + { |
| 113 | + id: 'pg', |
| 114 | + label: 'live postgres', |
| 115 | + env: 'OS_TEST_POSTGRES_URL', |
| 116 | + url: PG_URL, |
| 117 | + available: !!PG_URL, |
| 118 | + live: true, |
| 119 | + hasLegacyStorageForm: false, |
| 120 | + config: () => ({ client: 'pg', connection: PG_URL }), |
| 121 | + }, |
| 122 | + { |
| 123 | + id: 'mysql', |
| 124 | + label: 'live mysql', |
| 125 | + env: 'OS_TEST_MYSQL_URL', |
| 126 | + url: MYSQL_URL, |
| 127 | + available: !!MYSQL_URL, |
| 128 | + live: true, |
| 129 | + hasLegacyStorageForm: false, |
| 130 | + config: () => ({ client: 'mysql2', connection: MYSQL_URL }), |
| 131 | + }, |
| 132 | +] as const; |
| 133 | + |
| 134 | +/** The live cells only — the ones the server-timezone axis applies to. */ |
| 135 | +export const LIVE_DIALECT_CELLS = DIALECT_CELLS.filter((c) => c.live); |
| 136 | + |
| 137 | +/** What a server reports about its own timezone. */ |
| 138 | +export interface ServerZone { |
| 139 | + /** The dialect's own spelling: `Asia/Shanghai`, `+08:00`, `SYSTEM`, … */ |
| 140 | + setting: string; |
| 141 | + /** |
| 142 | + * Minutes east of UTC the server is currently at, or `NaN` when the dialect |
| 143 | + * could not be made to say. `NaN` fails the skew guard on purpose: a zone we |
| 144 | + * cannot compare is a zone we cannot prove is skewed. |
| 145 | + */ |
| 146 | + offsetMinutes: number; |
| 147 | +} |
| 148 | + |
| 149 | +/** Unwrap a raw result across knex's three dialect shapes. */ |
| 150 | +function rowsOf(res: any): any[] { |
| 151 | + if (Array.isArray(res) && Array.isArray(res[0])) return res[0]; // mysql2: [rows, fields] |
| 152 | + if (Array.isArray(res)) return res; // better-sqlite3 |
| 153 | + return res?.rows ?? []; // pg |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * Read the SERVER's timezone through an already-connected driver. |
| 158 | + * |
| 159 | + * Both queries deliberately read the server's own setting rather than anything |
| 160 | + * the driver configured: `driver-sql` pins the mysql2 *session* to UTC (#3942) |
| 161 | + * and Postgres reads back whatever `TimeZone` the server was started with, so |
| 162 | + * asking the session would report the fix instead of the hazard the fix exists |
| 163 | + * for. |
| 164 | + */ |
| 165 | +export async function readServerZone(cell: DialectCell, driver: SqlDriver): Promise<ServerZone> { |
| 166 | + if (cell.id === 'pg') { |
| 167 | + const rows = rowsOf( |
| 168 | + await driver.execute( |
| 169 | + `select current_setting('TimeZone') as tz, extract(timezone from now())::int as off_seconds`, |
| 170 | + ), |
| 171 | + ); |
| 172 | + const row = rows[0] ?? {}; |
| 173 | + return { setting: String(row.tz ?? ''), offsetMinutes: eastOfUtc(Number(row.off_seconds) / 60) }; |
| 174 | + } |
| 175 | + if (cell.id === 'mysql') { |
| 176 | + // `convert_tz` resolves a numeric `+08:00` zone without the (usually |
| 177 | + // unloaded) mysql tz tables; a NAMED global zone yields NULL there, so fall |
| 178 | + // back to parsing the setting and let the guard fail if neither can answer. |
| 179 | + const rows = rowsOf( |
| 180 | + await driver.execute( |
| 181 | + `select @@global.time_zone as tz, |
| 182 | + timestampdiff(second, utc_timestamp(), |
| 183 | + convert_tz(utc_timestamp(), '+00:00', @@global.time_zone)) as off_seconds`, |
| 184 | + ), |
| 185 | + ); |
| 186 | + const row = rows[0] ?? {}; |
| 187 | + const setting = String(row.tz ?? ''); |
| 188 | + const seconds = row.off_seconds == null ? Number.NaN : Number(row.off_seconds); |
| 189 | + return { |
| 190 | + setting, |
| 191 | + offsetMinutes: eastOfUtc( |
| 192 | + Number.isFinite(seconds) ? seconds / 60 : parseUtcOffsetMinutes(setting), |
| 193 | + ), |
| 194 | + }; |
| 195 | + } |
| 196 | + // SQLite is in-process: there is no server, and therefore no server zone. |
| 197 | + return { setting: '', offsetMinutes: Number.NaN }; |
| 198 | +} |
| 199 | + |
| 200 | +/** |
| 201 | + * Collapse `-0` onto `+0`. |
| 202 | + * |
| 203 | + * Not cosmetic: `expect(x).not.toBe(0)` is `Object.is`, and `Object.is(-0, 0)` |
| 204 | + * is FALSE — so a UTC zone that arrives as `-0` (which is what negating a |
| 205 | + * zero `getTimezoneOffset()` produces) sails through the "not UTC" guard. This |
| 206 | + * was measured by sabotage: `TZ=UTC` passed the guard until this existed. |
| 207 | + */ |
| 208 | +const eastOfUtc = (minutes: number): number => (minutes === 0 ? 0 : minutes); |
| 209 | + |
| 210 | +/** `+08:00` / `-05:30` → minutes east of UTC; anything else → `NaN`. */ |
| 211 | +function parseUtcOffsetMinutes(setting: string): number { |
| 212 | + const m = /^([+-])(\d{1,2}):(\d{2})$/.exec(setting.trim()); |
| 213 | + if (!m) return Number.NaN; |
| 214 | + const minutes = Number(m[2]) * 60 + Number(m[3]); |
| 215 | + return eastOfUtc(m[1] === '-' ? -minutes : minutes); |
| 216 | +} |
| 217 | + |
| 218 | +/** The Node process's timezone, as the two facts the guard compares. */ |
| 219 | +export function processZone(): { name: string; offsetMinutes: number } { |
| 220 | + return { |
| 221 | + name: Intl.DateTimeFormat().resolvedOptions().timeZone || '(unknown)', |
| 222 | + // `getTimezoneOffset` is minutes WEST of UTC; flip it so both sides of the |
| 223 | + // comparison are "minutes east", the sign every server reports. Subtracting |
| 224 | + // rather than negating keeps a UTC process at `+0` — see {@link eastOfUtc}. |
| 225 | + offsetMinutes: eastOfUtc(0 - new Date().getTimezoneOffset()), |
| 226 | + }; |
| 227 | +} |
| 228 | + |
| 229 | +/** |
| 230 | + * The non-vacuity guard: server ≠ UTC ≠ process, and server ≠ process. |
| 231 | + * |
| 232 | + * Call it from an `it()` so a mis-provisioned run is a named red rather than a |
| 233 | + * green nobody reads. Every failure message carries the command that fixes it, |
| 234 | + * because the usual cause is a local run that simply never set `TZ`. |
| 235 | + */ |
| 236 | +export function assertThreeWayZoneSkew(cell: DialectCell, server: ServerZone): void { |
| 237 | + const proc = processZone(); |
| 238 | + const seen = `server=${cell.id}:${server.setting || '(unreported)'} (${server.offsetMinutes} min), ` + |
| 239 | + `process=${proc.name} (${proc.offsetMinutes} min)`; |
| 240 | + |
| 241 | + expect( |
| 242 | + Number.isFinite(server.offsetMinutes), |
| 243 | + `could not determine the ${cell.label} server's UTC offset (${seen}) — point it at a server ` + |
| 244 | + `with an explicit non-UTC timezone (PG: timezone=Asia/Shanghai, MySQL: default_time_zone='+08:00')`, |
| 245 | + ).toBe(true); |
| 246 | + |
| 247 | + expect( |
| 248 | + server.offsetMinutes, |
| 249 | + `the ${cell.label} server runs at UTC (${seen}) — on UTC the D-B2 divergence is invisible and ` + |
| 250 | + `this cell proves nothing; start it with timezone=Asia/Shanghai / default_time_zone='+08:00'`, |
| 251 | + ).not.toBe(0); |
| 252 | + |
| 253 | + expect( |
| 254 | + proc.offsetMinutes, |
| 255 | + `the process runs at UTC (${seen}) — re-run with a skewed zone, e.g. TZ=America/New_York, ` + |
| 256 | + `so a process-zone leak cannot hide behind an agreeing server`, |
| 257 | + ).not.toBe(0); |
| 258 | + |
| 259 | + expect( |
| 260 | + server.offsetMinutes, |
| 261 | + `the ${cell.label} server and the process share one UTC offset (${seen}) — the two zones must ` + |
| 262 | + `disagree, or a value folded through the wrong one still lands on the right answer`, |
| 263 | + ).not.toBe(proc.offsetMinutes); |
| 264 | +} |
0 commit comments