Files
HealthLog/src/lib/analytics/request-clock-sql.ts
T
Marc-André Bombeck 9cc77c79fc fix(analytics): cut every summary window on the request's clock
The summaries slice and the comprehensive aggregator took their JS windows
(the DAY-bucket bounds, the rollup slope anchors, lastSeen staleness) from
the process clock and their SQL windows (avg7, avg30, the month before, the
median, the 90-day canonical pick, the cold-path slope evidence) from
NOW(), the database's clock. One response mixed two clocks, so a reading
on a window's edge fell in or out with whichever host answered, and with
the process clock pinned the analytics parity walk changed its avg7 with
the hour it ran at.

Each read now takes one `now` and binds it into its statements
(`request-clock-sql.ts`): `$N::timestamp`, which keeps the driver's
offset-free UTC string as written in any session zone. The canonical-pick
helper takes its window as an instant plus an interval instead of reading
NOW(); the series and calendar readers anchor it on their own `since`, the
BP-in-target read on its 365-day start. The window-mean and live-slope
columns the two aggregates each spelled out now come from one builder.

The streak's local day series read its bounds through `::timestamptz`,
which follows the session zone: outside a UTC session today's reading fell
out of the streak. It reads them as UTC first.

The parity walks for analytics, the dashboard summary and the
comprehensive overview pin a fixed day instead of today: their records are
laid out from the anchor day, so the chronotype's work and free nights and
the weight-by-weekday points moved with the weekday the walk ran on. The
snapshots are unchanged. A new suite pins the clock to four times of day,
far from the database clock, with readings ten minutes either side of
every edge, on both read paths, in a UTC and an Auckland session.
2026-10-24 12:14:33 +02:00

89 lines
3.5 KiB
TypeScript

/**
* The request's clock inside a live aggregate.
*
* A summary read cuts some of its windows in JS (`startOfUtcDay(now - 7 d)`
* for the rollup slope, the `bucketStart` bound of the DAY buckets, the
* staleness of `lastSeenAt`) and others in SQL (the 7- and 30-day means, the
* 90-day canonical pick, the median). Those used to read `NOW()`, the
* database's clock, so one response mixed two clocks: a reading on a window's
* edge fell in or out with whichever host answered, and a process whose clock
* is pinned (a test, a replayed request) saw its SQL windows cut hours or
* days away from its JS ones. Every window is now cut against the one `now`
* the request took, bound as a parameter.
*
* `measured_at` is a naive UTC timestamp. The driver sends a `Date` parameter
* as an offset-free UTC string, so `$N::timestamp` keeps it as written,
* whatever the session time zone; `::timestamptz` would read it in the
* session zone and shift every bound outside UTC.
*/
import { windowMeanSql } from "@/lib/measurements/day-mean";
const BOUND_INSTANT_RE = /^\$[1-9][0-9]?::timestamp$/;
const INSTANT_LITERAL_RE =
/^'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}'::timestamp$/;
/** The `Date` bound at `$param`, as the naive UTC timestamp `measured_at` holds. */
export function boundInstantSql(param: number): string {
if (!Number.isInteger(param) || param < 1 || param > 99) {
throw new Error(`parameter index out of range for SQL splice: ${param}`);
}
return `$${param}::timestamp`;
}
/**
* `at` as a naive UTC timestamp literal, for a fragment that several
* statements splice at different parameter positions (`seriesRowsFrom`). It
* is written from the `Date` itself, never from input, and checked against a
* closed format at every splice point.
*/
export function instantLiteralSql(at: Date): string {
if (Number.isNaN(at.getTime())) {
throw new Error("invalid instant for SQL splice");
}
return `'${at.toISOString().slice(0, 23).replace("T", " ")}'::timestamp`;
}
/** Refuse anything but a `boundInstantSql` / `instantLiteralSql` expression. */
export function assertInstantSql(sql: string): string {
if (!BOUND_INSTANT_RE.test(sql) && !INSTANT_LITERAL_RE.test(sql)) {
throw new Error(`unsafe instant expression for SQL splice: ${sql}`);
}
return sql;
}
function windowedMean(filter: string): string {
return windowMeanSql({
typeColumn: 'm."type"',
value: 'm."value"',
weight: "m.day_weight",
filter,
});
}
/**
* The 7-day window mean over canonical readings carrying a `day_weight` (see
* `day-mean.ts`): the mean of the day values for an hourly-mean type (pulse),
* the unchanged `AVG(value)` otherwise. Days are UTC days, the frame the
* canonical-source pick and the rollup tier bucket by.
*/
export function avg7Sql(now: string): string {
return windowedMean(
`m."measured_at" >= ${assertInstantSql(now)} - INTERVAL '7 days'`,
);
}
/**
* `avg7`, `avg30` and `avg30_last_month` (days [30, 60) back, the tile's
* delta callout) as SELECT columns over rows aliased `m`, grouped per type.
*/
export function windowMeanColumnsSql(now: string): string {
const at = assertInstantSql(now);
const avg30 = windowedMean(`m."measured_at" >= ${at} - INTERVAL '30 days'`);
const lastMonth = windowedMean(
`m."measured_at" >= ${at} - INTERVAL '60 days' AND m."measured_at" < ${at} - INTERVAL '30 days'`,
);
return `${avg7Sql(at)}::double precision AS avg7,
${avg30}::double precision AS avg30,
${lastMonth}::double precision AS avg30_last_month`;
}