From cefc8c15c14011ede55223524e87d7eabc6771fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:15:03 +0200 Subject: [PATCH 01/10] fix(db): pin every connection to UTC and say so at boot when it is not Stored times are UTC in timestamp columns without a zone, and Postgres reads now(), CURRENT_TIMESTAMP and offset-free timestamptz parameters through the session time zone. On a database whose default is not UTC every expiry check ran off by the offset. The Prisma pool, pg-boss, the lock and the maintenance connections now send TimeZone=UTC as a startup option, or SET it on connect when the startup options are disabled for a pooler. A boot check reads SHOW TimeZone and warns on anything else. --- src/instrumentation.ts | 11 +++ src/lib/__tests__/db.test.ts | 39 +++++++++- .../boot/__tests__/time-zone-check.test.ts | 59 +++++++++++++++ src/lib/boot/time-zone-check.ts | 75 +++++++++++++++++++ src/lib/db-session.ts | 69 +++++++++++++++++ src/lib/db.ts | 36 ++++----- .../measurement-maintenance-session.test.ts | 1 + src/lib/jobs/boss-instance.ts | 4 + src/lib/jobs/job-lock.ts | 7 +- src/lib/jobs/measurement-maintenance.ts | 2 +- 10 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 src/lib/boot/__tests__/time-zone-check.test.ts create mode 100644 src/lib/boot/time-zone-check.ts create mode 100644 src/lib/db-session.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9a3e2cb25..78981c5e6 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -28,6 +28,17 @@ export async function register() { console.error("[instrumentation] Encryption key check crashed:", err); } + // Every connection is pinned to UTC; say clearly if one is not. + try { + const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([ + import("@/lib/boot/time-zone-check"), + import("@/lib/db"), + ]); + await runBootTimeZoneCheck(prisma); + } catch { + // A diagnostic must never break boot. + } + // Every Node process watches its own event loop — web-only // containers included; a stall report from the process serving // requests is the whole point. diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 5cf7c93e6..9ca117bee 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildSessionOptions, getConnectionBudget, + pinTimeZoneOnConnect, + SET_SESSION_TIME_ZONE, + timeZoneSessionOptions, getPgBossPoolMax, getPoolConnectionTimeoutMs, getPrismaPoolMax, @@ -135,14 +138,14 @@ describe("buildSessionOptions", () => { it("keeps work_mem when the timeout is disabled", () => { process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0"; delete process.env.DATABASE_WORK_MEM; - expect(buildSessionOptions()).toBe("-c work_mem=16MB"); + expect(buildSessionOptions()).toBe("-c work_mem=16MB -c TimeZone=UTC"); }); it("sets the default work_mem beside the timeouts", () => { delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; delete process.env.DATABASE_WORK_MEM; expect(buildSessionOptions()).toBe( - "-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB", + "-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c TimeZone=UTC", ); }); @@ -166,6 +169,38 @@ describe("buildSessionOptions", () => { }); }); +// Every session runs in UTC whatever the database default says: the stored +// `timestamp` values are UTC and `now()` reads through the session zone. +describe("session time zone pin", () => { + afterEach(() => { + delete process.env.DATABASE_SESSION_OPTIONS_DISABLED; + }); + + it("rides the startup options of every pool", () => { + expect(buildSessionOptions()).toContain("-c TimeZone=UTC"); + expect(timeZoneSessionOptions()).toBe("-c TimeZone=UTC"); + }); + + it("adds no connect hook while the startup options carry the pin", () => { + const on = vi.fn(); + pinTimeZoneOnConnect({ on } as never); + expect(on).not.toHaveBeenCalled(); + }); + + it("pins by statement on connect when the startup options are off", async () => { + process.env.DATABASE_SESSION_OPTIONS_DISABLED = "true"; + expect(timeZoneSessionOptions()).toBeUndefined(); + const on = vi.fn(); + pinTimeZoneOnConnect({ on } as never); + expect(on).toHaveBeenCalledWith("connect", expect.any(Function)); + const query = vi.fn().mockResolvedValue(undefined); + const hook = on.mock.calls[0][1] as (client: unknown) => void; + hook({ query }); + expect(query).toHaveBeenCalledWith(SET_SESSION_TIME_ZONE); + expect(SET_SESSION_TIME_ZONE).toBe("SET TIME ZONE 'UTC'"); + }); +}); + describe("process-wide Prisma client", () => { const globalSlot = globalThis as unknown as { prisma?: unknown }; diff --git a/src/lib/boot/__tests__/time-zone-check.test.ts b/src/lib/boot/__tests__/time-zone-check.test.ts new file mode 100644 index 000000000..bce14204c --- /dev/null +++ b/src/lib/boot/__tests__/time-zone-check.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + checkSessionTimeZone, + isUtcTimeZone, + runBootTimeZoneCheck, +} from "../time-zone-check"; + +function clientAnswering(rows: unknown) { + return { $queryRawUnsafe: vi.fn().mockResolvedValue(rows) }; +} + +describe("boot time zone check", () => { + afterEach(() => vi.restoreAllMocks()); + + it("accepts the spellings Postgres reports for UTC", () => { + for (const name of ["UTC", "Etc/UTC", "utc", "UCT", "Zulu"]) { + expect(isUtcTimeZone(name)).toBe(true); + } + for (const name of ["Europe/Berlin", "GMT+1", "America/New_York"]) { + expect(isUtcTimeZone(name)).toBe(false); + } + }); + + it("stays quiet on a UTC session", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = clientAnswering([{ TimeZone: "UTC" }]); + expect(await runBootTimeZoneCheck(client)).toEqual({ + state: "utc", + timeZone: "UTC", + }); + expect(client.$queryRawUnsafe).toHaveBeenCalledWith("SHOW TimeZone"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns, naming the zone and the fix, on any other zone", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const outcome = await runBootTimeZoneCheck( + clientAnswering([{ TimeZone: "Europe/Berlin" }]), + ); + expect(outcome).toEqual({ state: "mismatch", timeZone: "Europe/Berlin" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('"Europe/Berlin"'); + expect(warn.mock.calls[0][0]).toContain("SET timezone TO 'UTC'"); + }); + + it("never throws when the database cannot answer", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = { + $queryRawUnsafe: vi.fn().mockRejectedValue(new Error("down")), + }; + expect(await checkSessionTimeZone(client)).toEqual({ + state: "error", + message: "down", + }); + await runBootTimeZoneCheck(client); + expect(warn.mock.calls[0][0]).toContain("skipped: down"); + }); +}); diff --git a/src/lib/boot/time-zone-check.ts b/src/lib/boot/time-zone-check.ts new file mode 100644 index 000000000..45a23ba60 --- /dev/null +++ b/src/lib/boot/time-zone-check.ts @@ -0,0 +1,75 @@ +/** + * Boot check: does a pooled connection really run in UTC? + * + * HealthLog pins `TimeZone=UTC` on every connection it opens (see + * `@/lib/db-session`). This reads the setting back on one pooled connection + * and says so in the log when it is anything else: a pooler that drops the + * startup options, a role or proxy that overrides the setting. The expiry + * checks of passkey challenges, sessions and one-time links read the stored + * UTC timestamps through that zone, so a mismatch shows up as "expired" + * straight away. The check never throws and never stops the boot. + */ + +const UTC_NAMES = /^(etc\/)?(utc|uct|universal|zulu)$/i; + +export interface TimeZoneCheckClient { + $queryRawUnsafe(query: string): Promise; +} + +export type TimeZoneCheckOutcome = + | { state: "utc"; timeZone: string } + | { state: "mismatch"; timeZone: string } + | { state: "error"; message: string }; + +export function isUtcTimeZone(name: string): boolean { + return UTC_NAMES.test(name.trim()); +} + +export async function checkSessionTimeZone( + client: TimeZoneCheckClient, +): Promise { + try { + const rows = + await client.$queryRawUnsafe>>( + "SHOW TimeZone", + ); + const value = rows[0] ? Object.values(rows[0])[0] : undefined; + const timeZone = typeof value === "string" ? value : String(value); + return isUtcTimeZone(timeZone) + ? { state: "utc", timeZone } + : { state: "mismatch", timeZone }; + } catch (err) { + return { + state: "error", + message: err instanceof Error ? err.message : String(err), + }; + } +} + +export function timeZoneMismatchMessage(timeZone: string): string { + return ( + `[boot] The database session runs in time zone "${timeZone}", not UTC. ` + + "HealthLog stores times as UTC and pins every connection to UTC, but " + + "something between the app and the database overrode it. Expiry checks " + + "(passkey challenges, sessions, one-time links) will be off by the zone " + + "offset. Fix: ALTER DATABASE SET timezone TO 'UTC', and if a " + + "connection pooler drops the startup options, set " + + "DATABASE_SESSION_OPTIONS_DISABLED=true so the app pins the zone by " + + "statement instead." + ); +} + +/** Run the check and log the outcome. Resolves the outcome for tests. */ +export async function runBootTimeZoneCheck( + client: TimeZoneCheckClient, +): Promise { + const outcome = await checkSessionTimeZone(client); + if (outcome.state === "mismatch") { + console.warn(timeZoneMismatchMessage(outcome.timeZone)); + } else if (outcome.state === "error") { + console.warn( + `[boot] Database time zone check skipped: ${outcome.message.slice(0, 300)}`, + ); + } + return outcome; +} diff --git a/src/lib/db-session.ts b/src/lib/db-session.ts new file mode 100644 index 000000000..e9b0e8323 --- /dev/null +++ b/src/lib/db-session.ts @@ -0,0 +1,69 @@ +/** + * Session settings shared by every Postgres connection HealthLog opens: the + * Prisma pool, pg-boss's pool and the dedicated lock and maintenance clients. + * Kept apart from `db.ts` so a side connection can use them without loading + * the Prisma client. + */ +import type { Pool, PoolClient } from "pg"; + +/** + * Whether the app may send the libpq `options` startup parameter at all. + * + * PgBouncer refuses a connection that carries a startup parameter it does not + * know (`unsupported startup parameter: options`) unless the operator lists + * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the + * statement timeout to 0, which dropped the whole string; v1.42 always sends + * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` + * (1, true or yes) sends none of the session settings; the pooler or the + * database's own defaults then apply. + */ +export function sessionOptionsDisabled(): boolean { + const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** + * The session time zone every connection HealthLog opens runs in. + * + * Almost every `DateTime` column is `timestamp(3)` WITHOUT time zone and + * holds UTC. Postgres converts between that type and `now()` / + * `CURRENT_TIMESTAMP` / `timestamptz` through the session time zone, and the + * driver sends a `Date` parameter as an offset-free UTC string that a + * `timestamptz` column reads in the session time zone. On a database whose + * default is anything but UTC (a shared instance set to Europe/Berlin, say) + * every expiry check then ran hours off, a passkey challenge expired before + * the prompt closed, and `timestamptz` writes landed shifted. Pinning the + * session removes the dependence for every query at once, whatever the + * database or role default says. + */ +export const SESSION_TIME_ZONE = "UTC"; + +export const TIME_ZONE_OPTION = `-c TimeZone=${SESSION_TIME_ZONE}`; + +/** + * The statement that pins the time zone where the startup `options` cannot + * be sent (`DATABASE_SESSION_OPTIONS_DISABLED`, a connection pooler). + * PgBouncer tracks `TimeZone` per client and replays it onto whichever server + * connection serves the next transaction, so a `SET` on connect holds in + * transaction pooling too. + */ +export const SET_SESSION_TIME_ZONE = `SET TIME ZONE '${SESSION_TIME_ZONE}'`; + +/** The startup option alone, for the side connections (pg-boss, locks). */ +export function timeZoneSessionOptions(): string | undefined { + return sessionOptionsDisabled() ? undefined : TIME_ZONE_OPTION; +} + +/** + * Pin the time zone on every new connection of `pool` by statement, when the + * startup options are off. pg queues the statement ahead of the first query + * the new client is handed out for, so no query runs before it. + */ +export function pinTimeZoneOnConnect(pool: Pool): void { + if (!sessionOptionsDisabled()) return; + pool.on("connect", (client: PoolClient) => { + client.query(SET_SESSION_TIME_ZONE).catch((err: unknown) => { + console.error("[db] Could not pin the session time zone:", err); + }); + }); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index bfa815ae1..be64fb5b7 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,19 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import { + pinTimeZoneOnConnect, + sessionOptionsDisabled, + TIME_ZONE_OPTION, +} from "@/lib/db-session"; + +export { + pinTimeZoneOnConnect, + SESSION_TIME_ZONE, + SET_SESSION_TIME_ZONE, + sessionOptionsDisabled, + timeZoneSessionOptions, +} from "@/lib/db-session"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; @@ -108,22 +122,6 @@ export function getWorkMem(): string { return raw && WORK_MEM_SHAPE.test(raw) ? raw : DEFAULT_WORK_MEM; } -/** - * Whether the app may send the libpq `options` startup parameter at all. - * - * PgBouncer refuses a connection that carries a startup parameter it does not - * know (`unsupported startup parameter: options`) unless the operator lists - * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the - * statement timeout to 0, which dropped the whole string; v1.42 always sends - * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` - * (1, true or yes) sends none of the session settings; the pooler or the - * database's own defaults then apply. - */ -export function sessionOptionsDisabled(): boolean { - const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); - return raw !== undefined && /^(1|true|yes)$/i.test(raw); -} - /** * Build the libpq `options` startup string applying the session settings. * Passed straight through `PrismaPg` to the underlying `pg.Pool`, which @@ -143,16 +141,18 @@ export function buildSessionOptions(): string | undefined { timeoutMs > 0 ? `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs} ` : ""; - return `${timeouts}-c work_mem=${getWorkMem()}`; + return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`; } function createPrismaClient() { - const adapter = new PrismaPg({ + const pool = new Pool({ connectionString: process.env.DATABASE_URL!, max: getPrismaPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), options: buildSessionOptions(), }); + pinTimeZoneOnConnect(pool); + const adapter = new PrismaPg(pool, { disposeExternalPool: true }); return new PrismaClient({ adapter }); } diff --git a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts index 93ba20002..213c9c5ed 100644 --- a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts +++ b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts @@ -26,6 +26,7 @@ describe("maintenance session settings behind a pooler", () => { "SET maintenance_work_mem = '128MB'", "SET max_parallel_maintenance_workers = '0'", "SET lock_timeout = '300000'", + "SET TimeZone = 'UTC'", ]); }); it("keeps maintenance in one process so it needs no /dev/shm segment", () => { diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index b807477d5..438e85818 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -8,6 +8,7 @@ import { PgBoss } from "pg-boss"; import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db"; +import { timeZoneSessionOptions } from "@/lib/db-session"; const BOSS_KEY = "__healthlog_pgboss__" as const; const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; @@ -76,6 +77,9 @@ function databaseOptions(connectionString: string) { connectionString, max: getPgBossPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), + // pg-boss keeps its own tables in `timestamptz`, so its queue math does + // not depend on the zone; the pin keeps every HealthLog session alike. + options: timeZoneSessionOptions(), } as const; } diff --git a/src/lib/jobs/job-lock.ts b/src/lib/jobs/job-lock.ts index 711fe471d..eb85441d7 100644 --- a/src/lib/jobs/job-lock.ts +++ b/src/lib/jobs/job-lock.ts @@ -19,6 +19,7 @@ * One extra connection per running locked job. */ import { Client } from "pg"; +import { timeZoneSessionOptions } from "@/lib/db-session"; import { caughtAs } from "@/lib/logging/signal"; /** What a guarded run came to. */ @@ -50,7 +51,11 @@ export async function withJobLock( connectionString: string | undefined = process.env.DATABASE_URL, options: JobLockOptions = {}, ): Promise> { - const client = new Client({ connectionString, keepAlive: true }); + const client = new Client({ + connectionString, + keepAlive: true, + options: timeZoneSessionOptions(), + }); // An idle client whose server goes away emits `error`; unhandled, that // takes the whole worker down. The run itself notices the database is // gone through its own queries. diff --git a/src/lib/jobs/measurement-maintenance.ts b/src/lib/jobs/measurement-maintenance.ts index 72a7c6a03..34eed50d9 100644 --- a/src/lib/jobs/measurement-maintenance.ts +++ b/src/lib/jobs/measurement-maintenance.ts @@ -77,7 +77,7 @@ export interface MeasurementMaintenancePayload { * whatever its container settings. */ export const MAINTENANCE_SESSION_OPTIONS = - "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c max_parallel_maintenance_workers=0 -c lock_timeout=300000"; + "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c max_parallel_maintenance_workers=0 -c lock_timeout=300000 -c TimeZone=UTC"; /** * The same settings as `SET` statements, for a host behind a connection From f65d25b0d0c8a21d530a2d08125c7b5890f4ce1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:31:37 +0200 Subject: [PATCH 02/10] fix(db): read the clock as UTC in raw SQL and prove it on a Berlin database Every raw-SQL clock read against a timestamp column without a zone now writes NOW() AT TIME ZONE 'UTC', so passkey and login challenges, rate limits, provider pauses, coach usage stamps, tombstone and measurement windows no longer pass through the session time zone even where the per-connection pin cannot apply. The regression-window bounds stay naive UTC timestamps instead of being turned into timestamptz. A structural guard keeps bare NOW() / CURRENT_TIMESTAMP out of raw SQL outside a short list of timestamptz targets. The integration test sets the database default to Europe/Berlin and drives challenge claims, session expiry, the native sign-in handoff freshness check and the rate-limit window through the app's client, plus the challenge claim through an unpinned client. Self-hosting docs describe the pin for an existing or shared Postgres. --- docs/self-hosting/getting-started.md | 20 ++ docs/self-hosting/scaling.md | 7 +- prisma/schema.prisma | 3 +- src/__tests__/sql-now-utc-guard.test.ts | 97 +++++++ src/app/api/measurements/series/route.ts | 2 +- src/lib/ai/coach/budget.ts | 8 +- src/lib/ai/provider-health-ledger.ts | 14 +- .../__tests__/summaries-slice.test.ts | 12 +- src/lib/analytics/source-rank-sql.ts | 4 +- src/lib/analytics/summaries-slice.ts | 29 +- src/lib/auth/passkey.ts | 6 +- src/lib/db.ts | 28 +- src/lib/export/measurement-bulk-insert.ts | 2 +- src/lib/insights/comprehensive-aggregator.ts | 29 +- src/lib/jobs/__tests__/boss-instance.test.ts | 2 + src/lib/jobs/data-arrival.ts | 2 +- src/lib/jobs/reminder/cleanup-handlers.ts | 2 +- src/lib/jobs/step-consolidation-repair.ts | 2 +- .../repair-apple-health-distance.ts | 2 +- src/lib/notifications/delivery-identity.ts | 4 +- src/lib/rate-limit.ts | 8 +- tests/integration/session-time-zone.test.ts | 252 ++++++++++++++++++ 22 files changed, 469 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/sql-now-utc-guard.test.ts create mode 100644 tests/integration/session-time-zone.test.ts diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c55033afd..9967227e6 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -77,6 +77,26 @@ process (`pnpm dev` against a host Postgres) or a **custom compose** that does not derive it — in that case uncomment and set `DATABASE_URL` explicitly in `.env` (see the commented example there). +### Using an existing or shared Postgres + +HealthLog can live in a database on a Postgres instance you already run. +It stores every time as UTC and pins each connection it opens to UTC +(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the +server's or the database's own `timezone` setting does not matter: an +instance set to `Europe/Berlin` works the same as one set to UTC. The app +checks the setting at startup and logs a warning naming the zone if a +connection still reports something else. + +Behind PgBouncer with `DATABASE_SESSION_OPTIONS_DISABLED=1` (see +[Scaling](./scaling.md#postgres-connection-pool-sizing)) the app pins the +zone with `SET TIME ZONE 'UTC'` on each new connection instead; PgBouncer +keeps that setting per client in transaction pooling too. If the startup +warning still appears, set the database default as well: + +```sql +ALTER DATABASE healthlog SET timezone TO 'UTC'; +``` + ## 3. Bring the stack up ```bash diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 2512ecad9..c39d30b5a 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -188,13 +188,16 @@ already`. Either raise `max_connections` on the Postgres side, lower `DATABASE_POOL_MAX`, or front the database with PgBouncer in transaction-pooling mode. -The app sets its statement timeout and `work_mem` on every connection -through the libpq `options` startup parameter. PgBouncer refuses that +The app sets its statement timeout, `work_mem` and `TimeZone=UTC` on every +connection through the libpq `options` startup parameter. PgBouncer refuses that parameter (`unsupported startup parameter: options`) unless it is listed in `ignore_startup_parameters`. Either list it there, or set `DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session settings; set the timeout and `work_mem` on the database role instead (`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them. +The time zone is the exception: with the options off the app pins it with +`SET TIME ZONE 'UTC'` on each new connection, which PgBouncer tracks per +client, and warns at startup if a connection still reports another zone. The measurement maintenance job (Admin, System status) then applies its own settings with `SET` after connecting. Its concurrent index rebuild needs a session, so run it through a session-mode pool or a direct connection. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..8cce98cc9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,8 @@ // round-trip the same instant. The invariant every reader, writer, and // raw-SQL query MUST hold is: // -// * The application server and the Postgres session run in UTC. +// * The application server and the Postgres session run in UTC (every +// connection pins TimeZone=UTC, see src/lib/db-session.ts). // * Wall-clock / calendar-local anchoring lives in an explicit `tz` // string column (e.g. `MoodEntry.tz`) or the user's `timezone`, NOT // in the column type. diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts new file mode 100644 index 000000000..8846c7622 --- /dev/null +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -0,0 +1,97 @@ +/** + * Raw SQL never reads the clock through the session time zone. + * + * Stored times are UTC in `timestamp` columns without a zone. Postgres + * compares such a column with `NOW()` / `CURRENT_TIMESTAMP` / + * `clock_timestamp()` by converting through the session time zone, and + * assigns one of them to such a column the same way, so on a database whose + * default is not UTC a fresh passkey challenge read as already expired. Every + * connection is pinned to UTC (`@/lib/db-session`), and on top of that every + * raw-SQL clock read writes `NOW() AT TIME ZONE 'UTC'`, which yields the naive + * UTC timestamp whatever the session says. + * + * The few bare reads left are listed below with the reason they are safe: + * each feeds a `timestamptz` column or returns the instant to JavaScript. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); +const SRC = join(ROOT, "src"); + +/** `file: reason` for every bare clock read that is deliberately left. */ +const ALLOWED: Record = { + "src/lib/rollups/mood-rollups.ts": "computed_at is timestamptz", + "src/lib/rollups/medication-compliance-rollups.ts": + "computed_at is timestamptz", + "src/lib/jobs/job-failures.ts": "pg-boss tables are timestamptz", + "src/lib/jobs/reminder/registrar-shared.ts": "pg-boss tables are timestamptz", + "src/lib/db-compat.ts": "DDL default on a timestamptz column", +}; + +const SQL_CLOCK = + /(?]|=)\s*now\(\)\s*(?:$|[,)]|[-+]\s*(?:make_interval|INTERVAL))|\bnow\(\)\s*[-+]\s*(?:make_interval|INTERVAL)/; +const ANCHORED = + /(?:NOW\(\)|CURRENT_TIMESTAMP|clock_timestamp\(\)) AT TIME ZONE 'UTC'/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) + out.push(full); + } + return out; +} + +function offenders(): string[] { + const found: string[] = []; + for (const file of walk(SRC)) { + const rel = relative(ROOT, file); + if (ALLOWED[rel]) continue; + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (/^\s*(\/\/|\*|\/\*|--)/.test(line)) return; + if (SQL_CLOCK.test(line.replace(ANCHORED, ""))) { + found.push(`${rel}:${index + 1}: ${line.trim()}`); + } + }); + } + return found; +} + +describe("raw SQL clock reads are anchored to UTC", () => { + it("finds the anchored form across the swept files", () => { + // The matcher must see something, or a broken walk would pass. + const passkey = readFileSync(join(SRC, "lib/auth/passkey.ts"), "utf8"); + expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3); + expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true); + expect(SQL_CLOCK.test("updated_at = now()")).toBe(true); + expect(SQL_CLOCK.test("const started = now();")).toBe(false); + expect(SQL_CLOCK.test("elapsedMs: () => now() - started,")).toBe(false); + expect(SQL_CLOCK.test("const ends = now() + perCallMs;")).toBe(false); + expect( + SQL_CLOCK.test("deleted_at >= now() - make_interval(days => 1)"), + ).toBe(true); + expect(SQL_CLOCK.test("Date.now()")).toBe(false); + }); + + it("leaves no bare NOW() / CURRENT_TIMESTAMP outside the allowlist", () => { + expect(offenders()).toEqual([]); + }); + + it("keeps the allowlist honest: every entry still has a bare read", () => { + for (const rel of Object.keys(ALLOWED)) { + const lines = readFileSync(join(ROOT, rel), "utf8") + .replace(ANCHORED, "") + .split("\n"); + expect( + lines.some((line) => SQL_CLOCK.test(line)), + rel, + ).toBe(true); + } + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 17049bf4c..a60dd7992 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -378,7 +378,7 @@ export const GET = apiHandler(async (request: NextRequest) => { AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND "deleted_at" IS NULL AND "value" >= $3 - AND "measured_at" >= date_trunc('day', $2::timestamptz) + AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC') ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text ) c ON c.d = date_trunc('day', m."measured_at") diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 88e1e0097..f1d387e0a 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -321,12 +321,12 @@ export async function reserveBudget( { total_tokens: number; operator_tokens: number }[] >` INSERT INTO coach_usage (id, user_id, date_key, total_tokens, operator_tokens, message_count, created_at, updated_at) - VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, NOW(), NOW()) + VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, date_key) DO UPDATE SET total_tokens = coach_usage.total_tokens + ${reserved}, operator_tokens = coach_usage.operator_tokens + ${operatorReserved}, message_count = coach_usage.message_count + ${messageBump}, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') RETURNING total_tokens, operator_tokens `; const totalAfter = Number(rows[0]?.total_tokens ?? reserved); @@ -453,7 +453,7 @@ export async function reconcileSpend( UPDATE coach_usage SET total_tokens = GREATEST(0, total_tokens + ${delta}), operator_tokens = GREATEST(0, operator_tokens + ${operatorDelta}), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${userId} AND date_key = ${dateKey} `; } @@ -472,7 +472,7 @@ async function refundReservation( SET total_tokens = GREATEST(0, total_tokens - ${reserved}), operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}), message_count = GREATEST(0, message_count - ${messageBump}), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${userId} AND date_key = ${dateKey} `; } diff --git a/src/lib/ai/provider-health-ledger.ts b/src/lib/ai/provider-health-ledger.ts index 64c49388c..1113fe373 100644 --- a/src/lib/ai/provider-health-ledger.ts +++ b/src/lib/ai/provider-health-ledger.ts @@ -203,14 +203,14 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { next_retry_at, updated_at) VALUES (gen_random_uuid()::text, ${userId}, ${providerType}, 'ok', NULL, - 0, NOW(), NULL, NULL, NOW()) + 0, (NOW() AT TIME ZONE 'UTC'), NULL, NULL, (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, provider_type) DO UPDATE SET last_result = 'ok', last_status = NULL, consecutive_failures = 0, - last_ok_at = NOW(), + last_ok_at = (NOW() AT TIME ZONE 'UTC'), next_retry_at = NULL, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') `; } catch { // Fire-and-forget — a failed write never blocks the hot path. @@ -240,13 +240,13 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { next_retry_at, updated_at) VALUES (gen_random_uuid()::text, ${userId}, ${providerType}, ${result}, - ${status}, 1, NULL, NOW(), NOW() + ${interval}::interval, NOW()) + ${status}, 1, NULL, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC') + ${interval}::interval, (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, provider_type) DO UPDATE SET last_result = ${result}, last_status = ${status}, consecutive_failures = provider_health.consecutive_failures + 1, - last_failure_at = NOW(), - next_retry_at = NOW() + CASE + last_failure_at = (NOW() AT TIME ZONE 'UTC'), + next_retry_at = (NOW() AT TIME ZONE 'UTC') + CASE WHEN ${result} = 'hard_failed' AND provider_health.consecutive_failures + 1 >= ${pauseFrom}::int THEN LEAST( @@ -258,7 +258,7 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { ) ELSE ${interval}::interval END, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') `; } catch { // Fire-and-forget. diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index b7e3551df..a503f58e0 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -662,13 +662,17 @@ describe("computeSummariesSlice", () => { // left with slope90 in v1.37.19). for (const days of ["7 days", "30 days"]) { expect(sql).toContain( - `(date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}') AT TIME ZONE 'UTC'`, + `m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}')`, ); } const anchored = sql.match( - /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\) AT TIME ZONE 'UTC'/g, + /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\)/g, ); expect(anchored?.length).toBe(4); + // The bound stays a naive UTC timestamp like measured_at: converting + // it to timestamptz would route the comparison through the session + // time zone. + expect(sql).not.toMatch(/INTERVAL '(?:7|30) days'\) AT TIME ZONE/); // The regression windows must NOT fall back to the bare wall-clock // `NOW() - INTERVAL 'N days'` bound the warm path never uses for the @@ -676,7 +680,9 @@ describe("computeSummariesSlice", () => { // REGR_SLOPE onward) so the avg7/avg30 windows above — which DO stay // wall-clock by design — don't trip the assertion. const slopeBlock = sql.slice(sql.indexOf("REGR_SLOPE")); - expect(slopeBlock).not.toMatch(/WHERE m\."measured_at" >= NOW\(\) -/); + expect(slopeBlock).not.toMatch( + /WHERE m\."measured_at" >= \(?NOW\(\)( AT TIME ZONE 'UTC'\))? -/, + ); }); }); }); diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts index c53485983..a5f1940b2 100644 --- a/src/lib/analytics/source-rank-sql.ts +++ b/src/lib/analytics/source-rank-sql.ts @@ -134,10 +134,10 @@ export function canonicalMeasurementsCte( onlyType?: MeasurementType, ): string { const sinceInner = sinceInterval - ? `AND "measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'` + ? `AND "measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${assertInterval(sinceInterval)}'` : ""; const sinceOuter = sinceInterval - ? `AND mm."measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'` + ? `AND mm."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${assertInterval(sinceInterval)}'` : ""; // A single-type reader (a series for one metric) scopes both halves to that // type, so the pick scans one metric's rows instead of every row the user diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 92b2c1ff0..9766410a9 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -174,10 +174,14 @@ function windowedMean(filter: string): string { filter, }); } -const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`); -const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`); +const AVG7_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days'`, +); +const AVG30_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, +); const AVG30_LAST_MONTH_SQL = windowedMean( - `m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`, + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days' AND m."measured_at" < (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, ); interface NarrowAggregateRow { @@ -566,7 +570,7 @@ async function computeFromRollups(userId: string): Promise { PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY m."value" ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' )::double precision AS median FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m GROUP BY m."type" @@ -1041,17 +1045,16 @@ async function computeFromLiveAggregate( PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY m."value" ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' )::double precision AS median, -- A2-M2 — anchor the regression windows on the UTC-midnight day -- boundary so this cold-fallback path returns the SAME boundary-day -- membership the warm computeFromRollups composes via -- startOfUtcDay(now - N days) + composeWindowedRegression. The -- expression date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL - -- 'N days' is the SQL twin of startOfUtcDay; the trailing AT TIME - -- ZONE 'UTC' re-materialises the naive truncation as a UTC instant - -- so the measured_at (timestamptz) comparison lands on the same wall - -- the rollup bucketStart sits on. The wall-clock NOW() - INTERVAL + -- 'N days' is the SQL twin of startOfUtcDay. It stays a naive UTC + -- timestamp, like measured_at itself, so the comparison never + -- passes through the session time zone. The wall-clock NOW() - INTERVAL -- bound let the boundary day flip in/out of the slope window across -- a warm-to-cold coverage transition (cache-dependent slope on the -- same request); the avg7/30/median windows above stay wall-clock @@ -1060,25 +1063,25 @@ async function computeFromLiveAggregate( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS r2_30 FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m GROUP BY m."type" diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index d2847ad82..cc7b7a5a3 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -186,7 +186,7 @@ export async function consumeRegistrationChallenge(input: { WHERE id = ${input.challengeId} AND user_id = ${input.userId} AND type = ${expectedType} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, created_at `; @@ -225,7 +225,7 @@ export async function claimAuthChallenge(input: { WHERE id = ${input.challengeId} AND type = ${input.type} AND user_id = ${input.userId} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, user_id ` : await prisma.$queryRaw< @@ -234,7 +234,7 @@ export async function claimAuthChallenge(input: { DELETE FROM auth_challenges WHERE id = ${input.challengeId} AND type = ${input.type} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, user_id `; if (rows.length !== 1) return null; diff --git a/src/lib/db.ts b/src/lib/db.ts index be64fb5b7..9cfff9d20 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,6 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; -import { Pool } from "pg"; +import { Pool, type PoolConfig } from "pg"; import { pinTimeZoneOnConnect, sessionOptionsDisabled, @@ -144,16 +144,32 @@ export function buildSessionOptions(): string | undefined { return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`; } -function createPrismaClient() { - const pool = new Pool({ +export function prismaPoolConfig(): PoolConfig { + return { connectionString: process.env.DATABASE_URL!, max: getPrismaPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), options: buildSessionOptions(), - }); + }; +} + +/** + * The adapter for the process-wide client. The startup options carry the + * UTC pin by default, so the adapter builds its own pool from the config. + * With the options off (a pooler) the pool is built here instead, to pin the + * zone by statement on connect; the adapter then reuses that one pool for + * its whole life, a reconnect included. + */ +export function createPrismaAdapter(): PrismaPg { + const config = prismaPoolConfig(); + if (!sessionOptionsDisabled()) return new PrismaPg(config); + const pool = new Pool(config); pinTimeZoneOnConnect(pool); - const adapter = new PrismaPg(pool, { disposeExternalPool: true }); - return new PrismaClient({ adapter }); + return new PrismaPg(pool); +} + +function createPrismaClient() { + return new PrismaClient({ adapter: createPrismaAdapter() }); } // One client per process, in production too. A production build gives every diff --git a/src/lib/export/measurement-bulk-insert.ts b/src/lib/export/measurement-bulk-insert.ts index e056817a0..adbb848ae 100644 --- a/src/lib/export/measurement-bulk-insert.ts +++ b/src/lib/export/measurement-bulk-insert.ts @@ -123,7 +123,7 @@ function selectExpression( const cast = `${ref}::timestamp(3)`; // Prisma fills both on create when the row does not carry them. return field === "createdAt" || field === "updatedAt" - ? `COALESCE(${cast}, CURRENT_TIMESTAMP)` + ? `COALESCE(${cast}, CURRENT_TIMESTAMP AT TIME ZONE 'UTC')` : cast; } if (kind === "bytea") return `decode(${ref}, 'hex')`; diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 8d562abd0..63f22ec82 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -145,10 +145,14 @@ function windowedMean(filter: string): string { filter, }); } -const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`); -const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`); +const AVG7_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days'`, +); +const AVG30_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, +); const AVG30_LAST_MONTH_SQL = windowedMean( - `m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`, + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days' AND m."measured_at" < (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, ); /** @@ -488,7 +492,7 @@ async function buildFromRollups( m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = $1 - AND m."measured_at" >= NOW() - INTERVAL '90 days' + AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, @@ -718,10 +722,9 @@ async function buildFromLiveAggregate( -- composes from (startOfUtcDay(now - N days) + -- composeWindowedRegression). The expression -- date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL 'N days' - -- is the SQL twin of startOfUtcDay; the trailing AT TIME ZONE 'UTC' - -- re-materialises the naive truncation as a UTC instant so the - -- measured_at (timestamptz) comparison is on the same wall the - -- rollup bucketStart sits on. Without this anchor an account that + -- is the SQL twin of startOfUtcDay. It stays a naive UTC timestamp, + -- like measured_at itself, so the comparison never passes through + -- the session time zone. Without this anchor an account that -- crosses a coverage boundary (warm to cold) saw the boundary day -- flip in/out of the slope window, giving a cache-dependent answer -- to the same request. @@ -729,25 +732,25 @@ async function buildFromLiveAggregate( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS r2_30 FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m JOIN window_stats ws ON ws."type" = m."type" @@ -765,7 +768,7 @@ async function buildFromLiveAggregate( m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = $1 - AND m."measured_at" >= NOW() - INTERVAL '90 days' + AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, diff --git a/src/lib/jobs/__tests__/boss-instance.test.ts b/src/lib/jobs/__tests__/boss-instance.test.ts index 08b70ed83..6b1950999 100644 --- a/src/lib/jobs/__tests__/boss-instance.test.ts +++ b/src/lib/jobs/__tests__/boss-instance.test.ts @@ -70,6 +70,7 @@ describe("web-process pg-boss producer", () => { connectionString: "postgres://healthlog@test/db", max: 2, connectionTimeoutMillis: 20_000, + options: "-c TimeZone=UTC", migrate: false, schedule: false, supervise: false, @@ -111,6 +112,7 @@ describe("worker pg-boss pool", () => { connectionString: "postgres://healthlog@test/db", max: 2, connectionTimeoutMillis: 20_000, + options: "-c TimeZone=UTC", }); }); }); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 3e048ee61..499689940 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -147,7 +147,7 @@ async function claimReaction( ), operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}), message_count = GREATEST(0, message_count - 1), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${arrival.userId} AND date_key = ${current.generationBudgetDateKey} `; diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 0c6927965..e15318865 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -294,7 +294,7 @@ export async function handleRateLimitCleanup( const p = getWorkerPrisma(); try { const result = await p.$executeRaw` - DELETE FROM rate_limits WHERE reset_at < NOW() + DELETE FROM rate_limits WHERE reset_at < (NOW() AT TIME ZONE 'UTC') `; evt.addMeta("rate_limit_cleanup_deleted", result); return jobDone({ deleted: result }); diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts index 09c8a3bc3..0c1108a9e 100644 --- a/src/lib/jobs/step-consolidation-repair.ts +++ b/src/lib/jobs/step-consolidation-repair.ts @@ -274,7 +274,7 @@ export async function enqueueBootTimeStepConsolidationRepair( FROM measurements t WHERE t."type" = 'ACTIVITY_STEPS' AND t."deleted_at" IS NOT NULL - AND t."deleted_at" >= now() - make_interval(days => ${TOMBSTONE_RETENTION_DAYS}) + AND t."deleted_at" >= (NOW() AT TIME ZONE 'UTC') - make_interval(days => ${TOMBSTONE_RETENTION_DAYS}) AND t."source" IN ('GOOGLE_HEALTH', 'FITBIT') AND t."external_id" LIKE 'stats:%' AND NOT EXISTS ( diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index 19cc9364c..9b0178cbf 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -326,7 +326,7 @@ export async function applyAppleHealthDistanceRepair( SET value = value * ${factor}, unit = 'm', sync_version = sync_version + 1, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE id = ANY(${chunk})`; } await auditLog(APPLE_HEALTH_DISTANCE_REPAIR_ACTION, { diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 97c9710f0..c68b9ea35 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -166,7 +166,7 @@ export async function claimManagedGuardianEgressAuthorization( ${delivery.recipientUserId}, ${channel}, ${eventType}, - clock_timestamp() + (clock_timestamp() AT TIME ZONE 'UTC') WHERE EXISTS ( SELECT 1 FROM "account_grants" @@ -177,7 +177,7 @@ export async function claimManagedGuardianEgressAuthorization( AND "revoked_at" IS NULL AND ( "expires_at" IS NULL - OR "expires_at" > clock_timestamp() + OR "expires_at" > (clock_timestamp() AT TIME ZONE 'UTC') ) ) RETURNING "id" diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 6914c167c..3d764b8e8 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -31,14 +31,14 @@ export async function checkRateLimit( const rows = await prisma.$queryRaw<{ count: number; reset_at: Date }[]>` INSERT INTO rate_limits (key, count, reset_at) - VALUES (${key}, 1, NOW() + ${windowInterval}::interval) + VALUES (${key}, 1, (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval) ON CONFLICT (key) DO UPDATE SET count = CASE - WHEN rate_limits.reset_at < NOW() THEN 1 + WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN 1 ELSE rate_limits.count + 1 END, reset_at = CASE - WHEN rate_limits.reset_at < NOW() THEN NOW() + ${windowInterval}::interval + WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval ELSE rate_limits.reset_at END RETURNING count, reset_at @@ -80,7 +80,7 @@ export async function refundRateLimit(key: string): Promise { await prisma.$executeRaw` UPDATE rate_limits SET count = GREATEST(count - 1, 0) - WHERE key = ${key} AND reset_at >= NOW() + WHERE key = ${key} AND reset_at >= (NOW() AT TIME ZONE 'UTC') `; } diff --git a/tests/integration/session-time-zone.test.ts b/tests/integration/session-time-zone.test.ts new file mode 100644 index 000000000..3743de7e8 --- /dev/null +++ b/tests/integration/session-time-zone.test.ts @@ -0,0 +1,252 @@ +/** + * HealthLog works on a database whose default time zone is not UTC. + * + * Stored times are UTC in `timestamp` columns without a zone; Postgres reads + * `now()` through the session time zone. On a shared instance set to + * Europe/Berlin a fresh passkey challenge used to be expired before the + * prompt closed. This file sets the test database's default to Europe/Berlin + * (every connection opened afterwards starts in it) and drives the expiry + * paths through the application's own client, which pins UTC per + * connection. A second block takes the pin away and shows that the raw-SQL + * expiry checks hold on their own. + */ +import { Client } from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { PrismaClient } from "@/generated/prisma/client"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ZONE = "Europe/Berlin"; +const USER_ID = "user-session-time-zone"; + +let databaseName = ""; + +async function rawClient(): Promise { + // No startup options: the connection takes the database default. + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + return client; +} + +async function setDatabaseZone(zone: string | null): Promise { + const client = await rawClient(); + try { + if (!databaseName) { + const { rows } = await client.query<{ name: string }>( + "SELECT current_database() AS name", + ); + databaseName = rows[0].name; + } + await client.query( + zone + ? `ALTER DATABASE "${databaseName}" SET timezone TO '${zone}'` + : `ALTER DATABASE "${databaseName}" RESET timezone`, + ); + } finally { + await client.end(); + } +} + +/** A Prisma client WITHOUT the pin: its sessions run in Europe/Berlin. */ +function unpinnedClient(): PrismaClient { + return new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }), + }); +} + +async function challenge( + db: PrismaClient, + type: string, + expiresInMs = 60_000, +): Promise { + const row = await db.authChallenge.create({ + data: { + userId: USER_ID, + challenge: `material-${type}`, + type, + expiresAt: new Date(Date.now() + expiresInMs), + }, + }); + return row.id; +} + +beforeAll(async () => { + // Before any pooled connection of this file opens. + await setDatabaseZone(ZONE); +}); + +afterAll(async () => { + // The container is shared with every other file of the run. + await setDatabaseZone(null); +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { id: USER_ID, username: "tz-user", email: "tz@example.test" }, + }); +}); + +describe("on a database whose default zone is Europe/Berlin", () => { + it("a connection without the pin really runs in Europe/Berlin", async () => { + const client = await rawClient(); + try { + const { rows } = await client.query<{ TimeZone: string }>( + "SHOW TimeZone", + ); + expect(rows[0].TimeZone).toBe(ZONE); + } finally { + await client.end(); + } + }); + + it("the application's pool runs in UTC and the boot check says so", async () => { + const { runBootTimeZoneCheck } = await import("@/lib/boot/time-zone-check"); + await expect(runBootTimeZoneCheck(getPrismaClient())).resolves.toEqual({ + state: "utc", + timeZone: "UTC", + }); + const unpinned = unpinnedClient(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(runBootTimeZoneCheck(unpinned)).resolves.toEqual({ + state: "mismatch", + timeZone: ZONE, + }); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + await unpinned.$disconnect(); + } + }); + + it("a fresh passkey registration challenge is accepted", async () => { + const { consumeRegistrationChallenge } = await import("@/lib/auth/passkey"); + const { hashToken } = await import("@/lib/auth/hmac"); + const sessionId = "session-tz"; + const id = await challenge( + getPrismaClient(), + `passkey-registration:v1:${hashToken(sessionId)}`, + ); + const claim = await consumeRegistrationChallenge({ + challengeId: id, + userId: USER_ID, + sessionId, + }); + expect(claim.ok).toBe(true); + if (claim.ok) { + // Stamped by the column default: UTC, not Berlin wall time. + expect(Math.abs(claim.createdAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + } + }); + + it("a fresh login challenge is claimed, an expired one is not", async () => { + const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + const fresh = await challenge(getPrismaClient(), "authentication"); + await expect( + claimAuthChallenge({ challengeId: fresh, type: "authentication" }), + ).resolves.not.toBeNull(); + const stale = await challenge(getPrismaClient(), "authentication", -1_000); + await expect( + claimAuthChallenge({ challengeId: stale, type: "authentication" }), + ).resolves.toBeNull(); + }); + + it("a session is valid until it expires and is stamped in UTC", async () => { + const { validateSessionFromCookieValue } = + await import("@/lib/auth/session"); + const { hashToken } = await import("@/lib/auth/hmac"); + const live = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + tokenHash: hashToken("hls_live-secret"), + expiresAt: new Date(Date.now() + 60 * 60_000), + }, + }); + await getPrismaClient().session.create({ + data: { + userId: USER_ID, + tokenHash: hashToken("hls_expired-secret"), + expiresAt: new Date(Date.now() - 60_000), + }, + }); + expect(Math.abs(live.createdAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + await expect( + validateSessionFromCookieValue("hls_live-secret"), + ).resolves.not.toBeNull(); + await expect( + validateSessionFromCookieValue("hls_expired-secret"), + ).resolves.toBeNull(); + }); + + it("the native sign-in handoff sees a session begun inside it as fresh", async () => { + // The handoff link is good only for a session created after it began: + // `session.createdAt >= startedAt`, with `startedAt` read from the + // database clock and `createdAt` stamped by the column default. + const { nativeHandoffDbNow } = + await import("@/lib/auth/native-web-handoff"); + const startedAt = await nativeHandoffDbNow(); + // The driver adapter reads a `timestamptz` as if its offset were +00:00, + // so on a Berlin session this instant came back two hours late. + expect(Math.abs(startedAt.getTime() - Date.now())).toBeLessThan(60_000); + const created = await getPrismaClient().$queryRaw<{ created_at: Date }[]>` + INSERT INTO sessions (id, user_id, token_hash, expires_at) + VALUES ('session-handoff', ${USER_ID}, 'handoff-hash', + (NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour') + RETURNING created_at + `; + const createdAt = created[0].created_at; + expect(createdAt.getTime()).toBeGreaterThanOrEqual(startedAt.getTime()); + expect(createdAt.getTime() - startedAt.getTime()).toBeLessThan(60_000); + }); + + it("a rate-limit window resets one window from now", async () => { + const { checkRateLimit } = await import("@/lib/rate-limit"); + const before = Date.now(); + const result = await checkRateLimit("tz:window", 5, 60_000); + expect(result.allowed).toBe(true); + expect(result.resetAt - before).toBeGreaterThan(55_000); + expect(result.resetAt - before).toBeLessThan(65_000); + }); +}); + +describe("without the per-connection pin", () => { + it("the raw-SQL challenge expiry still holds on a Berlin session", async () => { + const unpinned = unpinnedClient(); + try { + const [{ TimeZone }] = + await unpinned.$queryRawUnsafe<{ TimeZone: string }[]>("SHOW TimeZone"); + expect(TimeZone).toBe(ZONE); + vi.resetModules(); + vi.doMock("@/lib/db", async (importOriginal) => ({ + ...(await importOriginal()), + prisma: unpinned, + })); + const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + const fresh = await challenge(unpinned, "authentication"); + await expect( + claimAuthChallenge({ challengeId: fresh, type: "authentication" }), + ).resolves.not.toBeNull(); + const stale = await challenge(unpinned, "authentication", -1_000); + await expect( + claimAuthChallenge({ challengeId: stale, type: "authentication" }), + ).resolves.toBeNull(); + } finally { + vi.doUnmock("@/lib/db"); + await unpinned.$disconnect(); + } + }); +}); From fca1260b209d274ec58d29701cd888aaaba61717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:34:33 +0200 Subject: [PATCH 03/10] fix(oura): exchange and refresh tokens at Oura's current token endpoint Apps from Oura's current developer portal receive codes from moi.ouraring.com, and the documented api.ouraring.com/oauth/token answers those codes with 400 invalid_request, so connecting failed and every collection read came back 401. Token requests now go to moi.ouraring.com/oauth/v2/ext/oauth-token first. A code works once, so the documented endpoint is never asked first: a code whose callback names the current server goes nowhere else, and only an older app's code or refresh token gets one retry there after a 400, 401 or 404. The callback checks the RFC 9207 iss parameter when present and refuses a foreign issuer. --- .../api/oura/callback/__tests__/route.test.ts | 46 +++++- src/app/api/oura/callback/route.ts | 13 +- src/lib/oura/__tests__/client.test.ts | 130 +++++++++++++++ src/lib/oura/client.ts | 149 ++++++++++++++++-- 4 files changed, 321 insertions(+), 17 deletions(-) diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 07cb60d32..efaf97c1d 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -41,7 +41,12 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); +vi.mock("@/lib/oura/client", async (importOriginal) => ({ + isAcceptedOuraIssuer: ( + await importOriginal() + ).isAcceptedOuraIssuer, + exchangeCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); @@ -65,10 +70,12 @@ function makeReq(opts: { code?: string; state?: string; cookie?: string; + iss?: string; }): NextRequest { const params = new URLSearchParams(); if (opts.code) params.set("code", opts.code); if (opts.state) params.set("state", opts.state); + if (opts.iss) params.set("iss", opts.iss); return { url: `https://app.example/api/oura/callback?${params}`, cookies: { @@ -127,10 +134,11 @@ describe("GET /api/oura/callback", () => { const res = await GET( makeReq({ code: "auth-code", state: "S", cookie: "S" }), ); - expect(exchangeMock).toHaveBeenCalledWith("auth-code", { - clientId: "c", - clientSecret: "s", - }); + expect(exchangeMock).toHaveBeenCalledWith( + "auth-code", + { clientId: "c", clientSecret: "s" }, + null, + ); const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); @@ -139,6 +147,34 @@ describe("GET /api/oura/callback", () => { }); }); +// Oura's current authorization server names itself on the callback +// (RFC 9207); the issuer travels on to pick the token endpoint. +describe("GET /api/oura/callback — issuer", () => { + it("passes Oura's issuer on to the code exchange", async () => { + const iss = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S", iss })); + expect(exchangeMock).toHaveBeenCalledWith( + "c", + { clientId: "c", clientSecret: "s" }, + iss, + ); + expect(res.headers.get("location")).toContain("oura=connected"); + }); + + it("refuses a callback that names a foreign authorization server", async () => { + const res = await GET( + makeReq({ + code: "c", + state: "S", + cookie: "S", + iss: "https://evil.example/oauth", + }), + ); + expect(res.headers.get("location")).toContain("reason=issuer"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.28 — connecting starts a sync straight away. The poll handler has * accepted a single-user payload since it was written; this callback is the diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 553dd9c64..89f4284ba 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -4,7 +4,7 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/oura/client"; +import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -67,11 +67,20 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); + // RFC 9207: an authorization server that names itself must be Oura's. The + // issuer also tells which token endpoint the code belongs to. Older apps + // send none, which is fine. + const iss = searchParams.get("iss"); + if (!isAcceptedOuraIssuer(iss)) { + annotate({ meta: { reason: "issuer" } }); + return ERR("issuer"); + } + try { const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, creds, iss); await prisma.user.update({ where: { id: userId }, diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 1fead0330..d34407fe9 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + OURA_OAUTH_LEGACY_TOKEN_URL, OURA_OAUTH_SCOPE, + OURA_OAUTH_TOKEN_URL, RESILIENCE_LEVELS, derivePeriodDaysFromCyclePhases, exchangeCode, @@ -12,6 +14,7 @@ import { fetchVo2Max, getAuthorizationUrl, getOuraCredentials, + isAcceptedOuraIssuer, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -132,6 +135,133 @@ describe("exchangeCode / refreshAccessToken", () => { }); }); +/** + * Oura moved its authorization server: apps from the current developer + * portal get codes from moi.ouraring.com, which the documented + * api.ouraring.com token endpoint refuses with 400 invalid_request. A code + * works once, so the live endpoint is always asked first. + */ +describe("token endpoint order", () => { + const MOI_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const OK = { + status: 200, + body: { access_token: "a", refresh_token: "r", expires_in: 86400 }, + }; + const urls = (mock: ReturnType) => + mock.mock.calls.map(([url]) => url); + + it("exchanges a code at the live endpoint, sending the secret in the body", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS, MOI_ISS); + expect(OURA_OAUTH_TOKEN_URL).toBe( + "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + const body = String(fetchMock.mock.calls[0]![1]!.body); + expect(body).toContain("grant_type=authorization_code"); + expect(body).toContain("client_id=cid"); + expect(body).toContain("client_secret=csecret"); + }); + + it("never hands a code from the current server to the legacy endpoint", async () => { + const fetchMock = installFetchMock([ + { status: 400, body: { error: "invalid_request" } }, + OK, + ]); + await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({ + upstreamError: "invalid_request", + }); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + expect(urls(fetchMock)).not.toContain(OURA_OAUTH_LEGACY_TOKEN_URL); + }); + + it("asks the legacy endpoint only after the live one refused an older app's code", async () => { + const fetchMock = installFetchMock([ + { status: 401, body: { error: "invalid_client" } }, + OK, + ]); + const t = await exchangeCode("code", CREDS); + expect(t.access_token).toBe("a"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }); + + it("does not try the legacy endpoint once the live one accepted the code", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it("refreshes at the live endpoint first", async () => { + const fetchMock = installFetchMock([OK]); + await refreshAccessToken("r1", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it.each([ + [400, { error: "invalid_request" }], + [404, null], + ])( + "falls back to the legacy endpoint on a %i from the live one", + async (status, body) => { + const fetchMock = installFetchMock([{ status, body }, OK]); + const t = await refreshAccessToken("r1", CREDS); + expect(t.refresh_token).toBe("r"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }, + ); + + it("reports the live endpoint's refusal when both refuse", async () => { + installFetchMock([ + { status: 400, body: { error: "invalid_grant" } }, + { status: 400, body: { error: "invalid_request" } }, + ]); + await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({ + upstreamError: "invalid_grant", + }); + }); + + it("does not fall back on a server error or a rate limit", async () => { + for (const status of [429, 503]) { + const fetchMock = installFetchMock([{ status }, OK]); + await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf( + OuraApiError, + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + vi.unstubAllGlobals(); + } + }); +}); + +describe("isAcceptedOuraIssuer", () => { + it("accepts no issuer and Oura's own servers", () => { + expect(isAcceptedOuraIssuer(null)).toBe(true); + expect( + isAcceptedOuraIssuer( + "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous", + ), + ).toBe(true); + expect(isAcceptedOuraIssuer("https://cloud.ouraring.com")).toBe(true); + }); + + it("refuses anything else", () => { + for (const iss of [ + "https://evil.example/oauth", + "http://moi.ouraring.com/oauth", + "https://moi.ouraring.com.evil.example/", + "not a url", + "", + ]) { + expect(isAcceptedOuraIssuer(iss), iss).toBe(false); + } + }); +}); + describe("fetchReadiness pagination", () => { it("walks next_token across pages", async () => { installFetchMock([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 0460c315f..073c6a858 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -10,7 +10,9 @@ * * Oura specifics: standard authorization-code OAuth WITH refresh tokens (the * sync layer refreshes on 401, persisting BOTH rotated tokens). The token - * endpoint takes client_id/secret in the body. Collections are read by + * endpoint takes client_id/secret in the body (client_secret_post) and lives + * on moi.ouraring.com, with the documented api.ouraring.com endpoint as a + * fallback for older apps. Collections are read by * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; @@ -22,7 +24,63 @@ import { envOr, envValue } from "@/lib/env"; const OURA_API_BASE = "https://api.ouraring.com"; const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; -const OURA_OAUTH_TOKEN_URL = "https://api.ouraring.com/oauth/token"; + +/** + * Oura's live token endpoint. Apps created in the current developer portal + * get their codes from this authorization server (the callback carries + * `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the + * documented `api.ouraring.com/oauth/token` answers those codes with + * `400 invalid_request`. Older apps are served here too. + */ +export const OURA_OAUTH_TOKEN_URL = + "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; + +/** + * The documented endpoint, kept only as a fallback for the older apps the + * live endpoint turns away. Never tried first for a code: an authorization + * code works once (RFC 6749 4.1.2), so a rejected first attempt would burn + * it before the right endpoint saw it. + */ +export const OURA_OAUTH_LEGACY_TOKEN_URL = + "https://api.ouraring.com/oauth/token"; + +/** The origin of Oura's current authorization server. */ +const OURA_MOI_ORIGIN = "https://moi.ouraring.com"; + +/** + * Authorization-server origins an `iss` callback parameter may name + * (RFC 9207). Anything else is a mix-up and refused. + */ +const OURA_ISSUER_ORIGINS = new Set([ + OURA_MOI_ORIGIN, + "https://cloud.ouraring.com", + "https://api.ouraring.com", +]); + +/** + * Check the `iss` the authorization server sent with the callback. Absent is + * fine (older apps never send one); present, it must name one of Oura's own + * authorization servers. + */ +export function isAcceptedOuraIssuer(iss: string | null): boolean { + if (iss === null) return true; + try { + const url = new URL(iss); + return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin); + } catch { + return false; + } +} + +/** True when the code was issued by the current authorization server. */ +function issuedByMoi(iss: string | null | undefined): boolean { + if (!iss) return false; + try { + return new URL(iss).origin === OURA_MOI_ORIGIN; + } catch { + return false; + } +} export interface OuraCredentials { clientId: string; @@ -76,12 +134,18 @@ export interface OuraTokenResponse { scope?: string; } -async function postToken( +interface TokenAttempt { + status: number; + json: unknown; +} + +async function requestToken( + url: string, params: URLSearchParams, verb: string, -): Promise { +): Promise { const start = performance.now(); - const res = await safeFetch(OURA_OAUTH_TOKEN_URL, { + const res = await safeFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -89,7 +153,7 @@ async function postToken( }, body: params.toString(), }); - const json = await res.json().catch(() => null); + const json: unknown = await res.json().catch(() => null); const verdict = classifyOuraResponse(res.status); getEvent()?.addExternalCall({ service: "oura", @@ -98,33 +162,91 @@ async function postToken( status: res.status, error: verdict.classification === "success" ? undefined : verdict.reason, }); + return { status: res.status, json }; +} + +function upstreamError(json: unknown): string | undefined { + if (json === null || typeof json !== "object") return undefined; + const error = (json as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +/** + * Whether the legacy endpoint is worth asking after the live one refused: + * a 400, 401 or 404. The live endpoint may not know an older app's client, + * code or refresh token at all, and its refusal does not tell "unknown here" + * apart from "bad grant" reliably, so every such refusal gets the one retry. + * Server errors and rate limits do not: they say nothing about the grant. + */ +const FALLBACK_STATUSES = new Set([400, 401, 404]); + +function belongsElsewhere(attempt: TokenAttempt): boolean { + return FALLBACK_STATUSES.has(attempt.status); +} + +function tokenResult(attempt: TokenAttempt, verb: string): OuraTokenResponse { + const verdict = classifyOuraResponse(attempt.status); if (verdict.classification !== "success") { throw new OuraApiError({ verb, classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, + upstreamError: upstreamError(attempt.json), }); } // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) // leaves `json` null. Casting it straight to the token type handed callers a // null they then dereferenced, surfacing an unclassified TypeError instead of // a handled integration error. Narrow explicitly and classify as transient. - if (json === null || typeof json !== "object") { + if (attempt.json === null || typeof attempt.json !== "object") { throw new OuraApiError({ verb, classification: "transient", - httpStatus: res.status, + httpStatus: attempt.status, reason: "empty_token_body", }); } - return json as OuraTokenResponse; + return attempt.json as OuraTokenResponse; } +/** + * Ask the live endpoint, then the legacy one when `mayFallBack` allows it and + * the live endpoint refused with 400, 401 or 404. The legacy endpoint is + * never asked first. + */ +async function postToken( + params: URLSearchParams, + verb: string, + mayFallBack: boolean, +): Promise { + const live = await requestToken(OURA_OAUTH_TOKEN_URL, params, verb); + if (mayFallBack && belongsElsewhere(live)) { + const legacy = await requestToken( + OURA_OAUTH_LEGACY_TOKEN_URL, + params, + `${verb}.legacy`, + ); + if (legacy.status >= 200 && legacy.status < 300) { + return tokenResult(legacy, verb); + } + // Neither endpoint took it. The live endpoint's answer is the one that + // describes the grant for every current app, so report that one. + } + return tokenResult(live, verb); +} + +/** + * Exchange an authorization code. `iss` is the callback's issuer parameter: + * a code from the current authorization server goes to the live endpoint + * only, since a second attempt elsewhere could never succeed. Without an + * issuer (an older app) the legacy endpoint is the fallback after the live + * one has refused the code; the live endpoint is always asked first. + */ export async function exchangeCode( code: string, creds: OuraCredentials, + iss?: string | null, ): Promise { return postToken( new URLSearchParams({ @@ -135,9 +257,15 @@ export async function exchangeCode( client_secret: creds.clientSecret, }), "exchangeCode", + !issuedByMoi(iss), ); } +/** + * Refresh an access token: live endpoint first, the legacy endpoint when the + * live one refuses it (a refresh token of an older app). + * A refused refresh does not consume the token, so the retry is safe. + */ export async function refreshAccessToken( refreshToken: string, creds: OuraCredentials, @@ -150,6 +278,7 @@ export async function refreshAccessToken( client_secret: creds.clientSecret, }), "refreshAccessToken", + true, ); } From 12a8ab7c63f2fc4f2b73c88cc90d95e2e0d58a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:37:57 +0200 Subject: [PATCH 04/10] feat(documents): open a PDF or image original in a new browser tab The document sheet gains an Open in new tab action for the types the browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report gets the browser's own zoom and search instead of the small preview. It opens the object URL the sheet already fetched through the app transport: a plain navigation to the serve route carries no record-session assertion and would be refused inside a shared record. Every other type keeps the download only, and the serve route's matrix now pins SVG and HTML to attachment as well. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../[id]/original/__tests__/route.test.ts | 4 ++ .../document-detail-download.test.ts | 38 +++++++++++++++ .../documents/document-detail-sheet.tsx | 48 ++++++++++++++++++- 10 files changed, 95 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8395f39a5..b370a245b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10858,6 +10858,7 @@ "saveError": "Die Änderung konnte nicht gespeichert werden.", "deleteError": "Das Dokument konnte nicht gelöscht werden.", "download": "Herunterladen", + "openInNewTab": "In neuem Tab öffnen", "delete": "Löschen", "titleLabel": "Titel", "titlePlaceholder": "Titel hinzufügen", diff --git a/messages/en.json b/messages/en.json index 164ca09ff..232132002 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10858,6 +10858,7 @@ "saveError": "Couldn't save the change.", "deleteError": "Couldn't delete the document.", "download": "Download", + "openInNewTab": "Open in new tab", "delete": "Delete", "titleLabel": "Title", "titlePlaceholder": "Add a title", diff --git a/messages/es.json b/messages/es.json index 85feaee82..521e07378 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10858,6 +10858,7 @@ "saveError": "No se pudo guardar el cambio.", "deleteError": "No se pudo eliminar el documento.", "download": "Descargar", + "openInNewTab": "Abrir en una pestaña nueva", "delete": "Eliminar", "titleLabel": "Título", "titlePlaceholder": "Añadir un título", diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..c20ce3479 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10858,6 +10858,7 @@ "saveError": "Impossible d'enregistrer la modification.", "deleteError": "Impossible de supprimer le document.", "download": "Télécharger", + "openInNewTab": "Ouvrir dans un nouvel onglet", "delete": "Supprimer", "titleLabel": "Titre", "titlePlaceholder": "Ajouter un titre", diff --git a/messages/it.json b/messages/it.json index 6937f4746..292b56dd7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10858,6 +10858,7 @@ "saveError": "Impossibile salvare la modifica.", "deleteError": "Impossibile eliminare il documento.", "download": "Scarica", + "openInNewTab": "Apri in una nuova scheda", "delete": "Elimina", "titleLabel": "Titolo", "titlePlaceholder": "Aggiungi un titolo", diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..db3908b97 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10858,6 +10858,7 @@ "saveError": "변경 내용을 저장하지 못했어요.", "deleteError": "문서를 삭제하지 못했어요.", "download": "다운로드", + "openInNewTab": "새 탭에서 열기", "delete": "삭제", "titleLabel": "제목", "titlePlaceholder": "제목 입력", diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..dba9b65ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10858,6 +10858,7 @@ "saveError": "Nie udało się zapisać zmiany.", "deleteError": "Nie udało się usunąć dokumentu.", "download": "Pobierz", + "openInNewTab": "Otwórz w nowej karcie", "delete": "Usuń", "titleLabel": "Tytuł", "titlePlaceholder": "Dodaj tytuł", diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index c759ae581..dea3c6a7c 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -240,6 +240,10 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", ["image/heic", "photo.heic"], ["application/xml", "export.xml"], ["application/json", "export.json"], + // Upload refuses markup outright; a row that somehow carries one must + // still never render in-origin, where its script would run. + ["image/svg+xml", "drawing.svg"], + ["text/html", "page.html"], ])( "Class B %s → attachment + octet-stream, never inline", async (mime, name) => { diff --git a/src/components/documents/__tests__/document-detail-download.test.ts b/src/components/documents/__tests__/document-detail-download.test.ts index e3d331e5e..b59f64e1a 100644 --- a/src/components/documents/__tests__/document-detail-download.test.ts +++ b/src/components/documents/__tests__/document-detail-download.test.ts @@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => { ); }); }); + +/** + * "Open in new tab" hands the browser the types it renders itself, so a long + * report gets the browser's zoom and search. It must open the object URL the + * sheet already holds (a bare navigation to the route carries no record-session + * assertion and is refused once the session has been inside a shared record), + * and it must never appear for a type outside the inline serving class. + */ +describe("the detail sheet's open-in-new-tab control", () => { + const control = SHEET.slice( + SHEET.indexOf("function DocumentOpenInTabButton("), + SHEET.indexOf("/** Inline preview for Class A documents"), + ); + + it("exists", () => { + expect(control.length).toBeGreaterThan(0); + expect(SHEET).toMatch(/ { + expect(SHEET).toMatch( + /doc\.servingClass === "inline" \? \(\s*\n\s* { + expect(control).toContain('window.open(readyUrl, "_blank", "noopener")'); + expect(control).not.toContain("/api/documents"); + }); + + it("is disabled until the bytes are in hand, and when they failed", () => { + expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/); + expect(SHEET).toMatch( + / { + if (readyUrl) window.open(readyUrl, "_blank", "noopener"); + }} + > + + + {t("documents.detail.openInNewTab")} + + + ); +} + /** Inline preview for Class A documents; skeleton until the blob paints. */ function InlinePreview({ src, @@ -759,8 +797,8 @@ export function DocumentDetailSheet({ doc ? ( // Delete sits bottom-LEFT, quieted to a text button: the // bottom-right slot reads as the primary/confirm action, and a - // destructive control there is a footgun. Share + Download — the - // safe, expected actions — keep the trailing edge. On phones their + // destructive control there is a footgun. Share, open in a new tab and + // Download, the safe, expected actions, keep the trailing edge. On phones their // labels collapse to icon-only (aria-labelled) so three controls // never overflow the sheet at 360 px; the labels return at `sm+`.
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({ )} + {doc.servingClass === "inline" ? ( + + ) : null} Date: Sun, 11 Oct 2026 12:42:58 +0200 Subject: [PATCH 05/10] fix(google-health): let a progressing backfill slice end quietly and continue Since the backfill resumes per collection, a slice that ends before the whole history is in threw "backfill incomplete" so pg-boss retried it, which logged job.run.failed at error and grew the queue's failure count although nothing had failed. A slice that finished at least one more collection now queues the next slice on the admission queue under its own singleton key and completes. Only a slice that finished nothing new still throws, so a backfill that is actually stuck keeps its error. The import lock keys on the import identity without the slice number, and a slice that finds the import already stamped walks nothing. --- .../__tests__/google-health-backfill.test.ts | 142 ++++++++++++++++-- src/lib/jobs/google-health-backfill.ts | 74 ++++++++- .../jobs/integration-backfill-admission.ts | 37 ++++- src/lib/jobs/integration-backfill-drain.ts | 14 +- .../google-health-backfill-rate-limit.test.ts | 55 ++++++- 5 files changed, 287 insertions(+), 35 deletions(-) diff --git a/src/lib/jobs/__tests__/google-health-backfill.test.ts b/src/lib/jobs/__tests__/google-health-backfill.test.ts index 2773abef9..8afde8dd0 100644 --- a/src/lib/jobs/__tests__/google-health-backfill.test.ts +++ b/src/lib/jobs/__tests__/google-health-backfill.test.ts @@ -7,24 +7,37 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { prismaMock, syncUserGoogleHealthMock, isParkedMock } = vi.hoisted( - () => ({ - prismaMock: { - googleHealthConnection: { - findMany: vi.fn(), - findUnique: vi.fn(async () => null), - update: vi.fn(), - }, +const { + prismaMock, + syncUserGoogleHealthMock, + isParkedMock, + bossSend, + resumeState, +} = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { + findMany: vi.fn(), + findUnique: vi.fn( + async (): Promise<{ + backfillProgress: unknown; + backfillCompletedAt: Date | null; + } | null> => null, + ), + update: vi.fn(), }, - syncUserGoogleHealthMock: vi.fn(), - isParkedMock: vi.fn(async () => false), - }), -); + }, + syncUserGoogleHealthMock: vi.fn(), + isParkedMock: vi.fn(async () => false), + bossSend: vi.fn(), + // Collections the mocked walk "finishes" in this attempt, added to the + // resume set the way `runGoogleHealthCollection` adds them. + resumeState: { finishes: [] as string[], doneBefore: [] as string[] }, +})); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); vi.mock("@/lib/jobs/boss-instance", () => ({ - getGlobalBoss: () => ({ send: vi.fn() }), + getGlobalBoss: () => ({ send: bossSend }), })); vi.mock("@/lib/logging/context", () => ({ @@ -40,12 +53,16 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { vi.mock("@/lib/google-health/sync-core", () => ({ GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", - readGoogleHealthBackfillDone: () => new Set(), + readGoogleHealthBackfillDone: () => new Set(resumeState.doneBefore), runWithGoogleHealthBackfillResume: async ( _userId: string, - _done: Set, + done: Set, fn: () => Promise, - ) => ({ result: await fn(), skipped: [] }), + ) => { + const result = await fn(); + for (const key of resumeState.finishes) done.add(key); + return { result, skipped: [] }; + }, })); vi.mock("@/lib/google-health/sync", () => ({ isGoogleHealthParked: isParkedMock, @@ -63,6 +80,10 @@ beforeEach(() => { vi.clearAllMocks(); isParkedMock.mockResolvedValue(false); prismaMock.googleHealthConnection.update.mockResolvedValue({}); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue(null); + bossSend.mockResolvedValue("job-next"); + resumeState.finishes = []; + resumeState.doneBefore = []; }); describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => { @@ -150,3 +171,92 @@ describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => { expect(prismaMock.googleHealthConnection.update).not.toHaveBeenCalled(); }); }); + +/** + * A long history walks in slices. A slice that finished more collections than + * it started with is progressing, not failing: it queues the next slice and + * ends quietly, without the error line or the failure count. Only a slice that + * finished nothing new is stuck and keeps throwing. + */ +describe("runGoogleHealthBackfillForUser — slices", () => { + it("continues a progressing slice instead of failing it", async () => { + resumeState.doneBefore = ["steps"]; + resumeState.finishes = ["sleep", "weight"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 9, failed: true }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 2), + ).resolves.toEqual({ + imported: 9, + continued: true, + }); + + expect(bossSend).toHaveBeenCalledTimes(1); + const [queue, payload, options] = bossSend.mock.calls[0]!; + expect(queue).toBe("integration-backfill-admission"); + expect(payload).toMatchObject({ + kind: "google-health-backfill", + data: { userId: "u1", slice: 3 }, + }); + // Its own key, so the exclusive policy does not swallow it while this + // slice is still active. + expect(options).toMatchObject({ + singletonKey: "google-health-backfill|u1|slice-3", + startAfter: expect.any(Number), + }); + // Not complete: no stamp. + expect(prismaMock.googleHealthConnection.update).not.toHaveBeenCalled(); + }); + + it("still throws for a slice that finished nothing new", async () => { + resumeState.doneBefore = ["steps"]; + resumeState.finishes = []; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 3, failed: true }); + + await expect(runGoogleHealthBackfillForUser("u1")).rejects.toThrow( + /incomplete/, + ); + expect(bossSend).not.toHaveBeenCalled(); + }); + + it("throws rather than drop the work when the next slice cannot be queued", async () => { + resumeState.finishes = ["sleep"]; + bossSend.mockResolvedValue(null); + syncUserGoogleHealthMock.mockResolvedValue({ imported: 1, failed: true }); + + await expect(runGoogleHealthBackfillForUser("u1")).rejects.toThrow( + /incomplete/, + ); + }); + + it("stamps the marker on the slice that finishes the history", async () => { + resumeState.doneBefore = ["steps", "sleep"]; + resumeState.finishes = ["weight"]; + syncUserGoogleHealthMock.mockResolvedValue({ imported: 4, failed: false }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 5), + ).resolves.toEqual({ + imported: 4, + }); + expect(bossSend).not.toHaveBeenCalled(); + expect( + prismaMock.googleHealthConnection.update.mock.calls[0]![0].data + .backfillCompletedAt, + ).toBeInstanceOf(Date); + }); + + it("walks nothing again once an earlier slice completed the import", async () => { + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + backfillProgress: null, + backfillCompletedAt: new Date(), + }); + + await expect( + runGoogleHealthBackfillForUser("u1", undefined, 4), + ).resolves.toEqual({ + imported: 0, + }); + expect(syncUserGoogleHealthMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 03d428e5e..bb3e66b42 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -15,7 +15,11 @@ import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; +import { + enqueueIntegrationBackfillAdmission, + INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + integrationBackfillSourceOptions, +} from "@/lib/jobs/integration-backfill-admission"; import { readGoogleHealthBackfillDone, runWithGoogleHealthBackfillResume, @@ -68,6 +72,14 @@ export interface GoogleHealthBackfillPayload { * (`runWithGoogleHealthBackfillResume`). The note is cleared once the stamp * lands, and on every reconnect. * + * Runs in slices: an attempt that ends before the whole history is in but + * finished at least one more collection than it started with made progress. + * It is not a failure: it sends the next slice (`slice + 1`) and returns + * `continued`, without the error line or the queue's failure count. Only an + * attempt that finished nothing new throws, because that one is stuck: the + * pg-boss retry and, after it, the next boot take it from there. Progress is + * bounded by the number of collections, so the chain of slices is too. + * * Never beside another run of the same account: the sync takes the account's * lock, waiting a bounded time for an hourly or manual run to finish. If the * lock does not come free the attempt throws without doing anything, and the @@ -76,7 +88,8 @@ export interface GoogleHealthBackfillPayload { export async function runGoogleHealthBackfillForUser( userId: string, shouldStop: () => boolean = () => false, -): Promise<{ imported: number }> { + slice: number = 0, +): Promise<{ imported: number; continued?: boolean }> { if (await isGoogleHealthParked(userId)) { annotate({ action: { @@ -89,9 +102,21 @@ export async function runGoogleHealthBackfillForUser( const connection = await prisma.googleHealthConnection.findUnique({ where: { userId }, - select: { backfillProgress: true }, + select: { backfillProgress: true, backfillCompletedAt: true }, }); + if (connection?.backfillCompletedAt) { + // A slice queued behind the one that finished the import, or a boot + // delivery that raced it: the history is in, nothing to walk again. + annotate({ + action: { + name: "google_health.backfill.already_complete", + details: { slice }, + }, + }); + return { imported: 0 }; + } const done = readGoogleHealthBackfillDone(connection?.backfillProgress); + const doneBefore = done.size; const { result: { imported, failed, busy }, @@ -115,9 +140,46 @@ export async function runGoogleHealthBackfillForUser( } if (failed) { - // Surface through the pg-boss retry path (retryLimit 3, backoff). If the - // failure parked the connection at error_reauth meanwhile, the next - // attempt takes the reauth return above instead of failing again. + // `done` is the resume set this attempt added to as it finished + // collections; a larger set than it started with is progress. + const finished = done.size - doneBefore; + if (finished > 0) { + const boss = getGlobalBoss(); + const sent = boss + ? await enqueueIntegrationBackfillAdmission( + boss, + { + kind: "google-health-backfill", + data: { + userId, + enqueuedAt: new Date().toISOString(), + slice: slice + 1, + }, + }, + INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS, + ) + : null; + if (sent) { + annotate({ + action: { + name: "google_health.backfill.continued", + details: { + imported, + slice, + collectionsFinished: finished, + collectionsDone: done.size, + }, + }, + }); + return { imported, continued: true }; + } + // No follow-up could be queued; fall through to the retry path so the + // work is not dropped. + } + // No collection finished in this attempt: the backfill is stuck, not + // slow. Surface through the pg-boss retry path (retryLimit 3, backoff). + // If the failure parked the connection at error_reauth meanwhile, the + // next attempt takes the reauth return above instead of failing again. throw new Error( `google-health backfill incomplete for user ${userId} — marker not stamped`, ); diff --git a/src/lib/jobs/integration-backfill-admission.ts b/src/lib/jobs/integration-backfill-admission.ts index 2e528ca55..382a65374 100644 --- a/src/lib/jobs/integration-backfill-admission.ts +++ b/src/lib/jobs/integration-backfill-admission.ts @@ -28,6 +28,14 @@ export type IntegrationBackfillData = { userId: string; enqueuedAt: string; provider?: "WHOOP" | "WITHINGS"; + /** + * The continuation number of an import that runs in slices: absent (0) for + * the job discovery or a connect sends, 1, 2, ... for each follow-up a + * slice that made progress sent for the rest. Part of the job's singleton + * key, so the follow-up is not swallowed by the `exclusive` policy while the + * slice that sends it is still active; never part of the import identity. + */ + slice?: number; }; export interface IntegrationBackfillAdmissionPayload { @@ -88,7 +96,12 @@ export function integrationBackfillSourceOptions( }; } -export function integrationBackfillAdmissionSingletonKey( +/** + * The import a job belongs to: one kind for one account (and provider). The + * import lock keys on it, so no two slices or deliveries of one import ever + * run at once. + */ +export function integrationBackfillIdentity( payload: IntegrationBackfillAdmissionPayload, ): string { if (payload.kind === "sleep-timeline-backfill") { @@ -100,17 +113,29 @@ export function integrationBackfillAdmissionSingletonKey( return `${payload.kind}|${payload.data.userId}`; } +/** The job's singleton key: the import identity, plus its slice number. */ +export function integrationBackfillAdmissionSingletonKey( + payload: IntegrationBackfillAdmissionPayload, +): string { + const identity = integrationBackfillIdentity(payload); + const slice = payload.data.slice ?? 0; + return slice > 0 ? `${identity}|slice-${slice}` : identity; +} + export async function enqueueIntegrationBackfillAdmission( boss: BossSender, payload: IntegrationBackfillAdmissionPayload, + startAfterSeconds: number = 0, ): Promise { - return boss.send( - INTEGRATION_BACKFILL_ADMISSION_QUEUE, - payload, - integrationBackfillAdmissionSendOptions(payload), - ); + return boss.send(INTEGRATION_BACKFILL_ADMISSION_QUEUE, payload, { + ...integrationBackfillAdmissionSendOptions(payload), + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), + }); } +/** Breathing room between one slice of an import and the next. */ +export const INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS = 60; + /** * The send options of one admission job. The options ride on the send, not on * `createQueue`, whose `ON CONFLICT DO NOTHING` leaves an existing queue's diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts index 76f91c4d6..68d7f7174 100644 --- a/src/lib/jobs/integration-backfill-drain.ts +++ b/src/lib/jobs/integration-backfill-drain.ts @@ -25,7 +25,7 @@ import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill"; import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill"; import { - integrationBackfillAdmissionSingletonKey, + integrationBackfillIdentity, type IntegrationBackfillAdmissionPayload, } from "@/lib/jobs/integration-backfill-admission"; import { withIntegrationBackfillLock } from "@/lib/jobs/integration-backfill-lock"; @@ -36,6 +36,8 @@ import { withBackgroundEvent } from "@/lib/logging/background"; interface RunCounts { imported: number; + /** Imports that ended a slice with progress and sent the next one. */ + continued: number; removed: number; deleted: number; markers: number; @@ -66,14 +68,16 @@ async function runAdmitted( return; } case "google-health-backfill": { - const { imported } = await runGoogleHealthBackfillForUser( + const { imported, continued } = await runGoogleHealthBackfillForUser( userId, shouldStop, + data.slice ?? 0, ); totals.imported += imported; + if (continued) totals.continued += 1; workerLog( "info", - `[google-health-backfill] user=${userId} imported=${imported}`, + `[google-health-backfill] user=${userId} imported=${imported}${continued ? " continued" : ""}`, ); return; } @@ -152,6 +156,7 @@ export async function drainIntegrationBackfillAdmission( const shouldStop = jobBudget(jobs); const totals: RunCounts = { imported: 0, + continued: 0, removed: 0, deleted: 0, markers: 0, @@ -159,7 +164,7 @@ export async function drainIntegrationBackfillAdmission( }; let alreadyRunning = 0; for (const job of jobs) { - const identity = integrationBackfillAdmissionSingletonKey(job.data); + const identity = integrationBackfillIdentity(job.data); const run = await withIntegrationBackfillLock(identity, () => runAdmitted(job.data, shouldStop, totals), ); @@ -183,6 +188,7 @@ export async function drainIntegrationBackfillAdmission( jobs: jobs.length, already_running: alreadyRunning, imported: totals.imported, + continued: totals.continued, removed: totals.removed, deleted: totals.deleted, markers: totals.markers, diff --git a/tests/integration/google-health-backfill-rate-limit.test.ts b/tests/integration/google-health-backfill-rate-limit.test.ts index 6c2d3e04a..2cf7bd9a1 100644 --- a/tests/integration/google-health-backfill-rate-limit.test.ts +++ b/tests/integration/google-health-backfill-rate-limit.test.ts @@ -168,6 +168,20 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => { }; }); +/** The follow-up slices a progressing backfill queues, recorded. */ +const sent = vi.hoisted(() => ({ + jobs: [] as Array<{ queue: string; data: unknown; options: unknown }>, +})); +vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({ + ...(await importOriginal()), + getGlobalBoss: () => ({ + send: async (queue: string, data: unknown, options: unknown) => { + sent.jobs.push({ queue, data, options }); + return `job-${sent.jobs.length}`; + }, + }), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -212,6 +226,7 @@ beforeEach(async () => { fake.requestsByPath = new Map(); fake.status429 = 0; fake.status401 = 0; + sent.jobs = []; await seed(); }, 120_000); @@ -353,9 +368,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () fake.requestsByPath.get("heart-rate/dataPoints") ?? 0; return heartRatePagesSeen >= 3; }; + // It finished four collections, so it is progressing, not failing: it + // ends quietly and queues the next slice of the import. await expect( runGoogleHealthBackfillForUser(USER, stopAfterThreeHeartRatePages), - ).rejects.toThrow(/incomplete/); + ).resolves.toMatchObject({ continued: true }); + expect(sent.jobs).toHaveLength(1); + expect(sent.jobs[0]).toMatchObject({ + queue: "integration-backfill-admission", + data: { + kind: "google-health-backfill", + data: { userId: USER, slice: 1 }, + }, + }); const afterFirst = await getPrismaClient().googleHealthConnection.findUnique({ @@ -373,9 +398,10 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () ); expect(done).not.toContain("fetchHeartRate"); - // Second attempt: the finished collections are not read again. + // Second attempt, the queued slice: the finished collections are not + // read again. fake.requestsByPath = new Map(); - await runGoogleHealthBackfillForUser(USER); + await runGoogleHealthBackfillForUser(USER, undefined, 1); expect(fake.requestsByPath.get("exercise/dataPoints")).toBeUndefined(); expect(fake.requestsByPath.get("sleep/dataPoints")).toBeUndefined(); @@ -392,6 +418,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", () expect(afterSecond?.backfillProgress).toBeNull(); }); + it("keeps failing a backfill whose slice finished nothing new", async () => { + const { runGoogleHealthBackfillForUser } = + await import("@/lib/jobs/google-health-backfill"); + + // A first slice finishes everything but the heart-rate stream. + const stopAtThirdPage = () => + (fake.requestsByPath.get("heart-rate/dataPoints") ?? 0) >= 3; + await runGoogleHealthBackfillForUser(USER, stopAtThirdPage); + expect(sent.jobs).toHaveLength(1); + + // The next slice cannot get through the heart-rate stream either: no + // collection finishes, so this one is stuck and says so. + fake.requestsByPath = new Map(); + await expect( + runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1), + ).rejects.toThrow(/incomplete/); + expect(sent.jobs).toHaveLength(1); + const row = await getPrismaClient().googleHealthConnection.findUnique({ + where: { userId: USER }, + }); + expect(row?.backfillCompletedAt).toBeNull(); + }); + it("never runs the hourly poll or a manual sync beside a running backfill", async () => { fake.holdAtPage = 2; fake.held = new Promise((resolve) => { From 118494b91d8a1719b07083156c6680e98062ebec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:54:37 +0200 Subject: [PATCH 06/10] test(rollups): stop the summary parity tests going red after midnight UTC The two summary tests seeded readings counting back from the current instant, a few more minutes earlier each, and compared the composed mean with a plain AVG. Between midnight and about 01:15 UTC the earliest minutes slid into the day before, a day gained a reading in another hour, and for pulse, whose day is a mean of hourly means, the composed mean left the plain AVG. The fixtures now count back from noon UTC yesterday, and the expected mean is the one each type documents: AVG, or the mean of hourly-mean days. --- tests/integration/measurement-rollups.test.ts | 135 +++++++++++------- 1 file changed, 87 insertions(+), 48 deletions(-) diff --git a/tests/integration/measurement-rollups.test.ts b/tests/integration/measurement-rollups.test.ts index 6e6613f1a..ba21112fb 100644 --- a/tests/integration/measurement-rollups.test.ts +++ b/tests/integration/measurement-rollups.test.ts @@ -35,6 +35,7 @@ import { } from "@/lib/rollups/measurement-rollups"; import { aggregateBuckets } from "@/lib/rollups/measurement-read"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { windowWeighting } from "@/lib/measurements/day-statistic"; vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -65,6 +66,81 @@ afterEach(() => { vi.restoreAllMocks(); }); +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * The instant the summary fixtures count back from: noon UTC yesterday. + * + * They used to count back from `Date.now()`, with each reading a few more + * minutes earlier than the last. Run between midnight and about 01:15 UTC, + * the earliest of those minutes slid across midnight into the day before, + * so a day gained a reading in another hour, and for a type whose day is a + * mean of hourly means (pulse) the composed mean moved off the plain AVG the + * tests compared it with: the suite went red for an hour every night. Noon + * leaves every offset inside its own day, so the layout is the same at any + * time the suite runs. + */ +function fixtureAnchor(): number { + const now = new Date(); + return ( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + DAY_MS + + 12 * 60 * 60 * 1000 + ); +} + +/** + * Live SQL over the raw rows for the summary's count / min / max / mean, + * with the mean each type documents: a plain AVG over readings, or, for a + * type whose day is a mean of hourly means (`windowWeighting(type) === + * "day"`), the mean of those per-day values. + */ +async function liveSummaryRows(userId: string, since: Date) { + const prisma = getPrismaClient(); + const rows = await prisma.$queryRaw< + Array<{ + type: string; + count: bigint; + min_value: number; + max_value: number; + mean_value: number; + day_mean_value: number; + }> + >` + WITH hourly AS ( + SELECT m."type", date_trunc('day', m."measured_at") AS day, + AVG(m."value") AS v + FROM measurements m + WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since} + GROUP BY m."type", date_trunc('day', m."measured_at"), + date_trunc('hour', m."measured_at") + ), + daily AS ( + SELECT "type", day, AVG(v) AS v FROM hourly GROUP BY "type", day + ), + by_day AS ( + SELECT "type", AVG(v)::double precision AS day_mean_value + FROM daily GROUP BY "type" + ) + SELECT + m."type"::text AS type, + COUNT(*) AS count, + MIN(m."value")::double precision AS min_value, + MAX(m."value")::double precision AS max_value, + AVG(m."value")::double precision AS mean_value, + d.day_mean_value AS day_mean_value + FROM measurements m + JOIN by_day d ON d."type" = m."type" + WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since} + GROUP BY m."type", d.day_mean_value + `; + return rows.map((row) => ({ + ...row, + expectedMean: + windowWeighting(row.type) === "day" ? row.day_mean_value : row.mean_value, + })); +} + describe("measurement rollups — integration", () => { it("writes a DAY rollup row that matches the inserted measurements", async () => { const prisma = getPrismaClient(); @@ -298,8 +374,8 @@ describe("measurement rollups — integration", () => { // type families exercise the per-type composition path. The // values are deliberately fractional so the round2 contract gets // a workout. - const now = Date.now(); - const dayMs = 24 * 60 * 60 * 1000; + const now = fixtureAnchor(); + const dayMs = DAY_MS; const seedRows: Array<{ userId: string; type: "WEIGHT" | "PULSE"; @@ -336,26 +412,7 @@ describe("measurement rollups — integration", () => { // the source of truth — the read-swap must produce the same // count/min/max/mean byte-for-byte. const ninetyDaysAgo = new Date(Date.now() - 90 * dayMs); - const live = await prisma.$queryRaw< - Array<{ - type: string; - count: bigint; - min_value: number; - max_value: number; - mean_value: number; - }> - >` - SELECT - m."type"::text AS type, - COUNT(*) AS count, - MIN(m."value")::double precision AS min_value, - MAX(m."value")::double precision AS max_value, - AVG(m."value")::double precision AS mean_value - FROM measurements m - WHERE m."user_id" = ${user.id} - AND m."measured_at" >= ${ninetyDaysAgo} - GROUP BY m."type" - `; + const live = await liveSummaryRows(user.id, ninetyDaysAgo); const round2 = (n: number) => Math.round(n * 100) / 100; @@ -366,9 +423,10 @@ describe("measurement rollups — integration", () => { expect(summary.count).toBe(Number(liveRow.count)); expect(summary.min).toBe(round2(liveRow.min_value)); expect(summary.max).toBe(round2(liveRow.max_value)); - // The composed weighted mean equals AVG(value) over the same - // rows; both run through `round2`. - expect(summary.mean).toBe(round2(liveRow.mean_value)); + // The composed mean equals the type's documented mean over the same + // rows (AVG, or the mean of hourly-mean days); both run through + // `round2`. + expect(summary.mean).toBe(round2(liveRow.expectedMean)); } }); @@ -741,8 +799,8 @@ describe("measurement rollups — integration", () => { // 60 readings over 30 days, two types, deliberately spread so // anomalies + slopes + windowed avgs all have signal. - const now = Date.now(); - const dayMs = 24 * 60 * 60 * 1000; + const now = fixtureAnchor(); + const dayMs = DAY_MS; const seedRows: Array<{ userId: string; type: "WEIGHT" | "PULSE"; @@ -777,26 +835,7 @@ describe("measurement rollups — integration", () => { // Parallel live aggregation over the same 90-day window using the // exact heavy-aggregate shape the cold-mount fallback runs. const ninetyDaysAgo = new Date(now - 90 * dayMs); - const live = await prisma.$queryRaw< - Array<{ - type: string; - count: bigint; - min_value: number; - max_value: number; - mean_value: number; - }> - >` - SELECT - m."type"::text AS type, - COUNT(*) AS count, - MIN(m."value")::double precision AS min_value, - MAX(m."value")::double precision AS max_value, - AVG(m."value")::double precision AS mean_value - FROM measurements m - WHERE m."user_id" = ${user.id} - AND m."measured_at" >= ${ninetyDaysAgo} - GROUP BY m."type" - `; + const live = await liveSummaryRows(user.id, ninetyDaysAgo); const aggregate = await buildComprehensiveAggregate(user.id); @@ -811,7 +850,7 @@ describe("measurement rollups — integration", () => { expect(summary.count).toBe(Number(liveRow.count)); expect(summary.min).toBe(round2(liveRow.min_value)); expect(summary.max).toBe(round2(liveRow.max_value)); - expect(summary.mean).toBe(round2(liveRow.mean_value)); + expect(summary.mean).toBe(round2(liveRow.expectedMean)); // Narrow aggregate fills the non-composable windowed columns. // Slope tuples and avg7/avg30 are sourced from live SQL even on // the rollup-fresh path so the values are present (not null) as From 7d8ff0f2df14bb41c1196eca39b43a66e3f64ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:09:12 +0200 Subject: [PATCH 07/10] fix(day): keep Escape in the date calendar from closing the whole day On a phone the date's calendar is a sheet over the day's own sheet. In the moment after it opens, before Radix has re-rendered both layers, the day's sheet still holds the Escape listener, so an Escape aimed at the calendar closed the day as well; the day-view spec caught it as a flake on the phone viewport (4 of 10 runs locally). The day's sheet now ignores an Escape that comes from inside another dialog, and the spec waits for the layer handover instead of racing it. The coach open-at-end spec read the thread's height as soon as an element with the thread's slot was visible, but the empty state carries the same slot. It now waits for the loaded thread's frame and all of its answers. --- e2e/coach-open-at-end.spec.ts | 24 +++++++++++++++++++----- e2e/day-view.spec.ts | 19 +++++++++++++++++++ src/components/day/day-layer.tsx | 12 ++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/e2e/coach-open-at-end.spec.ts b/e2e/coach-open-at-end.spec.ts index ef3ef4fd1..a86f0426c 100644 --- a/e2e/coach-open-at-end.spec.ts +++ b/e2e/coach-open-at-end.spec.ts @@ -185,12 +185,26 @@ test.describe("Coach: a saved conversation opens at its end", () => { waitUntil: "domcontentloaded", }); - const thread = page.locator('[data-slot="coach-message-thread"]'); - await expect(thread).toBeVisible({ timeout: 15_000 }); + // The empty state carries the same `coach-message-thread` slot, so the + // thread being visible says nothing about the conversation having + // arrived; the height used to be read from the empty state on a slow + // run. Wait for the frame only a loaded thread renders, and for every + // stored answer in it. + await expect( + page.locator('[data-slot="coach-message-thread-frame"]'), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.locator('[data-slot="coach-assistant-turn"]'), + ).toHaveCount(8, { timeout: 15_000 }); + const thread = page.locator( + '[data-slot="coach-message-thread-frame"] [data-slot="coach-message-thread"]', + ); // The thread scrolls at all: otherwise "at the end" proves nothing. - expect( - await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), - ).toBe(true); + await expect + .poll(() => + thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), + ) + .toBe(true); const chart = page.locator( '[data-slot="coach-result-chart"][data-ref="r1"]', diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 3b99bacd5..0aae88187 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -275,6 +275,25 @@ test.describe("the day view", () => { ), ) .toBe(true); + // On a phone the calendar is a sheet over the day's own sheet. Focus + // arrives before Radix has handed it the Escape key: in between, the day + // underneath still holds it. The handover shows as the day's layer + // turning inert (`pointer-events: none`) once the calendar is the top + // layer; wait for that, not for a fixed time. The docked column is no + // dialog and has no such handover. + if (!desktop) + await expect + .poll(() => + panel(page).evaluate( + (el) => + new Promise((resolve) => + requestAnimationFrame(() => + setTimeout(() => resolve(getComputedStyle(el).pointerEvents)), + ), + ), + ), + ) + .toBe("none"); await page.keyboard.press("Escape"); await expect(picker).toHaveCount(0); await expect(panel(page)).toBeVisible(); diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index d0cfaec05..8c75f2ea3 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -717,6 +717,18 @@ function DaySheet({ event.preventDefault(); returnFocus(); }} + onEscapeKeyDown={(event) => { + // A dialog opened from inside the day (the date's calendar) is + // portaled out of the panel. For a moment after it opens, until + // Radix has re-rendered both layers, the panel still holds the + // Escape listener: an Escape aimed at the calendar then closed the + // whole day. Escape from inside another dialog is never the day's. + const owner = + event.target instanceof Element + ? event.target.closest('[role="dialog"]') + : null; + if (owner && owner !== panelRef.current) event.preventDefault(); + }} className={cn( // Quicker than the primitive's 500 ms, which read as lag on a // layer opened from a tap; no slide at all under reduced motion. From dcb7a0bc094a04b9fd8e04e7a68c711e04acb5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:09:12 +0200 Subject: [PATCH 08/10] fix(ai): count the daily AI budget on the account's own day The token ledger keyed every day on UTC while the daily briefing and the other per-day generations run on the account's local day, so east of UTC the first generations after local midnight were refused against the previous day's spent ceiling. The budget day is now the calendar day in the account's time zone; each request still resolves it once and books its reservation and reconcile on the same row. The medication extraction reads relative phrases against the same local day, the operator's spend card shows the operator's own day, and the limit copy says the budget resets at midnight in the person's time zone. --- docs/api/openapi.yaml | 2 +- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- src/app/api/admin/provider-health/route.ts | 8 ++- src/app/api/ai/test/route.ts | 2 +- .../documents/inbound/[id]/extract/route.ts | 2 +- .../api/documents/inbound/[id]/index/route.ts | 2 +- .../documents/inbound/[id]/suggest/route.ts | 2 +- .../documents/inbound/[id]/summary/route.ts | 2 +- src/app/api/labs/ocr/extract/route.ts | 4 +- src/app/api/medications/extract/route.ts | 7 ++- .../insights/coach-panel/chat-bubble.tsx | 2 +- src/lib/ai/coach/__tests__/budget.test.ts | 43 ++++++++++--- src/lib/ai/coach/budget.ts | 35 +++++++---- src/lib/ai/coach/self-context-questions.ts | 2 +- src/lib/ai/coach/turn/budget.ts | 2 +- src/lib/documents/auto-stage-labs.ts | 2 +- src/lib/documents/fenced-chat.ts | 2 +- src/lib/documents/index-document.ts | 2 +- src/lib/insights/briefing-provider.ts | 4 +- src/lib/insights/status-provider.ts | 2 +- src/lib/jobs/coach-nudge-ai.ts | 2 +- .../jobs/document-content-index-backfill.ts | 2 +- src/lib/jobs/document-summary.ts | 2 +- src/lib/jobs/reaction-line.ts | 2 +- src/lib/openapi/routes/medications/schemas.ts | 2 +- .../coach-budget-reservation.test.ts | 63 ++++++++++++++++++- tests/integration/coach-chat.test.ts | 4 +- tests/integration/document-ai-runs.test.ts | 6 +- .../rate-limit-429-headers.test.ts | 2 +- 35 files changed, 175 insertions(+), 65 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..3d0288869 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -26856,7 +26856,7 @@ components: - ko today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). - Format: `YYYY-MM-DD`. Defaults to the server's UTC day." + Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: diff --git a/messages/de.json b/messages/de.json index b370a245b..c34ec8463 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", "refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.", - "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", "errorDemo": "In der Demo antwortet der Coach nicht.", "errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Gespeichert", "settingsCancel": "Abbrechen", "dailyLimitTitle": "Heute-Limit erreicht", - "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", "cluster": { diff --git a/messages/en.json b/messages/en.json index 232132002..fd91f3c6d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.", "refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.", - "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.", + "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight in your time zone.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", "errorDemo": "The Coach doesn't answer in the demo.", "errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that. Connect your own key or a local model.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Saved", "settingsCancel": "Cancel", "dailyLimitTitle": "Daily limit reached", - "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", + "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight in your time zone.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", "cluster": { diff --git a/messages/es.json b/messages/es.json index 521e07378..487a61a25 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.", "refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud. Reformula tu pregunta dentro de ese marco.", - "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).", + "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche en tu zona horaria.", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", "errorDemo": "En la demo, el Coach no responde.", "errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Guardado", "settingsCancel": "Cancelar", "dailyLimitTitle": "Límite diario alcanzado", - "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", + "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche en tu zona horaria.", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", "cluster": { diff --git a/messages/fr.json b/messages/fr.json index c20ce3479..87d13f4fb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal : mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.", "refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé. Reformule ta question dans ce cadre.", - "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).", + "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit dans ton fuseau horaire.", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", "errorDemo": "Dans la démo, le Coach ne répond pas.", "errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Enregistré", "settingsCancel": "Annuler", "dailyLimitTitle": "Limite quotidienne atteinte", - "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", + "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit dans ton fuseau horaire.", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", "cluster": { diff --git a/messages/it.json b/messages/it.json index 292b56dd7..01f51b19d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log: misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.", "refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute: riformula la domanda in questo perimetro.", - "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).", + "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", "errorDemo": "Nella demo il Coach non risponde.", "errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Salvato", "settingsCancel": "Annulla", "dailyLimitTitle": "Limite giornaliero raggiunto", - "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", + "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", "cluster": { diff --git a/messages/ko.json b/messages/ko.json index db3908b97..f8d8ad8e6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.", "refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.", - "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.", "errorDemo": "데모에서는 코치가 답하지 않아요.", "errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.", @@ -2999,7 +2999,7 @@ "settingsSaved": "저장됨", "settingsCancel": "취소", "dailyLimitTitle": "하루 한도 도달", - "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "providerRateLimitTitle": "제공자 요청 제한", "providerRateLimitBody": "제공자가 일시적으로 요청을 제한하고 있어요. 5분쯤 뒤에 다시 시도해 주세요.", "cluster": { diff --git a/messages/pl.json b/messages/pl.json index dba9b65ea..602f5a443 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2843,7 +2843,7 @@ "coach": { "refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu: pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.", "refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne. Przeformułuj pytanie w tych ramach.", - "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).", + "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy w Twojej strefie czasowej.", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", "errorDemo": "W wersji demo Coach nie odpowiada.", "errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI: połącz własny klucz lub model lokalny.", @@ -2999,7 +2999,7 @@ "settingsSaved": "Zapisano", "settingsCancel": "Anuluj", "dailyLimitTitle": "Osiągnięto dzienny limit", - "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", + "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy w Twojej strefie czasowej.", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", "cluster": { diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 199d99923..bfe9eeffd 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -73,11 +73,13 @@ interface ProviderHealthSummary { const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES]; export const GET = apiHandler(async () => { - await requireAdmin(); + const { user } = await requireAdmin(); annotate({ action: { name: "admin.provider-health.get" } }); - // The UTC day the ledger buckets by — the same key every reservation writes. - const dateKey = buildDateKey(); + // The ledger buckets each account by its own local day; the operator's + // "today" is the day in the operator's own zone, which on a single-zone + // instance is everyone's. + const dateKey = await buildDateKey(user.id); const groups = await prisma.providerHealth.groupBy({ // `lastStatus` joins the grouping key rather than an aggregate: the diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index eb8166511..1a1e25402 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -145,7 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // `admin-key` is the operator's own credential and draws the operator // ceiling; an override names the caller's own key, and every other resolved // type is the user's own egress, so those draw the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(user.id); const reservation = await reserveBudget( user.id, AI_TEST_MAX_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 1fc8338c5..e011909a8 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -183,7 +183,7 @@ async function reserveFor( providerType: ProviderChainType, tokens: number, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, tokens, diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 7c01083fc..514c266ba 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -200,7 +200,7 @@ async function handleVisionIndex( const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, DOCUMENT_INDEX_RESERVE_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 28ac50c64..da9d78fcc 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -122,7 +122,7 @@ async function reserveFor( userId: string, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentAssist.maxTokens, diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 9c2c7c484..c5e293890 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -216,7 +216,7 @@ async function reserveFor( ask: SummaryRequest, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(ask.userId); const reservation = await reserveBudget( ask.userId, budgetFor(ask.mode), diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 94a76ee03..a4e21e9d2 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -157,7 +157,7 @@ async function handleTextExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked provider // egresses on the operator's own key; a BYOK / Codex / local pick runs on the // user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.text, @@ -218,7 +218,7 @@ async function handleVisionExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked vision // provider egresses on the operator's own key; a BYOK / Codex pick runs on // the user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.vision, diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index c8bee510e..aaf949bfb 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise { chain.map((entry) => entry.providerType), ); - const today = todayOverride ?? buildDateKey(); + // The account's own today: "tomorrow" in a description means the + // person's tomorrow, and the same key is the budget day below. + const accountToday = await buildDateKey(userId); + const today = todayOverride ?? accountToday; const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({ text, today, @@ -239,7 +242,7 @@ async function handleExtract(request: NextRequest): Promise { // paying for their own key by the operator's ceiling. `resolveDailyCap` // charges the operator ceiling only when the chain's primary is the // operator's own credential; every other chain gets the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = accountToday; const estimatedTokens = MAX_OUTPUT_TOKENS + Math.ceil((systemPrompt.length + userPrompt.length) / 4); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 9a712d453..dbd8738b2 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -560,7 +560,7 @@ function ChatBubbleImpl({ // v1.4.25 W5 — map server-emitted error codes to specific Coach // i18n keys. Distinct user-quota and provider-rate-limit copy so the - // user understands daily-cap (resets at UTC midnight) vs. transient + // user understands daily-cap (resets at their local midnight) vs. transient // provider load (retry in ~5 min). Codes that have no dedicated // translation fall back to the generic provider-unavailable copy. const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null; diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index 3757daa61..aea8edaa7 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -22,16 +22,45 @@ vi.mock("@/lib/db", () => ({ }, })); +const { zoneOf } = vi.hoisted(() => ({ + zoneOf: vi.fn(async (userId: string) => + userId === "berlin" + ? "Europe/Berlin" + : userId === "new-york" + ? "America/New_York" + : "UTC", + ), +})); +vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf })); + +/** + * The budget day turns at the account's own midnight, the boundary the daily + * briefing and the other per-day generations use. On the UTC day, the first + * generations of a new local day east of UTC were counted against the old + * day's exhausted ceiling. + */ describe("buildDateKey", () => { - it("formats UTC YYYY-MM-DD", () => { - // 2026-05-10T22:30Z is UTC May 10 — Berlin would be May 11 already - const key = buildDateKey(new Date("2026-05-10T22:30:00.000Z")); - expect(key).toBe("2026-05-10"); + it("is the account's local day, not the UTC day", async () => { + // 22:30 UTC on May 10 is already 00:30 on May 11 in Berlin (CEST). + const at = new Date("2026-05-10T22:30:00.000Z"); + await expect(buildDateKey("berlin", at)).resolves.toBe("2026-05-11"); + await expect(buildDateKey("utc", at)).resolves.toBe("2026-05-10"); + expect(zoneOf).toHaveBeenCalledWith("berlin"); }); - it("rolls forward at UTC midnight", () => { - const key = buildDateKey(new Date("2026-05-11T00:01:00.000Z")); - expect(key).toBe("2026-05-11"); + it("turns at local midnight on either side of it", async () => { + // Berlin: 23:59 local on May 10, then 00:01 local on May 11. + await expect( + buildDateKey("berlin", new Date("2026-05-10T21:59:00.000Z")), + ).resolves.toBe("2026-05-10"); + await expect( + buildDateKey("berlin", new Date("2026-05-10T22:01:00.000Z")), + ).resolves.toBe("2026-05-11"); + // West of UTC the local day ends after the UTC one: 02:00 UTC on May 11 + // is still the evening of May 10 in New York. + await expect( + buildDateKey("new-york", new Date("2026-05-11T02:00:00.000Z")), + ).resolves.toBe("2026-05-10"); }); }); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index f1d387e0a..2849190b2 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -9,12 +9,13 @@ * us add per-day analytics ("avg replies per active user") later * without further migrations. * - * `dateKey` is a UTC `YYYY-MM-DD` string. UTC, not Europe/Berlin: the - * job runner that may seed off-hour tasks (planned v1.5) needs a - * boundary that does not jump on DST. Display layers can format with - * the user's timezone. + * `dateKey` is a `YYYY-MM-DD` string: the calendar day in the account's own + * time zone (`buildDateKey`), the same boundary as the daily briefing and + * every other per-day surface. */ import { prisma } from "@/lib/db"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; /** @@ -215,8 +216,9 @@ export interface DailySpend { export async function readDailySpend( userId: string, - dateKey: string = buildDateKey(), + dateKey?: string, ): Promise { + dateKey ??= await buildDateKey(userId); const row = await prisma.coachUsage.findUnique({ where: { userId_dateKey: { userId, dateKey } }, select: { totalTokens: true, operatorTokens: true }, @@ -228,15 +230,24 @@ export async function readDailySpend( } /** - * Build the UTC day-key for a given clock. Defaults to "now". + * The budget day of one account at `at` (default now): the calendar day in + * the account's own time zone. * - * UTC choice: the budget guards spend against the operator's LLM - * bill — the bill cycles at the provider's UTC midnight, so aligning - * the local meter to the same boundary keeps reasoning trivial. + * Up to v1.42.1 this was the UTC day. The daily briefing and the other + * per-day generations run on the account's local day, so in a zone east of + * UTC the first ones of a new local day still counted against the old UTC + * day, whose ceiling the evening had used up: a burst of refusals right + * after local midnight. The day now turns where the person's day turns. + * + * A caller resolves the key once per request or job and hands the same key + * to the reservation and the reconcile, so both land on one row even when + * the call spans midnight. */ -export function buildDateKey(at: Date = new Date()): string { - // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the spend meter follows the provider's UTC billing day (see the doc comment) - return at.toISOString().slice(0, 10); +export async function buildDateKey( + userId: string, + at: Date = new Date(), +): Promise { + return userDayKey(at, await resolveUserTimezone(userId)); } /** diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 86d352c7d..ac0959832 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -240,7 +240,7 @@ export async function deriveClarifyingQuestions( // serialises them on the row's unique key instead. The cap follows the // chain's cost owner, so a user on their own plan is not held to the // operator-cost ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, QUESTIONS_MAX_TOKENS, diff --git a/src/lib/ai/coach/turn/budget.ts b/src/lib/ai/coach/turn/budget.ts index 85a78cc69..603a08019 100644 --- a/src/lib/ai/coach/turn/budget.ts +++ b/src/lib/ai/coach/turn/budget.ts @@ -68,7 +68,7 @@ export async function reserveTurnBudget(args: { // egresses through the operator's own key or account; a chain on the // person's own plan or key gets the generous user-plan ceiling. const dailyCap = resolveDailyCap(chain); - const reqDateKey = buildDateKey(); + const reqDateKey = await buildDateKey(userId); const firstRound = toolMode ? Math.max(1, Math.floor(args.firstRound ?? AI_BUDGETS.coach.maxTokens)) : AI_BUDGETS.coach.maxTokens; diff --git a/src/lib/documents/auto-stage-labs.ts b/src/lib/documents/auto-stage-labs.ts index ef9133ee8..fc694a56e 100644 --- a/src/lib/documents/auto-stage-labs.ts +++ b/src/lib/documents/auto-stage-labs.ts @@ -290,7 +290,7 @@ export async function maybeAutoStageLabFacts( // ceiling under the cost owner the auto-index resolved. // v1.38.19 — auto-staging runs off the upload, with no one waiting // on it: a background surface, so half the day's ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtractText.maxTokens, diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 1af3eb30a..90a834650 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -312,7 +312,7 @@ export async function streamFencedReply( await appendMessage({ conversationId, role: "user", content: message }); // ── Budget reservation (atomic, before the provider call) ── - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentChat.maxTokens, diff --git a/src/lib/documents/index-document.ts b/src/lib/documents/index-document.ts index 9674b2e1d..80c3ff25d 100644 --- a/src/lib/documents/index-document.ts +++ b/src/lib/documents/index-document.ts @@ -242,7 +242,7 @@ async function tryProviderIndex( return { outcome: null, note: null }; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentTranscribe.maxTokens, diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index fb2686927..422ec7fbc 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -93,7 +93,7 @@ export interface RunBriefingCompletionArgs { * a pinned seed would reproduce the phrasing the re-roll exists to vary. */ seed?: number; - /** UTC day-key override; defaults to today. Tests pin it. */ + /** Budget day-key override; defaults to the account's today. Tests pin it. */ dateKey?: string; /** * v1.41 — `daily_briefing` on the calls that may reason (the generation and @@ -122,7 +122,7 @@ export interface RunBriefingCompletionArgs { export async function runBriefingCompletion( args: RunBriefingCompletionArgs, ): Promise { - const dateKey = args.dateKey ?? buildDateKey(); + const dateKey = args.dateKey ?? (await buildDateKey(args.userId)); // v1.41 — one resolution per call: the operator's switch and cap, the payer // from the chain's primary. `undefined` is today's wire. diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index d098fe15c..6646c9771 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -250,7 +250,7 @@ export async function runStatusCompletion( // credential (`admin-openai` / `admin-codex`). A self-hoster on their own key // or a local model is measured against the generous user-plan ceiling, so // their own hardware/plan is never rationed by the operator's bill. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); // Thinking is billed as output, so a reasoning call reserves its thinking // budget too; the reconcile below settles on the reported count. const estimatedTokens = diff --git a/src/lib/jobs/coach-nudge-ai.ts b/src/lib/jobs/coach-nudge-ai.ts index 3df90feeb..4ffb0e27e 100644 --- a/src/lib/jobs/coach-nudge-ai.ts +++ b/src/lib/jobs/coach-nudge-ai.ts @@ -211,7 +211,7 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => { const maxTokens = budget.maxTokens ?? 160; // v1.38.19 — a background surface: half the day's ceiling. const dailyCap = resolveDailyCapFor("job", chain); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(params.userId); // Per-user budget gate (atomic reserve; refunds itself on refusal). const reservation = await reserveBudget( diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 357619313..f355cb7d3 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -268,7 +268,7 @@ export async function runContentIndexBackfillForUser( break outer; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentTranscribe.maxTokens, diff --git a/src/lib/jobs/document-summary.ts b/src/lib/jobs/document-summary.ts index 02f495b81..de889748f 100644 --- a/src/lib/jobs/document-summary.ts +++ b/src/lib/jobs/document-summary.ts @@ -223,7 +223,7 @@ export async function runDocumentSummaryJob( return; } - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentSummary.maxTokens, diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index a30e48d3b..1051aae19 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -592,7 +592,7 @@ export async function runReactionLine( generationClaimId, job.userId, revision, - buildDateKey(), + await buildDateKey(job.userId), // v1.38.19 — a background surface: half the day's ceiling. resolveDailyCapFor("job", chain), resolveCostOwner(chain), diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 83d8de485..8f6bce5d3 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -1036,7 +1036,7 @@ export const medicationExtractRequest = z .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe( - 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', + 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the account\'s own day in its time zone.', ), }) .meta({ diff --git a/tests/integration/coach-budget-reservation.test.ts b/tests/integration/coach-budget-reservation.test.ts index 2b4f28443..0ddc28a1d 100644 --- a/tests/integration/coach-budget-reservation.test.ts +++ b/tests/integration/coach-budget-reservation.test.ts @@ -9,7 +9,7 @@ * prove both contracts against a real Postgres; a mocked client could not * detect a missing atomic increment. */ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -180,3 +180,64 @@ describe("reserveBudget (real Postgres)", () => { expect(row?.totalTokens).toBeGreaterThan(0); }); }); + +/** + * The budget day turns at the account's own midnight, the boundary the daily + * briefing uses. On the UTC day a Berlin account's first generations after + * local midnight still counted against the previous day's spent ceiling. + */ +describe("the budget day is the account's local day", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("opens a fresh day at local midnight while the UTC day is still running", async () => { + const { buildDateKey, reserveBudget } = + await import("@/lib/ai/coach/budget"); + const user = await getPrismaClient().user.create({ + data: { + username: "budget-berlin", + email: "budget-berlin@example.test", + role: "USER", + timezone: "Europe/Berlin", + }, + }); + // 22:30 UTC on 2026-06-19 is 00:30 on 2026-06-20 in Berlin (CEST). + vi.useFakeTimers({ + toFake: ["Date"], + now: new Date("2026-06-19T22:30:00.000Z"), + }); + + // The evening used the whole ceiling, booked to the day that just ended. + await getPrismaClient().coachUsage.create({ + data: { + userId: user.id, + dateKey: "2026-06-19", + totalTokens: 1000, + operatorTokens: 1000, + messageCount: 3, + }, + }); + + const dateKey = await buildDateKey(user.id); + expect(dateKey).toBe("2026-06-20"); + const first = await reserveBudget( + user.id, + 200, + dateKey, + 1000, + "operator", + "job", + ); + expect(first.allowed).toBe(true); + const rows = await getPrismaClient().coachUsage.findMany({ + where: { userId: user.id }, + orderBy: { dateKey: "asc" }, + select: { dateKey: true, totalTokens: true }, + }); + expect(rows).toEqual([ + { dateKey: "2026-06-19", totalTokens: 1000 }, + { dateKey: "2026-06-20", totalTokens: 200 }, + ]); + }); +}); diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 9ae2ae8d9..bafa99d28 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -230,7 +230,9 @@ describe("POST /api/insights/chat — integration", () => { // the reservation is refunded so the ledger stays at the cap. const { userId } = await seedUserWithSession(); const prisma = getPrismaClient(); - const dateKey = new Date().toISOString().slice(0, 10); + // The account's own day, the key the route reserves under. + const { buildDateKey } = await import("@/lib/ai/coach/budget"); + const dateKey = await buildDateKey(userId); // v1.21.0 — the daily cap is now provider-aware: the seeded user resolves // to a user-egress chain, so the applicable ceiling is USER_PLAN_CAP. Seed // the spend at that ceiling so the reservation trips the gate. (Seeding the diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index 2b82d6575..ffa9dbba5 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -483,7 +483,7 @@ describe("the reaper", () => { const { readDailySpend, buildDateKey } = await import("@/lib/ai/coach/budget"); expect( - (await readDailySpend(user.id, buildDateKey())).total, + (await readDailySpend(user.id, await buildDateKey(user.id))).total, ).toBeGreaterThan(0); const { reapAiRuns } = await import("@/lib/documents/ai-runs/store"); @@ -493,7 +493,9 @@ describe("the reaper", () => { timedOut: 0, deleted: 0, }); - expect((await readDailySpend(user.id, buildDateKey())).total).toBe(0); + expect( + (await readDailySpend(user.id, await buildDateKey(user.id))).total, + ).toBe(0); const run = await getPrismaClient().documentAiRun.findUniqueOrThrow({ where: { id: runId }, diff --git a/tests/integration/rate-limit-429-headers.test.ts b/tests/integration/rate-limit-429-headers.test.ts index 87800caef..db4608027 100644 --- a/tests/integration/rate-limit-429-headers.test.ts +++ b/tests/integration/rate-limit-429-headers.test.ts @@ -185,7 +185,7 @@ describe("429 rate-limit headers (real Postgres)", () => { await getPrismaClient().coachUsage.create({ data: { userId: TEST_USER_ID, - dateKey: buildDateKey(), + dateKey: await buildDateKey(TEST_USER_ID), totalTokens: USER_PLAN_CAP, messageCount: 1, }, From 576ff9a44439aedb7ab85cafb6b7eaaa2d7fef5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:15:02 +0200 Subject: [PATCH 09/10] test(documents): pin that Open in new tab hands over the preview's bytes --- e2e/documents.spec.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 8e5a8f76d..a186b6996 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -232,6 +232,45 @@ test.describe("document vault", () => { await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); }); + test("a PDF opens in a new browser tab from the fetched bytes", async ({ + page, + }) => { + // Headless Chromium has no PDF viewer and turns a PDF tab into a + // download, so the tab itself is not observable here. Record what the + // control hands to `window.open` instead, and read the bytes behind it. + await page.addInitScript(() => { + const calls: unknown[][] = []; + (window as unknown as { __opened: unknown[][] }).__opened = calls; + window.open = (...args: unknown[]) => { + calls.push(args); + return null; + }; + }); + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); + await openButton(page, "MRT Knie").click(); + const sheet = page.getByRole("dialog", { name: "MRT Knie" }); + await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); + const open = sheet.locator('[data-slot="document-open-in-tab"]'); + // Enabled once the preview's bytes are in hand. + await expect(open).toBeEnabled(); + if (process.env.E2E_SHOT_DIR) { + await page.screenshot({ + path: `${process.env.E2E_SHOT_DIR}/open-in-tab-${test.info().project.name}.png`, + }); + } + await open.click(); + const opened = await page.evaluate( + () => (window as unknown as { __opened: string[][] }).__opened, + ); + // The object URL the preview already shows, not the serve route: a bare + // navigation would carry no record-session assertion. + const previewSrc = await sheet.locator("iframe").getAttribute("src"); + expect(previewSrc).toMatch(/^blob:/); + expect(opened).toEqual([[previewSrc, "_blank", "noopener"]]); + // The sheet stays where it was. + await expect(sheet).toBeVisible(); + }); + test("doctor flow B: the condition filter reaches the MRT report", async ({ page, }) => { From 7a43131883d1a927e5e752d4f3e79575c481268a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:18:08 +0200 Subject: [PATCH 10/10] fix(db): track the time-zone pin's failure log as background work The pin's SET on connect runs detached; its failure line now registers with the background-task tracker like every other detached writer. The coach thread test reads the new budget reset copy. --- .../coach-panel/__tests__/message-thread.test.tsx | 2 +- src/lib/db-session.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 9e7f0b1b2..3ddcfb18c 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -699,7 +699,7 @@ describe("", () => { />, ); expect(html).toContain( - "Daily limit reached. The budget refreshes at midnight UTC.", + "Daily limit reached. The budget refreshes at midnight in your time zone.", ); // v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights // provider"). Both old and new strings should be absent here when diff --git a/src/lib/db-session.ts b/src/lib/db-session.ts index e9b0e8323..38415033a 100644 --- a/src/lib/db-session.ts +++ b/src/lib/db-session.ts @@ -6,6 +6,8 @@ */ import type { Pool, PoolClient } from "pg"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; + /** * Whether the app may send the libpq `options` startup parameter at all. * @@ -62,8 +64,12 @@ export function timeZoneSessionOptions(): string | undefined { export function pinTimeZoneOnConnect(pool: Pool): void { if (!sessionOptionsDisabled()) return; pool.on("connect", (client: PoolClient) => { - client.query(SET_SESSION_TIME_ZONE).catch((err: unknown) => { - console.error("[db] Could not pin the session time zone:", err); - }); + trackBackgroundTask( + client.query(SET_SESSION_TIME_ZONE).catch((err: unknown) => { + // The boot check reports a session that is not in UTC; this names + // the cause when the statement itself failed. + console.error("[db] Could not pin the session time zone:", err); + }), + ); }); }