test(driver-sql): measure what each dialect materialises for a datetime JS cannot hold (#14078) - #14409
Conversation
…me JS cannot hold (#14078) Part of #14078. Measurement only — no change to the shared canonical-ISO spelling, which is a maintainer decision about four packages. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
Enqueue provenance ( Generated by Claude Code |
Part of #14078 — the MEASUREMENT half. ⛔ No change to the shared canonical-ISO spelling: A vs B is the maintainer's call, and this PR exists to turn "should it change" into an evidenced question.
#14078remains open on that ruling.Headline: the reachability the card declined to claim is real, on both live dialects. Two independent driver code paths materialise a stored value as
new Date(NaN)— one of them by returning a module-level constant namedINVALID_DATE. Details and file references below; the p1 re-grade condition the triage wrote is met.1. The copies census — the denominator, re-derived
Method: a mechanical scan of every
instanceof Datearm in non-test source underpackages/, each classified by whether a NaN/finite guard on that value's time is in lexical scope, and separately by whether the arm reachestoISOString(). Run at three window sizes (6, 8, 14 lines) — the count is identical at all three.Positive control (required, and it caught a bad first predicate): the two guarded sites the card names —
packages/rest/src/export-format.ts:291andpackages/rest/src/import-prepare.ts:115— must land in the guarded bucket. A first pass that requiredtoISOString()in-window failed this control (neither site calls it; one returns theDate, the other delegates), so the predicate was widened to the wholeinstanceof Datepopulation. Control now PASSES. Reported because a census whose control fails is not a census.Population at the branch point
1dcb995f: 92 arms across 54 non-test source files.Unguarded and reaching
toISOString()— 11:packages/metadata-protocol/src/sys-metadata-repository.ts:120canonicalIsoInstantpackages/metadata/src/loaders/database-loader.ts:63canonicalIsoInstantpackages/rest/src/rest-server.ts:570canonicalIsoStamppackages/rest/src/rest-server.ts:626formatCsvCell, the sibling armpackages/metadata-protocol/src/protocol.ts:8082auditMetaItem'soccurredAtpackages/metadata/src/migrations/migrate-sys-notification-to-event.ts:219packages/objectql/src/record-title.ts:102packages/services/service-analytics/src/strategies/filter-normalizer.ts:1678packages/formula/src/template-engine.ts:160packages/drivers/driver-memory/src/memory-analytics.ts:1225packages/drivers/driver-turso/src/remote-transport.ts:3662So the shared spelling is 5 arms in 4 files — the triage's "four, not three" correction holds, and the fifth (
rest-server.ts:626) is the sibling arm the dispatch flagged. Six further unguarded arms carry the same hazard in other families; they are reported as denominator, not folded into this decision.The open question on
canonicalVersionInstantis answered: it is NOT the same shape.packages/metadata-protocol/src/protocol.ts:1523routes itsDatearm through a time value and then tests it:Number.isFinite(NaN)is false, so an InvalidDatereturnsnullthere rather than throwing. It is already total, and it is not part of the four that need a decision. (The classifier only sees this at window 14, because the guard sits 11 lines below the arm; hand-verified either way.)A third guard precedent, beyond the two the card names.
packages/services/service-storage/src/stranded-orphan-inventory.ts:211is the same three-arm function shape as the four copies and already carries the guard, with a comment naming this exact hazard: "An Invalid Date ISinstanceof Date, andtoISOString()THROWS on it (RangeError) rather than returning something odd." That matters for option B's cost: it is reuse of an established in-repo spelling, not a new invention — and one of the three precedents deliberately choseundefinedoverString(value)as its terminal arm, which is a design input if B is taken.2. The driver-source reading — how a zero / legacy datetime materialises, per dialect
The client libraries materialise datetimes in pure JavaScript, so the arithmetic that decides
Datevs InvalidDatewas reproduced and executed in-container rather than inferred from documentation. Versions as installed:pg@8.22.0(viapg-types@2.2.0,postgres-date@1.0.7),mysql2@3.23.1,better-sqlite3@13.0.3,knex@3.3.0.Driver-side configuration read off
packages/drivers/driver-sql/src/sql-driver.ts:dateStringsandtypeCastare never set anywhere indriver-sql/src(so mysql2's defaultDatematerialisation applies);withUtcSession(:4992) pins mysql2 totimezone: 'Z'and the session to+00:00;withPostgresCalendarDayAsText(:5116) overrides the type parser fordate/date[]only, leavingtimestamp/timestamptzto pg's stock parser, and says so deliberately. On the two live dialects the read door hands the client value straight through:formatOutput(:15980) appliesrepairNaiveUtcAuditTimestampto the audit columns insideif (this.isSqlite)(:15998), so nothing downstream converts it.getTime()DatearmString(value), the spelling it replaced0000-00-00 00:00:00DateNaNRangeError: Invalid time value"Invalid Date"1000-01-01 00:00:00.000Date294276-01-01 00:00:00+00DateNaNRangeError: Invalid time value"Invalid Date"infinitynumber(Infinity)"Infinity"— served0001-01-01 00:00:00+00DatestringMySQL, two independent paths, both landing on
new Date(NaN):mysql2/lib/packets/packet.js,Packet#readDateTime: after reading the components it short-circuitsif (y + m + d + H + M + S + ms === 0) return INVALID_DATE;, where line 13 of that file isconst INVALID_DATE = new Date(NaN);. The InvalidDateis not an arithmetic accident here — the library returns one by name, deliberately, for the zero datetime..query()takes) —Packet#parseDateTime(timezone)isnew Date(str + timezone). Under thetimezone: 'Z'the driver pins, a zero datetime composesnew Date('0000-00-00 00:00:00Z'), measured to be an InvalidDate. Same for0000-00-00and0000-00-00 00:00:00.000.A zero datetime is storable whenever
NO_ZERO_DATE/ strict mode is not in force — the classic legacy-import and shared-database shape.Postgres — the range gap, which is not about zero dates at all:
postgres-date@1.0.7builds every instant asnew Date(Date.UTC(year, ...)).Date.UTCanswersNaNoutside ±8.64e15 ms, andnew Date(NaN)is an InvalidDate— no throw, no null on that path. Measured boundary:275760-09-13is exactly8640000000000000;275760-09-14isNaN. Postgres' own documentedtimestamp/timestamptzceiling is294276-12-31 AD, so every year from 275760 to 294276 is a value the server stores and the client materialises as an InvalidDate. The low end is not a hazard:4713 BCand year0001both land inside JS's range, measured.Falsified, in the safe direction: the assumption that
infinity/-infinitywould be a candidate.postgres-datetests for the exact tokensinfinityand-infinityfirst and returnsNumber('Infinity')— a JS number. It never reaches the copies'instanceof Datearm at all; it falls to their terminalString(value)and is served as the textInfinity.3. The live-dialect test, and its local status
New file (the whole diff):
packages/drivers/driver-sql/src/sql-driver-14078-invalid-date-materialisation.test.ts.timezone: 'Z'off the real driver config rather than assuming it, so §A3's premise is measured and not asserted.DIALECT_CELLS/declareDialectCell, so each live cell is a named skip without its URL and a named RED underOS_EXPECT_LIVE_DIALECT_MATRIX=1. It creates the table throughinitObjects(soupdated_atis the columncreateAuditTimestampColumnproduces:timestamptz/DATETIME(3)/ TEXT), writes the exotic value with raw knex — deliberately bypassing the ObjectQL write door, because the question is what an existing row does on the way out, not what the write door admits — and reads it back throughfindOne, the door consumers of the shared spelling actually read through. MySQL's cell chains apool.afterCreatethat clearssql_modeso a zero datetime can reach disk at all;afterCreaterather than a pooledSET SESSIONbecausesql_modeis a session variable andknex.rawtakes whichever connection the pool hands it — the same trapsql-driver.tsdocuments forlock_wait_timeout, here removed rather than managed. SQLite is the negative control: with no temporal type there is no client-side parse, so no InvalidDatecan be constructed on the way out.Local run status — the live legs are NOT MEASURED here.
OS_TEST_POSTGRES_URLandOS_TEST_MYSQL_URLare unset in the dev container and no live server is reachable from it. The reading comes from the CI jobTemporal Conformance (live PG + MySQL). Locally:Non-vacuity, by ablation. Replacing the reproduced shared-spelling arm (
value.toISOString()) with the spelling it replaced (String(value)) turns §A1 red —Tests 1 failed | 7 passed | 2 skipped— so §A1 measures the spelling rather than restating a tautology. Mutation confirmed on disk by grep counts before and after (anchor 1 to 0, injected text 0 to 1); restore proven bygit hash-objectmatching the HEAD blob and an emptygit diff HEAD. A first attempt at this ablation reported exit 1 that was not a red test —No test files foundfrom a wrong path base — and was rerun rather than reported.4. Verification
Run at HEAD
4f6f64254(the final commit on this branch); the gate family was re-derived from the change set withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths passed) — 27 families, one more than the dispatch's hint, the addition beingnode scripts/check-system-context-census.mjs.node scripts/check-test-completeness.mjs, printsPREREQUISITE NOT METand exit 3 by its own design when run from the family list with no saved turbo log: NOT MEASURED, and by the gate's own words not a red.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debtalso start at that prerequisite; both were re-run after building the workspace closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and both then reported OK —check-type-check-coverage --re-measure: OK — 27 ledger entries re-measured, 1217 raw tsc errors total, none above its recorded number.pnpm --filter @objectstack/driver-sql test— 151 test files passed, 9 skipped; 2287 tests passed, 138 skipped.pnpm --filter @objectstack/driver-sql exec tsc --noEmit --listFiles— exit 0, 0 errors, and the new file confirmed present in the tsc program (include: ["src/**/*"], no test exclusion), so the typecheck really covers it.pnpm lint— the full repo scan (eslint . --no-inline-config), exit 0, clean. No narrowing claimed.Tests-only diff (one new
*.test.ts, no package publishes anything from it) ⇒skip-changeset, applied at PR open with a read-back of the resulting label set.Generated by Claude Code
Generated by Claude Code