Skip to content

Commit eb649cb

Browse files
claude[bot]claude
andauthored
test(driver-sql): characterise how the record read door materialises updated_at (#13971)
The record-data OCC gate compared `updated_at` against the caller's token with `String(v)` on both sides. On Postgres — the production default driver — that value arrives as a JS `Date`, so the comparison ran a `Date.prototype.toString()` spelling against the ISO text the client echoed back: one instant, two spellings, and every guarded save answered 409 on records nobody had touched. It survived because every existing OCC pin drives ISO text on BOTH sides (memory, a mocked engine, or SQLite — all round-trip canonical ISO). The discriminating input is the driver's `Date`, and nothing in the repo ever produced it into that seam. `@objectstack/metadata-protocol` has no driver dependency and must not grow one, so the seam's own regression suite necessarily drives a hand-made `Date`; the composed fact — this driver, on these dialects, hands a `Date` out of its record read door — was asserted by nothing. This pins it where the live servers already are: §A the JavaScript half, server-free and therefore measurable on every runner: `Date.prototype.toString` renders whole seconds in the PROCESS zone while `toISOString` renders milliseconds in UTC. The process zone is FORCED rather than required, so the cell is non-vacuous at UTC too. §B the dialect half, through `findOne` — the door `probeRecord` reads: `updated_at` is a JS `Date` on live Postgres/MySQL and canonical ISO-8601-Z TEXT on SQLite, the accident that kept every OCC pin green. Not over-pinning: `withPostgresCalendarDayAsText` states the decision in as many words ("`timestamptz` / `timestamp` are deliberately untouched: those are instants, a `Date` is the right materialisation for them"). Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L Co-authored-by: claude <noreply@anthropic.com>
1 parent aee1fd9 commit eb649cb

1 file changed

Lines changed: 362 additions & 0 deletions

File tree

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
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

Comments
 (0)