Merge branch 'v1422/fixes' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:28:29 +02:00
commit 3c4e053dcd
83 files changed
+1808 -258

No files matched your search

+1 -1
View File
@@ -26952,7 +26952,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:
+20
View File
@@ -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
+5 -2
View File
@@ -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.
+19 -5
View File
@@ -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"]',
+19
View File
@@ -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<string>((resolve) =>
requestAnimationFrame(() =>
setTimeout(() => resolve(getComputedStyle(el).pointerEvents)),
),
),
),
)
.toBe("none");
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(panel(page)).toBeVisible();
+39
View File
@@ -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,
}) => {
+3 -2
View File
@@ -2844,7 +2844,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.",
@@ -3000,7 +3000,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": {
@@ -10879,6 +10879,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",
+3 -2
View File
@@ -2844,7 +2844,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.",
@@ -3000,7 +3000,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": {
@@ -10879,6 +10879,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",
+3 -2
View File
@@ -2844,7 +2844,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.",
@@ -3000,7 +3000,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": {
@@ -10879,6 +10879,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",
+3 -2
View File
@@ -2844,7 +2844,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.",
@@ -3000,7 +3000,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": {
@@ -10879,6 +10879,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",
+3 -2
View File
@@ -2844,7 +2844,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.",
@@ -3000,7 +3000,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": {
@@ -10879,6 +10879,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",
+3 -2
View File
@@ -2844,7 +2844,7 @@
"coach": {
"refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.",
"refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.",
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.",
"errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.",
"errorDemo": "데모에서는 코치가 답하지 않아요.",
"errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.",
@@ -3000,7 +3000,7 @@
"settingsSaved": "저장됨",
"settingsCancel": "취소",
"dailyLimitTitle": "하루 한도 도달",
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.",
"providerRateLimitTitle": "제공자 요청 제한",
"providerRateLimitBody": "제공자가 일시적으로 요청을 제한하고 있어요. 5분쯤 뒤에 다시 시도해 주세요.",
"cluster": {
@@ -10879,6 +10879,7 @@
"saveError": "변경 내용을 저장하지 못했어요.",
"deleteError": "문서를 삭제하지 못했어요.",
"download": "다운로드",
"openInNewTab": "새 탭에서 열기",
"delete": "삭제",
"titleLabel": "제목",
"titlePlaceholder": "제목 입력",
+3 -2
View File
@@ -2844,7 +2844,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.",
@@ -3000,7 +3000,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": {
@@ -10879,6 +10879,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ł",
+2 -1
View File
@@ -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.
+97
View File
@@ -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<string, string> = {
"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 =
/(?<![.\w])(?:NOW\(\)|CURRENT_TIMESTAMP\b|clock_timestamp\(\))|(?:(?<!=)[<>]|=)\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);
}
});
});
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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,
@@ -183,7 +183,7 @@ async function reserveFor(
providerType: ProviderChainType,
tokens: number,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const dateKey = await buildDateKey(userId);
const reservation = await reserveBudget(
userId,
tokens,
@@ -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,
@@ -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) => {
@@ -122,7 +122,7 @@ async function reserveFor(
userId: string,
providerType: ProviderChainType,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const dateKey = await buildDateKey(userId);
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentAssist.maxTokens,
@@ -216,7 +216,7 @@ async function reserveFor(
ask: SummaryRequest,
providerType: ProviderChainType,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const dateKey = await buildDateKey(ask.userId);
const reservation = await reserveBudget(
ask.userId,
budgetFor(ask.mode),
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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")
+5 -2
View File
@@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
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<Response> {
// 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);
@@ -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<typeof import("@/lib/oura/client")>()
).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
+11 -2
View File
@@ -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 },
+12
View File
@@ -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.
@@ -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(/<DocumentOpenInTabButton\s/);
});
it("renders only for the inline serving class", () => {
expect(SHEET).toMatch(
/doc\.servingClass === "inline" \? \(\s*\n\s*<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}/,
);
expect([...SHEET.matchAll(/<DocumentOpenInTabButton\s/g)]).toHaveLength(1);
});
it("opens the fetched object URL, never the route, without an opener", () => {
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(
/<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}\s+unavailable=\{inlineFailed\}/,
);
});
});
@@ -23,6 +23,7 @@ import {
ArrowRight,
Check,
Download,
ExternalLink,
FlaskConical,
Pencil,
Plus,
@@ -199,6 +200,43 @@ function DocumentDownloadButton({
);
}
/**
* Open the stored original in a new browser tab, for the types the browser
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
* long report gets the browser's own zoom and search.
*
* It opens the object URL the sheet already holds rather than the route: a
* plain navigation carries no record-session assertion, so the fence would
* refuse it once the session has been inside a shared record. The bytes are
* already in hand, so the tab opens inside the click and no popup blocker
* steps in. A type outside the inline class never gets the control.
*/
function DocumentOpenInTabButton({
readyUrl,
unavailable,
}: {
readyUrl: string | null;
unavailable: boolean;
}) {
const { t } = useTranslations();
return (
<Button
variant="outline"
data-slot="document-open-in-tab"
disabled={readyUrl === null || unavailable}
aria-label={t("documents.detail.openInNewTab")}
onClick={() => {
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
}}
>
<ExternalLink className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.openInNewTab")}
</span>
</Button>
);
}
/** 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+`.
<div className="flex w-full items-center justify-between gap-2">
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({
</span>
</Button>
)}
{doc.servingClass === "inline" ? (
<DocumentOpenInTabButton
readyUrl={inlineSrc}
unavailable={inlineFailed}
/>
) : null}
<DocumentDownloadButton
documentId={doc.id}
filename={doc.filename}
@@ -699,7 +699,7 @@ describe("<MessageThread>", () => {
/>,
);
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
@@ -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;
+11
View File
@@ -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.
+37 -2
View File
@@ -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 };
+36 -7
View File
@@ -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");
});
});
+27 -16
View File
@@ -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<DailySpend> {
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<string> {
return userDayKey(at, await resolveUserTimezone(userId));
}
/**
@@ -321,12 +332,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 +464,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 +483,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}
`;
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+7 -7
View File
@@ -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.
@@ -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'\))? -/,
);
});
});
});
+2 -2
View File
@@ -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
+16 -13
View File
@@ -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<SummariesSlice> {
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"
+3 -3
View File
@@ -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;
@@ -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");
});
});
+75
View File
@@ -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<T = unknown>(query: string): Promise<T>;
}
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<TimeZoneCheckOutcome> {
try {
const rows =
await client.$queryRawUnsafe<Array<Record<string, unknown>>>(
"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 <name> 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<TimeZoneCheckOutcome> {
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;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 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";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
/**
* 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) => {
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);
}),
);
});
}
+37 -21
View File
@@ -1,5 +1,19 @@
import { Prisma, PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool, type PoolConfig } 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,17 +141,35 @@ 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({
export function prismaPoolConfig(): PoolConfig {
return {
connectionString: process.env.DATABASE_URL!,
max: getPrismaPoolMax(),
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
options: buildSessionOptions(),
});
return new PrismaClient({ adapter });
};
}
/**
* 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);
return new PrismaPg(pool);
}
function createPrismaClient() {
return new PrismaClient({ adapter: createPrismaAdapter() });
}
// One client per process, in production too. A production build gives every
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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')`;
+2 -2
View File
@@ -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<RunRawWithFallbackResult> {
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.
+16 -13
View File
@@ -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
`,
+1 -1
View File
@@ -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 =
@@ -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",
});
});
});
@@ -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<string>(),
readGoogleHealthBackfillDone: () => new Set<string>(resumeState.doneBefore),
runWithGoogleHealthBackfillResume: async (
_userId: string,
_done: Set<string>,
done: Set<string>,
fn: () => Promise<unknown>,
) => ({ 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();
});
});
@@ -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", () => {
+4
View File
@@ -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;
}
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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}
`;
@@ -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,
+1 -1
View File
@@ -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,
+68 -6
View File
@@ -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`,
);
+31 -6
View File
@@ -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<string | null> {
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
+10 -4
View File
@@ -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,
+6 -1
View File
@@ -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<T>(
connectionString: string | undefined = process.env.DATABASE_URL,
options: JobLockOptions = {},
): Promise<GuardedRun<T>> {
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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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 });
+1 -1
View File
@@ -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 (
@@ -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, {
+2 -2
View File
@@ -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"
@@ -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({
+130
View File
@@ -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<typeof installFetchMock>) =>
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([
+139 -10
View File
@@ -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<OuraTokenResponse> {
): Promise<TokenAttempt> {
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<OuraTokenResponse> {
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<OuraTokenResponse> {
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,
);
}
+4 -4
View File
@@ -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<void> {
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')
`;
}
@@ -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 },
]);
});
});
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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 },
@@ -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<typeof import("@/lib/jobs/boss-instance")>()),
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<void>((resolve) => {
+87 -48
View File
@@ -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
@@ -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,
},
+252
View File
@@ -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<Client> {
// 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<void> {
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<string> {
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<typeof import("@/lib/db")>()),
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();
}
});
});