postgres.js Dropped the Microseconds From My timestamptz Parameter
A revision check refused every save because a bound timestamptz went through a JavaScript Date and lost its microseconds. One extra ::text cast fixed it.
Every save refused as "someone else saved this day"
In RPOMS, the operations system I built for a router-refurbishment line, saving a daily report is guarded by a revision: the row's own updated_at, read as text when the form loads and compared when it saves. How that guard works is in Optimistic Concurrency for a Postgres Upsert. This note is about why, once deployed, it refused every attempt, even from a single admin immediately after a reload.
If you pass a timestamp string with microsecond precision to postgres.js and cast it to timestamptz in the query, this applies to you.
The comparison that could never be true
The predicate was:
WHERE reports.updated_at IS NOT DISTINCT FROM ${expectedRevision}::timestamptzThe token was updated_at::text, a 29-character string such as 2026-09-06 04:57:54.791364+00. Because the parameter was cast to timestamptz, the driver was told to send a timestamp, and it serialised the value through a JavaScript Date to get there. Date holds milliseconds. The microseconds were gone before PostgreSQL saw the value. Measured read-only against the live database:
| Parameter as | What the server received |
|---|---|
$1::text | 2026-09-06 04:57:54.791364+00 (29 characters) |
$1::timestamptz::text | 2026-09-06 04:57:54.791+00 (26 characters) |
.791364 is not .791, so the predicate was false, the upsert's update branch was skipped, RETURNING gave nothing, and the save was reported as a conflict. It was not one unlucky day: a freshly read revision was accepted for 0 of 64 report rows. It also failed identically with prepared statements switched on, so it was the cast, not the connection pooler.
The fix: ::text::timestamptz
WHERE reports.updated_at IS NOT DISTINCT FROM ${expectedRevision}::text::timestamptzThe middle cast types the parameter as text, so the driver sends the characters untouched and PostgreSQL parses them itself, microseconds included. All 64 rows were then accepted, and a stale revision and a null revision on an existing day were still refused, both measured. Concurrency protection was not weakened; one line changed and no migration was needed.
Comparing updated_at::text to the parameter also works, but it compares rendered text, and rendering depends on the session's TimeZone. Casting to timestamptz compares instants, and the token carries its own offset, so it holds however either side is rendered.
Why tests and my first diagnosis both missed it
Local development used the file-based store, which does not implement the guarded upsert and falls back to a plain one. Every "save the same day twice" test passed without this statement ever running.
Before finding the cast, I blamed a NULL updated_at and shipped a fix for that. The live column turned out to be NOT NULL DEFAULT now() with no null rows, so that diagnosis was simply wrong, and the source comment now says so. What found the real cause was two small read-only scripts run against the live database: one that reproduced the save's comparison, and one that printed how a bound parameter arrives under each cast. Neither contains an INSERT, UPDATE, DELETE, TRUNCATE, DROP or ALTER.
Two tests now pin it. One asserts the predicate's shape and fails on the old form. The other runs the real save function against an in-process PostgreSQL through the real postgres.js driver, because the defect lives in how the driver types a bound parameter and a mock would prove nothing. Seeding that test taught the same lesson again: ${SEED}::timestamptz stores .791 on the way in just as readily, which is how the first draft of the test failed to reproduce the bug.
The project behind this is the RPOMS case study. For another way a production-only database problem can hide behind a friendly error screen, see the EMAXCONNSESSION postmortem.
The work behind this
Related
Keep reading
Optimistic Concurrency for a Postgres Upsert, Using the Row's Own updated_at
Two admins saving the same day meant the second silently replaced the first. One guarded INSERT ... ON CONFLICT statement and a 409 fixed it, and two bugs followed.
Testing Real PostgreSQL SQL in Vitest With PGlite and the Real Driver
No Postgres server, no Docker, and production off limits. PGlite behind a loopback socket let the real postgres.js driver run real SQL in Vitest and in CI.
postgres.js Stored My JSON Arrays as Strings in a jsonb Column
Writing JSON.stringify(x)::jsonb through postgres.js encodes the value twice, so a jsonb column holds a string, not an array. The symptom, the sql.json() fix, and handling rows already stored.