|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#13567] What the record read door materialises `updated_at` AS, per dialect |
| 5 | + * — the composed fact #13382 was a production bug about, pinned in the package |
| 6 | + * the live servers are attached to. |
| 7 | + * |
| 8 | + * ## The gap this closes, stated as a coverage fact |
| 9 | + * |
| 10 | + * The record-data optimistic-concurrency gate (`assertVersionOf` / |
| 11 | + * `assertVersionMatch` in `@objectstack/metadata-protocol`) compared a record's |
| 12 | + * `updated_at` against the caller's token with `String(v)` on both sides. On |
| 13 | + * Postgres — the production default driver — `updated_at` arrives as a JS |
| 14 | + * `Date`, so the comparison ran |
| 15 | + * |
| 16 | + * ``` |
| 17 | + * Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time) ← the driver's value |
| 18 | + * 2026-08-30T10:19:25.947Z ← the client's echo |
| 19 | + * ``` |
| 20 | + * |
| 21 | + * One instant, two spellings, strict string compare: every guarded save |
| 22 | + * answered `409 CONCURRENT_UPDATE`, on records nobody had touched. |
| 23 | + * |
| 24 | + * It survived because EVERY existing OCC pin drives ISO text on both sides — a |
| 25 | + * memory or mocked engine, or SQLite, all of which round-trip canonical ISO |
| 26 | + * strings. Those pins were green the whole time the production default driver |
| 27 | + * refused every write. The discriminating input is the driver's `Date`, and |
| 28 | + * nothing in the repo ever produced it into that seam. |
| 29 | + * |
| 30 | + * `@objectstack/metadata-protocol` has no driver dependency and must not grow |
| 31 | + * one — the layering runs the other way — so the seam's own regression suite |
| 32 | + * necessarily drives a hand-made `Date`. That is correct and catches a revert |
| 33 | + * of the repair. What it cannot assert is the half that made the bug real: |
| 34 | + * **this driver, on these dialects, hands a `Date` out of its record read |
| 35 | + * door.** That half is asserted here, where a live Postgres and a live MySQL |
| 36 | + * actually exist (`Temporal Conformance (live PG + MySQL)`). |
| 37 | + * |
| 38 | + * ## Not over-pinning: the driver states this decision deliberately |
| 39 | + * |
| 40 | + * `withPostgresCalendarDayAsText` installs a text parser for `date` and |
| 41 | + * `date[]` and says, in as many words, why the instant types are left alone: |
| 42 | + * *"`timestamptz` / `timestamp` are deliberately untouched: those are instants, |
| 43 | + * a `Date` is the right materialisation for them, and `Field.datetime` depends |
| 44 | + * on it."* This file pins that stated decision at the door a consumer reads it |
| 45 | + * through, and the SQLite side of the same asymmetry alongside it. |
| 46 | + * |
| 47 | + * ## Which half rests on which evidence |
| 48 | + * |
| 49 | + * §A is pure JavaScript — `Date.prototype.toString` renders whole seconds in |
| 50 | + * the PROCESS's zone while `toISOString` renders milliseconds in UTC. It needs |
| 51 | + * no server, so it runs on every runner including Test Core, and it is the half |
| 52 | + * that can be measured anywhere. |
| 53 | + * |
| 54 | + * §B is the DIALECT fact and only a live server can answer it: what |
| 55 | + * `SqlDriver#findOne` puts in `updated_at`. The SQLite cell runs everywhere and |
| 56 | + * pins the ISO-text side — the accident that kept every OCC pin green; the two |
| 57 | + * live cells pin the `Date` side and are the reason this file lives in |
| 58 | + * `driver-sql` rather than next to the seam. |
| 59 | + * |
| 60 | + * ## Why the audit column and not a declared `Field.datetime` |
| 61 | + * |
| 62 | + * `created_at` / `updated_at` are BUILTIN columns, so they are not in |
| 63 | + * `datetimeFields` and no declared-field coercion reaches them. `formatOutput` |
| 64 | + * repairs them only inside its `if (this.isSqlite)` arm |
| 65 | + * (`repairNaiveUtcAuditTimestamp` over `AUDIT_TIMESTAMP_COLUMNS`), which is |
| 66 | + * precisely why the two live dialects hand the raw client value through — and |
| 67 | + * why `updated_at`, not some other datetime, is the value the OCC seam reads. |
| 68 | + */ |
| 69 | + |
| 70 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 71 | +import { SqlDriver } from './index.js'; |
| 72 | +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; |
| 73 | + |
| 74 | +/** Driver options every write here uses — this fixture is not tenant-scoped. */ |
| 75 | +const OPTS = { bypassTenantAudit: true } as any; |
| 76 | + |
| 77 | +const TABLE = 'os13567_stamp'; |
| 78 | + |
| 79 | +/** |
| 80 | + * How many create + read-back rounds each cell measures. |
| 81 | + * |
| 82 | + * Sized for §B4's non-vacuity guard rather than for coverage: `String(Date)` |
| 83 | + * drops milliseconds, which is only OBSERVABLE on a stamp that carried some. |
| 84 | + * Postgres' `CURRENT_TIMESTAMP` is microsecond-precision and MySQL's `now(3)` |
| 85 | + * is millisecond-precision, so a stamp landing on an exact `.000` is ~1 in |
| 86 | + * 1000 — rare, but not impossible, and a run in which every stamp did would |
| 87 | + * report a green that no truncation could have perturbed. Six independent |
| 88 | + * rounds put that at ~1e-18, the same sizing `#11224` uses one file over. |
| 89 | + */ |
| 90 | +const ROUNDS = 6; |
| 91 | + |
| 92 | +/** Canonical audit-timestamp text — the shape SQLite stores and returns. */ |
| 93 | +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; |
| 94 | + |
| 95 | +/** |
| 96 | + * The tail of `Date.prototype.toString` — `HH:mm:ss` with NO fractional digits, |
| 97 | + * followed by the zone offset it bakes in. |
| 98 | + * |
| 99 | + * Normative since ES2018 (`ToDateString` = DateString + TimeString + |
| 100 | + * TimeZoneString); only the trailing parenthesised zone NAME is |
| 101 | + * implementation-defined, so nothing here depends on it. |
| 102 | + */ |
| 103 | +const WHOLE_SECONDS_AND_ZONE = / \d{2}:\d{2}:\d{2} GMT[+-]\d{4}/; |
| 104 | + |
| 105 | +/** The instant from the production report, kept verbatim. */ |
| 106 | +const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z'; |
| 107 | + |
| 108 | +/** |
| 109 | + * Run `body` with the process pinned to `tz`, then restore. |
| 110 | + * |
| 111 | + * The zone is FORCED rather than required, so §A and §B3 are non-vacuous on any |
| 112 | + * runner: `Temporal Conformance` pins `TZ=America/New_York`, Test Core runs at |
| 113 | + * UTC, and a developer runs at whatever their laptop is set to. Restoring |
| 114 | + * rather than assuming matters because vitest reuses a worker across files — a |
| 115 | + * leaked `TZ` would silently re-zone whatever runs next in this process, which |
| 116 | + * is asserted below rather than trusted. |
| 117 | + * |
| 118 | + * The sibling copy in `sql-driver-11389-date-tz-skew.test.ts` is deliberately |
| 119 | + * left alone: this is a zone-scoping utility, not a guard that could weaken in |
| 120 | + * one copy and nowhere else. |
| 121 | + */ |
| 122 | +async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> { |
| 123 | + const previous = process.env.TZ; |
| 124 | + process.env.TZ = tz; |
| 125 | + try { |
| 126 | + return await body(); |
| 127 | + } finally { |
| 128 | + if (previous === undefined) delete process.env.TZ; |
| 129 | + else process.env.TZ = previous; |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +/** Minutes east of UTC, read out of a `Date.prototype.toString` rendering. */ |
| 134 | +function offsetInSpelling(spelled: string): number { |
| 135 | + const m = /GMT([+-])(\d{2})(\d{2})/.exec(spelled); |
| 136 | + if (!m) throw new Error(`no GMT offset in ${JSON.stringify(spelled)}`); |
| 137 | + return collapseNegativeZero((m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))); |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * The PROCESS's current offset for `at`, in minutes east of UTC. |
| 142 | + * |
| 143 | + * `getTimezoneOffset()` reports minutes WEST, so it is flipped here to match |
| 144 | + * the sign every `GMT+hhmm` spelling carries — and the flip is the reason |
| 145 | + * {@link collapseNegativeZero} exists. |
| 146 | + */ |
| 147 | +function processOffsetEastOfUtc(at: Date): number { |
| 148 | + return collapseNegativeZero(0 - at.getTimezoneOffset()); |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Collapse `-0` onto `+0`. |
| 153 | + * |
| 154 | + * Not cosmetic, and measured on this file's own first run: `expect(x).toBe(y)` |
| 155 | + * is `Object.is`, and `Object.is(-0, 0)` is FALSE. At UTC the spelling parses |
| 156 | + * to `+0` while negating a zero `getTimezoneOffset()` produces `-0`, so §A2's |
| 157 | + * UTC cell went red on two values that denote the same offset. The matrix |
| 158 | + * testkit's own `eastOfUtc` makes the same collapse for the same reason (there |
| 159 | + * it was found by sabotage, here by running). |
| 160 | + */ |
| 161 | +function collapseNegativeZero(minutes: number): number { |
| 162 | + return minutes === 0 ? 0 : minutes; |
| 163 | +} |
| 164 | + |
| 165 | +// ── §A The JavaScript half — measurable on any runner, no server ──────────── |
| 166 | + |
| 167 | +describe('#13567 §A — what `String(Date)` spells, and what it drops', () => { |
| 168 | + it('§A1 spells the reported instant exactly as the incident recorded it', async () => { |
| 169 | + const value = new Date(REPORTED_INSTANT); |
| 170 | + // The prefix only: the trailing `(China Standard Time)` is the one |
| 171 | + // implementation-defined part of `toString`, and pinning an ICU display |
| 172 | + // name would make this file fail on a tzdata refresh rather than on a |
| 173 | + // regression. |
| 174 | + const spelled = await underProcessZone('Asia/Shanghai', () => String(value)); |
| 175 | + expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true); |
| 176 | + // The two sides of the comparison the seam used to make, verbatim. |
| 177 | + expect(value.toISOString()).toBe(REPORTED_INSTANT); |
| 178 | + expect(spelled).not.toBe(REPORTED_INSTANT); |
| 179 | + }); |
| 180 | + |
| 181 | + it('§A2 renders whole seconds in the PROCESS zone, whatever that zone is', async () => { |
| 182 | + const value = new Date(REPORTED_INSTANT); |
| 183 | + expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947); |
| 184 | + |
| 185 | + const spellings = new Map<string, string>(); |
| 186 | + for (const tz of ['Asia/Shanghai', 'America/New_York', 'UTC', 'Pacific/Chatham']) { |
| 187 | + await underProcessZone(tz, () => { |
| 188 | + const spelled = String(value); |
| 189 | + spellings.set(tz, spelled); |
| 190 | + expect(spelled, `${tz}: no whole-second + offset tail`).toMatch(WHOLE_SECONDS_AND_ZONE); |
| 191 | + // The offset baked into the spelling IS the process's, not UTC's. |
| 192 | + expect(offsetInSpelling(spelled), `${tz}: spelled offset`).toBe(processOffsetEastOfUtc(value)); |
| 193 | + // The milliseconds are gone: the rendering names an instant `getMilliseconds()` |
| 194 | + // earlier than the value it was rendered from. |
| 195 | + expect(Date.parse(spelled), `${tz}: parsed back`).toBe(value.getTime() - value.getMilliseconds()); |
| 196 | + }); |
| 197 | + } |
| 198 | + |
| 199 | + // One instant, one `Date`, four process zones, four different spellings — |
| 200 | + // which is what "carries the process zone" means, asserted without |
| 201 | + // depending on any particular offset being what tzdata says today. |
| 202 | + expect(new Set(spellings.values()).size, `${JSON.stringify([...spellings])}`).toBe(spellings.size); |
| 203 | + }); |
| 204 | + |
| 205 | + it('§A3 restores the ambient process zone', async () => { |
| 206 | + const before = Intl.DateTimeFormat().resolvedOptions().timeZone; |
| 207 | + await underProcessZone('Pacific/Kiritimati', () => { |
| 208 | + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('Pacific/Kiritimati'); |
| 209 | + }); |
| 210 | + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(before); |
| 211 | + }); |
| 212 | +}); |
| 213 | + |
| 214 | +// ── §B The dialect half — what the read door actually hands back ──────────── |
| 215 | + |
| 216 | +function measure(cell: DialectCell): void { |
| 217 | + describe(`#13567 §B — \`updated_at\` as the record read door materialises it (${cell.label})`, () => { |
| 218 | + let driver: SqlDriver; |
| 219 | + const rows: any[] = []; |
| 220 | + |
| 221 | + beforeAll(async () => { |
| 222 | + driver = new SqlDriver(cell.config()); |
| 223 | + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); |
| 224 | + // The DDL path, so the audit columns are the ones |
| 225 | + // `createAuditTimestampColumn` produces: `timestamptz` on Postgres, |
| 226 | + // `DATETIME(3)` on MySQL, TEXT on SQLite. That pairing is the whole |
| 227 | + // subject — the column type is what decides the materialised JS type. |
| 228 | + await driver.initObjects([ |
| 229 | + { name: TABLE, fields: { id: { type: 'text' }, title: { type: 'string' } } }, |
| 230 | + ] as any); |
| 231 | + for (let i = 0; i < ROUNDS; i++) { |
| 232 | + const id = `r${i}`; |
| 233 | + await driver.create(TABLE, { id, title: 'one' }, OPTS); |
| 234 | + // `findOne` deliberately, and NOT a raw knex read: this is the door the |
| 235 | + // OCC seam probes through (`probeRecord` → `engine.findOne`), so a |
| 236 | + // read-side coercion that normalised the value would be IN scope here |
| 237 | + // and must be visible to the assertions below. |
| 238 | + rows.push(await driver.findOne(TABLE, { where: { id } }, OPTS)); |
| 239 | + } |
| 240 | + }, 60_000); |
| 241 | + |
| 242 | + afterAll(async () => { |
| 243 | + await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {}); |
| 244 | + await driver?.disconnect(); |
| 245 | + }); |
| 246 | + |
| 247 | + it('§B0 the read door returned a row per round, carrying `updated_at`', () => { |
| 248 | + // Guards the vacuous pass: every assertion below reads `updated_at` off |
| 249 | + // these rows, so a door that did not select the column at all would let |
| 250 | + // them all pass having checked nothing. |
| 251 | + expect(rows).toHaveLength(ROUNDS); |
| 252 | + for (const row of rows) { |
| 253 | + expect(row, 'findOne returned nothing').toBeTruthy(); |
| 254 | + expect( |
| 255 | + row.updated_at, |
| 256 | + 'the record read door did not return `updated_at` — the OCC seam reads this exact key', |
| 257 | + ).toBeDefined(); |
| 258 | + expect(row.updated_at).not.toBeNull(); |
| 259 | + } |
| 260 | + }); |
| 261 | + |
| 262 | + if (cell.id === 'sqlite') { |
| 263 | + it('§B1 hands `updated_at` back as canonical ISO-8601-Z TEXT', () => { |
| 264 | + for (const row of rows) { |
| 265 | + expect(typeof row.updated_at, `updated_at type for ${row.id}`).toBe('string'); |
| 266 | + expect(row.updated_at).toMatch(ISO_Z); |
| 267 | + } |
| 268 | + }); |
| 269 | + |
| 270 | + it('§B2 `String()` of it IS the token a client echoes — the accident that hid the defect', () => { |
| 271 | + // This is the control, and the reason this file exists: on the dialect |
| 272 | + // every OCC pin was written against, the naive `String(v)` comparison |
| 273 | + // matches BY ACCIDENT, because the stored value already is the |
| 274 | + // canonical spelling the client was served. |
| 275 | + const value = rows[0].updated_at as string; |
| 276 | + expect(String(value)).toBe(value); |
| 277 | + expect(new Date(value).toISOString()).toBe(value); |
| 278 | + }); |
| 279 | + } else { |
| 280 | + it('§B1 hands `updated_at` back as a JS `Date`', () => { |
| 281 | + // The composed fact. `timestamptz` (Postgres, via node-pg's stock OID |
| 282 | + // 1184 parser — `withPostgresCalendarDayAsText` overrides only `date` |
| 283 | + // and `date[]`) and `DATETIME(3)` (MySQL, via mysql2's `parseDateTime` |
| 284 | + // under the `timezone: 'Z'` pin `withUtcSession` installs) both |
| 285 | + // materialise as a `Date`, and `formatOutput`'s audit-column repair is |
| 286 | + // SQLite-gated, so nothing downstream converts it. |
| 287 | + for (const row of rows) { |
| 288 | + expect( |
| 289 | + row.updated_at instanceof Date, |
| 290 | + `${cell.label} returned updated_at as ${typeof row.updated_at} ` + |
| 291 | + `(${JSON.stringify(String(row.updated_at))}) — the OCC seam's Date handling is ` + |
| 292 | + `pinned against a shape this dialect no longer produces`, |
| 293 | + ).toBe(true); |
| 294 | + } |
| 295 | + // A real instant, not an Invalid Date dressed up as one. |
| 296 | + for (const row of rows) expect(Number.isFinite((row.updated_at as Date).getTime())).toBe(true); |
| 297 | + }); |
| 298 | + |
| 299 | + it('§B3 `String()` of it is NOT the token the client echoes, and follows the process zone', async () => { |
| 300 | + const value = rows[0].updated_at as Date; |
| 301 | + // What the GET served and what the client hands back as its next |
| 302 | + // `If-Match`: a `Date` leaves this process as its ISO-8601 form. |
| 303 | + const echoed = value.toISOString(); |
| 304 | + expect(echoed).toMatch(ISO_Z); |
| 305 | + |
| 306 | + const shanghai = await underProcessZone('Asia/Shanghai', () => String(value)); |
| 307 | + const newYork = await underProcessZone('America/New_York', () => String(value)); |
| 308 | + |
| 309 | + for (const [tz, spelled] of [['Asia/Shanghai', shanghai], ['America/New_York', newYork]] as const) { |
| 310 | + expect(spelled, `${tz}: shape`).toMatch(WHOLE_SECONDS_AND_ZONE); |
| 311 | + expect( |
| 312 | + spelled === echoed, |
| 313 | + `${tz}: a strict String() compare of the driver's value against the client's echoed ` + |
| 314 | + `token is the comparison #13382 made — ${JSON.stringify(spelled)} vs ` + |
| 315 | + `${JSON.stringify(echoed)}`, |
| 316 | + ).toBe(false); |
| 317 | + } |
| 318 | + |
| 319 | + // One `Date`, two process zones, two spellings: the process zone is |
| 320 | + // baked into the rendering. Asserted by DIFFERENCE rather than against |
| 321 | + // a literal offset, so no tzdata value is pinned here. |
| 322 | + expect( |
| 323 | + shanghai, |
| 324 | + `the same instant spelled identically under two different process zones — ` + |
| 325 | + `${JSON.stringify(shanghai)}`, |
| 326 | + ).not.toBe(newYork); |
| 327 | + expect(offsetInSpelling(shanghai)).not.toBe(offsetInSpelling(newYork)); |
| 328 | + }); |
| 329 | + |
| 330 | + it('§B4 `String()` drops the milliseconds the echoed token carries', async () => { |
| 331 | + const withMillis = rows.filter((row) => (row.updated_at as Date).getMilliseconds() !== 0); |
| 332 | + expect( |
| 333 | + withMillis.length, |
| 334 | + `none of the ${ROUNDS} stamps in this run carried sub-second digits, so nothing here ` + |
| 335 | + `could have observed the millisecond loss — the cell measured nothing`, |
| 336 | + ).toBeGreaterThan(0); |
| 337 | + |
| 338 | + for (const row of withMillis) { |
| 339 | + const value = row.updated_at as Date; |
| 340 | + const echoed = value.toISOString(); |
| 341 | + // The echoed token names the instant exactly … |
| 342 | + expect(echoed, `${row.id}: echoed token`).toMatch(/\.\d{3}Z$/); |
| 343 | + expect(Date.parse(echoed), `${row.id}: echoed token instant`).toBe(value.getTime()); |
| 344 | + // … while its `String()` names one `getMilliseconds()` earlier. Read |
| 345 | + // in the ambient zone the suite is running under, which is the zone |
| 346 | + // the seam's `String(v)` would have used. |
| 347 | + expect(Date.parse(String(value)), `${row.id}: spelled instant`).toBe( |
| 348 | + value.getTime() - value.getMilliseconds(), |
| 349 | + ); |
| 350 | + expect(Date.parse(String(value))).not.toBe(Date.parse(echoed)); |
| 351 | + } |
| 352 | + }); |
| 353 | + } |
| 354 | + }); |
| 355 | +} |
| 356 | + |
| 357 | +// A matrix that silently finds zero cells reports OK — every cell is declared |
| 358 | +// EITHER WAY, measured when it is provisioned and a named skip when it is not |
| 359 | +// (a named RED under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`). |
| 360 | +for (const cell of DIALECT_CELLS) { |
| 361 | + declareDialectCell(cell, 'audit stamp materialisation (#13567)', measure); |
| 362 | +} |
0 commit comments