Merge branch 'release/v1.45.0' into feat/v145-backup

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 09:59:37 +02:00
commit 0cefac9d68
94 files changed
+14850 -658

No files matched your search

@@ -1,14 +1,13 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 125,
"total": 148250,
"count": 124,
"total": 147278,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
"app/api/insights/generate/route.ts": 1209,
"app/api/measurements/batch/route.ts": 1576,
"app/api/measurements/batch/route.ts": 1569,
"app/api/measurements/route.ts": 938,
"app/api/measurements/series/route.ts": 879,
"app/api/medications/[id]/route.ts": 1281,
"app/api/medications/intake/bulk/route.ts": 1044,
"app/api/workouts/batch/route.ts": 971,
@@ -52,11 +51,11 @@
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 1094,
"lib/ai/prompts/insight-generator.ts": 1121,
"lib/ai/provider.ts": 1644,
"lib/ai/provider.ts": 1632,
"lib/ai/safety/outbound-screen.ts": 1463,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1314,
"lib/analytics/summaries-slice.ts": 1248,
"lib/api-handler.ts": 1978,
"lib/auth/session.ts": 811,
"lib/cache/server-cache.ts": 851,
@@ -107,10 +106,10 @@
"lib/jobs/reminder/register-status.ts": 878,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/rich-reads.ts": 1468,
"lib/mcp/rich-reads.ts": 1467,
"lib/mcp/tools.ts": 2654,
"lib/measurements/dense-intraday-retention.ts": 1057,
"lib/measurements/import-apple-health-export.ts": 1729,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 978,
"lib/medications/scheduling/recurrence.ts": 1108,
"lib/medications/scheduling/slot-upsert.ts": 926,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 410,
"total": 148267,
"total": 148056,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -38,10 +38,10 @@
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 240,
"app/api/measurements/[id]/route.ts :: apiHandler(arg) #2": 203,
"app/api/measurements/batch/route.ts :: postBatch": 1277,
"app/api/measurements/batch/route.ts :: postBatch": 1262,
"app/api/measurements/route.ts :: apiHandler(arg)": 286,
"app/api/measurements/route.ts :: postMeasurement": 377,
"app/api/measurements/series/route.ts :: apiHandler(arg)": 670,
"app/api/measurements/series/route.ts :: apiHandler(arg)": 549,
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
"app/api/medications/[id]/intake/[eventId]/route.ts :: apiHandler(arg)": 390,
"app/api/medications/[id]/intake/route.ts :: postIntake": 607,
@@ -299,7 +299,7 @@
"lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256,
"lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206,
"lib/analytics/score/reader.ts :: computeUserHealthScore": 324,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 441,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 377,
"lib/api-handler.ts :: apiHandler": 207,
"lib/api-handler.ts :: apiHandler > wrapped": 201,
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
@@ -389,7 +389,7 @@
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay > foldOnce": 212,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative": 344,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative > writeDay": 205,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1256,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323,
"lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268,
"lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2959,
"total": 10525,
"total": 10524,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1885,7 +1885,7 @@
"lib/analytics/sleep-night.ts": 3,
"lib/analytics/source-priority.ts": 2,
"lib/analytics/source-rank-sql.ts": 3,
"lib/analytics/summaries-slice.ts": 37,
"lib/analytics/summaries-slice.ts": 36,
"lib/analytics/trend-delta.ts": 1,
"lib/analytics/trends.ts": 6,
"lib/analytics/value-bands.ts": 1,
@@ -79,6 +79,8 @@ const WRITE_SITES: Record<string, string> = {
"a primitive; every caller runs the tail over the verdicts' dirty identities",
"lib/measurements/import-apple-health-export.ts":
"the import worker refolds the import's span and re-warms every imported type",
"lib/measurements/import-unchanged-rows.ts":
"the Apple export import's natural-key rescue, inside the import; its worker runs both legs once over the whole import",
"lib/import/health-connect/import.ts":
"the Health Connect import worker refolds the import's span and re-warms every imported type",
"lib/export/measurements-restore.ts":
@@ -93,7 +93,6 @@ const HELPER: Entry = { helper: true };
/** Files with a database mean over values. */
const SQL_READERS: Record<string, Entry> = {
"app/api/measurements/series/route.ts": HELPER,
"lib/ai/coach/tools/availability.ts": HELPER,
"lib/analytics/summaries-slice.ts": HELPER,
"lib/doctor-report/dense-buckets.ts": HELPER,
@@ -102,6 +101,8 @@ const SQL_READERS: Record<string, Entry> = {
// The dry run's hour means, weighed by both methods through the helper.
"lib/jobs/pulse-day-mean.ts": HELPER,
"lib/measurements/daily-series-read.ts": HELPER,
// The series route's long-window dense read.
"lib/measurements/dense-series-read.ts": HELPER,
"lib/rollups/measurement-rollups.ts": HELPER,
"lib/rollups/tiered-context.ts": HELPER,
"lib/insights/correlation-channel-series.ts": {
@@ -576,15 +576,21 @@ describe("a batch of duplicates only (v1.42)", () => {
});
describe("cache invalidation by origin (v1.42)", () => {
// Every call also says how far back the batch reached (`touchedFrom`), so
// closed blocks of older readings stay cached.
it.each([
["foreground", { evict: true }],
["manual", { evict: true }],
["background", undefined],
["push", undefined],
[undefined, undefined],
["background", {}],
["push", {}],
[undefined, {}],
])("syncTrigger %s → %o", async (trigger, expected) => {
await post([weight(`uuid-${trigger}`, new Date(NOW - DAY))], trigger);
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, expected);
const at = new Date(NOW - DAY);
await post([weight(`uuid-${trigger}`, at)], trigger);
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, {
...expected,
touchedFrom: at,
});
});
it("evicts for a background batch that wrote a hand-entered reading", async () => {
@@ -599,6 +605,7 @@ describe("cache invalidation by origin (v1.42)", () => {
);
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, {
evict: true,
touchedFrom: new Date(NOW - DAY),
});
});
});
+78 -85
View File
@@ -66,6 +66,14 @@ import {
oppositeMergeSource,
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import {
externalKeyOf,
onceInBatch,
readStoredSampleIdentities,
readStoredStatsRows,
sampleIsStoredDuplicate,
statsWriteUnchanged,
} from "@/lib/measurements/batch-stored-rows";
import {
lockMeasurementUser,
reconcileExternalMeasurement,
@@ -643,6 +651,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
let insertedCount = 0;
let updatedCount = 0;
/**
* `stats:` re-posts reported `updated` with nothing to write (see
* `isUnchangedStatsRow`). Counted in the response's `updated` like before,
* and in nothing that starts work.
*/
let unchangedStatsCount = 0;
let duplicateCount = 0;
let failedCount = 0;
// v1.44 — subset of `insertedCount` written by the set-based insert.
@@ -769,82 +783,52 @@ async function postBatch(request: NextRequest): Promise<Response> {
// before this one is reached, and then the reconciler has to decide.
// `stats:` rows are never prefiltered (overwrite semantics), and neither
// is a value the reconciler's own plausibility gate would refuse.
const externalKeyCounts = new Map<string, number>();
const naturalKeyCounts = new Map<string, number>();
const naturalKeyOf = (
type: unknown,
source: unknown,
measuredAt: Date,
sleepStage: unknown,
) => `${type}::${source}::${measuredAt.getTime()}::${sleepStage ?? "null"}`;
for (const p of prepared) {
const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
externalKeyCounts.set(
externalKey,
(externalKeyCounts.get(externalKey) ?? 0) + 1,
);
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
p.row.measuredAt as Date,
p.row.sleepStage,
);
naturalKeyCounts.set(
naturalKey,
(naturalKeyCounts.get(naturalKey) ?? 0) + 1,
);
}
const sampleExternalIds = [
const onceInThisBatch = onceInBatch(prepared.map((p) => p.row));
const existingByExternalKey = await readStoredSampleIdentities(
prisma,
user.id,
[
...new Set(
prepared
.map((p) => p.row.externalId as string)
.filter((externalId) => !isStatsExternalId(externalId)),
),
],
);
const isExactStoredDuplicate = (p: Prepared): boolean =>
sampleIsStoredDuplicate(
existingByExternalKey.get(
externalKeyOf(p.row.type, p.row.source, p.row.externalId),
),
p.row,
) &&
onceInThisBatch(p.row) &&
classifyRangeRejection(
p.row.type as MeasurementType,
p.row.value as number,
) === null;
// An unchanged `stats:` re-post is reported `updated` without a write
// (see `batch-stored-rows.ts`), under the same once-per-batch key rule.
const storedStats = await readStoredStatsRows(prisma, user.id, [
...new Set(
prepared
.map((p) => p.row.externalId as string)
.filter((externalId) => !isStatsExternalId(externalId)),
.filter((externalId) => isStatsExternalId(externalId)),
),
];
const existingByExternalKey = new Map<
string,
{ measuredAt: Date; sleepStage: unknown }
>();
if (sampleExternalIds.length > 0) {
const existing = await prisma.measurement.findMany({
where: { userId: user.id, externalId: { in: sampleExternalIds } },
select: {
type: true,
source: true,
externalId: true,
measuredAt: true,
sleepStage: true,
},
});
for (const row of existing) {
existingByExternalKey.set(
`${row.type}::${row.source}::${row.externalId}`,
{ measuredAt: row.measuredAt, sleepStage: row.sleepStage },
);
}
}
function isExactStoredDuplicate(p: Prepared): boolean {
const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
const stored = existingByExternalKey.get(externalKey);
if (!stored) return false;
const measuredAt = p.row.measuredAt as Date;
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
measuredAt,
p.row.sleepStage,
]);
const isUnchangedStatsRow = (p: Prepared): boolean =>
onceInThisBatch(p.row) &&
classifyRangeRejection(
p.row.type as MeasurementType,
p.row.value as number,
) === null &&
statsWriteUnchanged(
storedStats.get(
externalKeyOf(p.row.type, p.row.source, p.row.externalId),
),
desiredFor(p),
);
return (
stored.measuredAt.getTime() === measuredAt.getTime() &&
(stored.sleepStage ?? null) === (p.row.sleepStage ?? null) &&
externalKeyCounts.get(externalKey) === 1 &&
naturalKeyCounts.get(naturalKey) === 1 &&
classifyRangeRejection(
p.row.type as MeasurementType,
p.row.value as number,
) === null
);
}
// v1.42 — the `folded_window` guard (`folded-window.ts`). A raw Apple
// Health sample of a dense or mean type, old enough to have been folded,
@@ -1063,6 +1047,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
if (statsRow && isUnchangedStatsRow(p)) {
results[p.index] = { index: p.index, status: "updated" };
unchangedStatsCount++;
pushMergeCandidate(p);
continue;
}
if (!statsRow && hasSameReadingSibling(p)) {
results[p.index] = {
index: p.index,
@@ -1093,16 +1084,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
const naturalKey = naturalKeyOf(
p.row.type,
p.row.source,
p.row.measuredAt as Date,
p.row.sleepStage,
);
if (
externalKeyCounts.get(key) === 1 &&
naturalKeyCounts.get(naturalKey) === 1
) {
if (onceInThisBatch(p.row)) {
pendingInserts.push(p);
pushMergeCandidate(p);
continue;
@@ -1311,7 +1293,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
details: {
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
updated: updatedCount + unchangedStatsCount,
duplicates: duplicateCount,
skipped: skipped.length,
failed: failedCount,
@@ -1449,7 +1431,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
meta: {
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
updated: updatedCount + unchangedStatsCount,
stats_unchanged: unchangedStatsCount,
duplicates: duplicateCount,
skipped: skipped.length,
// v1.32.1 (issue #585) — per-reason skip breakdown; see the
@@ -1497,7 +1480,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
syncTrigger === "foreground" ||
syncTrigger === "manual" ||
wroteManualRow;
invalidateUserMeasurements(user.id, evict ? { evict: true } : undefined);
// Every row this batch created, changed or retired is in
// `writtenIdentities`, so its earliest `measuredAt` bounds what changed:
// closed blocks of older readings (the pre-fold remainder, the day
// view's past history blocks) stay cached across the sync.
const touchedFrom = new Date(
Math.min(...writtenIdentities.map((w) => w.measuredAt.getTime())),
);
invalidateUserMeasurements(user.id, {
...(evict ? { evict: true } : {}),
...(Number.isFinite(touchedFrom.getTime()) ? { touchedFrom } : {}),
});
// v1.37.19 — shared post-mutation tail for every distinct
// (type, day) the batch touched. Updates participate as well as
@@ -1563,7 +1556,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
const response = apiSuccess({
processed: entries.length,
inserted: insertedCount + hourInsertedCount,
updated: updatedCount + hourUpdatedCount,
updated: updatedCount + unchangedStatsCount + hourUpdatedCount,
duplicates: duplicateCount,
failed: failedCount,
skipped,
@@ -21,6 +21,51 @@ vi.mock("@/lib/db", () => ({
},
}));
// The route's dense read is one statement (`dense-series-read.ts`, held to
// the two statements it replaced by an integration parity test). Here it
// issues those two statements again, so the route's shaping of buckets and
// figures is pinned against the same queued results as before.
vi.mock("@/lib/measurements/dense-series-read", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/measurements/dense-series-read")
>();
const { usesHourlyMeanDay } =
await import("@/lib/measurements/day-statistic");
return {
...actual,
readDenseSeries: async (args: {
userId: string;
type: Parameters<typeof usesHourlyMeanDay>[0];
grain: "day" | "hour";
userTz: string;
since: Date;
seriesRows: string;
}) => {
const { prisma } = await import("@/lib/db");
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
const bucketRows = await prisma.$queryRawUnsafe(
actual.denseBucketsSql(
hourWeighted,
`${args.seriesRows} WHERE m."measured_at" >= $4`,
),
args.userId,
args.grain,
args.userTz,
args.since,
);
const [rawAgg] = (await prisma.$queryRawUnsafe(
`WITH src AS (SELECT m.* FROM ${args.seriesRows} WHERE m."measured_at" >= $4) ${actual.denseFiguresSql(usesHourlyMeanDay(args.type))}`,
args.userId,
args.grain,
args.userTz,
args.since,
)) as unknown[];
return { bucketRows, rawAgg };
},
};
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night;
+7 -134
View File
@@ -14,6 +14,7 @@
* window, judged by that direction) and `referenceLines` (the person's own
* target lines, else the clinical ones, in the unit of the points).
*/
import { readDenseSeries } from "@/lib/measurements/dense-series-read";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
@@ -33,14 +34,7 @@ import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
import { VALUE_RANGES } from "@/lib/validations/measurement";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import {
dayWeightedRows,
hourWeightSql,
readingsMean,
weightedHourMeanSql,
windowMeanSql,
zoneDayFrame,
} from "@/lib/measurements/day-mean";
import { readingsMean } from "@/lib/measurements/day-mean";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
@@ -461,135 +455,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
// weighted mean of its local hours' means, so a workout hour with hundreds
// of times the readings does not outweigh the rest of the day (see
// `day-statistic.ts`). The hour grain is already one mean per hour.
const hourWeighted = grain === "day" && usesHourlyMeanDay(type);
const bucketRows = await prisma.$queryRawUnsafe<
Array<{
bucket_start: Date;
mean: number;
min_value: number;
max_value: number;
}>
>(
hourWeighted
? `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
'hour',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_hour
FROM ${seriesRows}
WHERE m."measured_at" >= $4
),
hourly AS (
SELECT
date_trunc($2, local_hour) AS local_day,
AVG("value") AS hour_mean,
COUNT(*) AS n,
MIN(COALESCE("value_min", "value")) AS min_value,
MAX(COALESCE("value_max", "value")) AS max_value
FROM localized
GROUP BY local_hour
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
${weightedHourMeanSql("hour_mean", hourWeightSql("n"))}::double precision AS mean,
MIN(min_value)::double precision AS min_value,
MAX(max_value)::double precision AS max_value
FROM hourly
GROUP BY local_day
ORDER BY bucket_start ASC
`
: `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
$2,
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_day
FROM ${seriesRows}
WHERE m."measured_at" >= $4
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
AVG("value")::double precision AS mean,
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
MAX(COALESCE("value_max", "value"))::double precision AS max_value
FROM localized
GROUP BY local_day
ORDER BY bucket_start ASC
`,
user.id,
const { bucketRows, rawAgg } = await readDenseSeries({
userId: user.id,
type,
grain,
userTz,
since,
);
// Stats stay aggregated over the RAW rows (not the day buckets) so the
// strip's mean/min/max/stdDev/count are the same figures the raw path
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
// JS population-variance helper, exact row count. Aggregate-only —
// no row transfer. For an hourly-mean type (pulse) the mean is the mean
// of the local days' values, each day the mean of its local hours'
// means, so it is the mean of the day points the chart draws; count,
// min, max and the spread stay over every reading.
const [rawAgg] = usesHourlyMeanDay(type)
? await prisma.$queryRawUnsafe<
Array<{
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}>
>(
`
WITH src AS (
SELECT m.*
FROM ${seriesRows}
WHERE m."measured_at" >= $2
)
SELECT
COUNT(*)::int AS n,
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
GROUP BY m."type"
`,
user.id,
since,
userTz,
)
: await prisma.$queryRawUnsafe<
Array<{
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}>
>(
`
SELECT
COUNT(*)::int AS n,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM ${seriesRows}
WHERE m."measured_at" >= $2
`,
user.id,
since,
);
seriesRows,
});
const round2 = (v: number) => Math.round(v * 100) / 100;
const dayId = (d: Date) =>
grain === "day"
+9 -7
View File
@@ -25,7 +25,9 @@ import localFont from "next/font/local";
// `next/font/local` is evaluated at build time from the literal arguments of
// each call, so every option below has to be written out in place.
export const interCyrillicExt = localFont({
// The bindings are never read: importing this module is what attaches the
// faces, so each name carries the leading underscore of an intended unused.
const _interCyrillicExt = localFont({
src: [
{
path: "./inter/inter-cyrillic-ext.woff2",
@@ -61,7 +63,7 @@ export const interCyrillicExt = localFont({
],
});
export const interCyrillic = localFont({
const _interCyrillic = localFont({
src: [
{ path: "./inter/inter-cyrillic.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-cyrillic.woff2", weight: "500", style: "normal" },
@@ -80,7 +82,7 @@ export const interCyrillic = localFont({
],
});
export const interGreekExt = localFont({
const _interGreekExt = localFont({
src: [
{ path: "./inter/inter-greek-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek-ext.woff2", weight: "500", style: "normal" },
@@ -96,7 +98,7 @@ export const interGreekExt = localFont({
],
});
export const interGreek = localFont({
const _interGreek = localFont({
src: [
{ path: "./inter/inter-greek.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-greek.woff2", weight: "500", style: "normal" },
@@ -116,7 +118,7 @@ export const interGreek = localFont({
],
});
export const interVietnamese = localFont({
const _interVietnamese = localFont({
src: [
{ path: "./inter/inter-vietnamese.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-vietnamese.woff2", weight: "500", style: "normal" },
@@ -136,7 +138,7 @@ export const interVietnamese = localFont({
],
});
export const interLatinExt = localFont({
const _interLatinExt = localFont({
src: [
{ path: "./inter/inter-latin-ext.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin-ext.woff2", weight: "500", style: "normal" },
@@ -156,7 +158,7 @@ export const interLatinExt = localFont({
],
});
export const interLatin = localFont({
const _interLatin = localFont({
src: [
{ path: "./inter/inter-latin.woff2", weight: "400", style: "normal" },
{ path: "./inter/inter-latin.woff2", weight: "500", style: "normal" },
+23 -9
View File
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
export async function register() {
// Only start the worker on the Node.js server runtime (not Edge, not build)
if (process.env.NEXT_RUNTIME === "nodejs") {
// The event-loop monitor comes first, so the loading of the database
// client and of the worker graph is inside its view, and every stall it
// reports names the boot step that was running (`boot-steps.ts`).
const [{ startEventLoopLagMonitor }, { beginBootStep }] = await Promise.all(
[
import("@/lib/observability/event-loop-lag"),
import("@/lib/boot/boot-steps"),
],
);
startEventLoopLagMonitor();
beginBootStep("readiness_summary");
// First-boot readiness summary: a human-readable block on the core secrets
// (with the exact fix on a bad one) before any fail-closed loader throws,
// plus a one-glance status of the optional channels. Runs for every process
@@ -18,6 +30,7 @@ export async function register() {
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
// neither the producer nor the worker (a wrong key must never write).
beginBootStep("key_check");
try {
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/key-check"),
@@ -29,6 +42,7 @@ export async function register() {
}
// Every connection is pinned to UTC; say clearly if one is not.
beginBootStep("time_zone_check");
try {
const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/time-zone-check"),
@@ -41,6 +55,7 @@ export async function register() {
// Every pulse reader follows the operator's pulse day-mean method; mirror
// it in this process and keep the mirror current.
beginBootStep("pulse_method_sync");
try {
const { startPulseDayMeanMethodSync } =
await import("@/lib/measurements/pulse-day-mean-method");
@@ -49,15 +64,10 @@ export async function register() {
// Without the mirror the process keeps the default method.
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
const { startEventLoopLagMonitor } =
await import("@/lib/observability/event-loop-lag");
startEventLoopLagMonitor();
// The same for its heap: a trend line, and one report naming the work in
// flight before the heap runs out (a heap-limit crash logs nothing else).
// Every Node process watches its heap as it watches its event loop: a
// trend line, and one report naming the work in flight before the heap
// runs out (a heap-limit crash logs nothing else).
beginBootStep("monitors");
try {
const [{ startHttpInFlightTracking }, { startHeapMonitor }] =
await Promise.all([
@@ -116,6 +126,7 @@ export async function register() {
// process owns no schedules, supervision, or consumers.
void (async () => {
try {
beginBootStep("producer_start");
const { startGlobalBossProducer } =
await import("@/lib/jobs/boss-instance");
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
@@ -125,6 +136,8 @@ export async function register() {
registerShutdownHandlers(boss);
}
ready(boss !== null);
const { reportBootSteps } = await import("@/lib/boot/boot-steps");
await reportBootSteps();
} catch (err) {
ready(false);
// Queue-backed garnishes degrade independently; never make the web
@@ -147,6 +160,7 @@ export async function register() {
startupEvt.setBackground({ task_name: "startup" });
try {
beginBootStep("worker_import");
const { startReminderWorker } =
await import("@/lib/jobs/reminder-worker");
await startReminderWorker();
+16 -2
View File
@@ -21,8 +21,10 @@ describe("per-process database connection budget", () => {
const originalConnectionLimit = process.env.DB_CONNECTION_LIMIT;
const originalPoolTimeout = process.env.DB_POOL_TIMEOUT;
const originalLegacyPoolMax = process.env.DATABASE_POOL_MAX;
const originalProcessType = process.env.HEALTHLOG_PROCESS_TYPE;
beforeEach(() => {
process.env.HEALTHLOG_PROCESS_TYPE = "web";
process.env.DATABASE_URL =
"postgresql://healthlog:test@db:5432/healthlog?connection_limit=20&pool_timeout=20";
delete process.env.DB_CONNECTION_LIMIT;
@@ -39,6 +41,7 @@ describe("per-process database connection budget", () => {
restore("DB_CONNECTION_LIMIT", originalConnectionLimit);
restore("DB_POOL_TIMEOUT", originalPoolTimeout);
restore("DATABASE_POOL_MAX", originalLegacyPoolMax);
restore("HEALTHLOG_PROCESS_TYPE", originalProcessType);
});
it("splits the URL connection_limit across Prisma and pg-boss", () => {
@@ -48,6 +51,17 @@ describe("per-process database connection budget", () => {
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget());
});
it("gives a process that runs the worker four pg-boss connections", () => {
for (const role of ["all", "worker"]) {
process.env.HEALTHLOG_PROCESS_TYPE = role;
expect(getPgBossPoolMax()).toBe(4);
expect(getPrismaPoolMax()).toBe(16);
}
process.env.DB_CONNECTION_LIMIT = "3";
expect(getPgBossPoolMax()).toBe(2);
expect(getPrismaPoolMax()).toBe(1);
});
it("uses the existing pool_timeout seconds for both pool constructors", () => {
expect(getPoolConnectionTimeoutMs()).toBe(20_000);
});
@@ -138,14 +152,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 -c TimeZone=UTC");
expect(buildSessionOptions()).toBe("-c work_mem=16MB -c jit=off -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 TimeZone=UTC",
"-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c jit=off -c TimeZone=UTC",
);
});
+2 -8
View File
@@ -17,6 +17,7 @@
* (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own
* and follows the instance default.
*/
import { readOperatorRow } from "@/lib/settings/operator-row";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -83,14 +84,7 @@ export function aiUsageSettingsFromRow(
export function loadAiUsageSettings(): Promise<AiUsageSettings> {
const read = async (): Promise<AiUsageSettings> => {
try {
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
aiUsageMaxLevel: true,
aiUsageDefaultLevel: true,
aiSyncRegenGapHours: true,
},
});
const row = await readOperatorRow();
return aiUsageSettingsFromRow(row);
} catch {
getEvent()?.addWarning(
+13 -3
View File
@@ -388,7 +388,17 @@ function buildDerivedHealthTokens(): ReadonlySet<string> {
return tokens;
}
const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
let memoDerivedHealthTokensCache:
ReturnType<typeof memoDerivedHealthTokensBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function memoDerivedHealthTokens(): ReturnType<
typeof memoDerivedHealthTokensBuild
> {
return (memoDerivedHealthTokensCache ??= memoDerivedHealthTokensBuild());
}
function memoDerivedHealthTokensBuild() {
return buildDerivedHealthTokens();
}
/**
* Test seam: the guard suite asserts the derived set is non-empty and that
@@ -396,13 +406,13 @@ const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
* metric labels fails the suite rather than quietly narrowing the allow-list.
*/
export function derivedHealthTokens(): ReadonlySet<string> {
return DERIVED_HEALTH_TOKENS;
return memoDerivedHealthTokens();
}
/** True when any whole word of the message is a health noun from the bundles. */
function matchesDerivedHealthToken(message: string): boolean {
for (const word of healthWordTokens(message)) {
if (DERIVED_HEALTH_TOKENS.has(word)) return true;
if (memoDerivedHealthTokens().has(word)) return true;
}
return false;
}
+2 -14
View File
@@ -1,4 +1,5 @@
import type { AIProvider, CompletionResult } from "./types";
import { readOperatorRow } from "@/lib/settings/operator-row";
import { bindResponseTimeout } from "./effective-timeout";
import { bindReasoningEffort } from "./reasoning-effort";
import { prisma } from "@/lib/db";
@@ -1145,16 +1146,6 @@ const PRESENCE_USER_SELECT = {
aiResponseTimeoutSeconds: true,
} as const;
const PRESENCE_SETTINGS_SELECT = {
adminAiKeyEncrypted: true,
adminAiModel: true,
adminAiBaseUrl: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
} as const;
const NO_PRESENCE: ProviderPresence = Object.freeze({
entries: [],
localOcrEnabled: false,
@@ -1217,10 +1208,7 @@ export function probeProviderChain(
where: { id: recordId },
select: PRESENCE_USER_SELECT,
}),
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: PRESENCE_SETTINGS_SELECT,
}),
readOperatorRow(),
]);
if (!row) return NO_PRESENCE;
const policy = providerCredentialPolicy(authority, row.managedProfileAt);
+2 -4
View File
@@ -5,6 +5,7 @@
* Server-only. The decision itself is the pure `resolveReasoning` in
* `./resolve.ts`; this file only gathers its inputs.
*/
import { readOperatorRow } from "@/lib/settings/operator-row";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -70,10 +71,7 @@ function loadMemoisedControls(): Promise<ReasoningAdminControls> {
"ai-reasoning-controls",
async () => {
try {
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { aiReasoningEnabled: true, aiReasoningMaxEffort: true },
});
const row = await readOperatorRow();
return {
enabled: row?.aiReasoningEnabled ?? true,
maxEffort: parseReasoningMaxEffort(row?.aiReasoningMaxEffort),
+124
View File
@@ -0,0 +1,124 @@
/**
* The pre-fold remainder of the dashboard slice: per type, the all-time
* figures of every reading older than the rollup fold window.
*
* The slice splices it into its per-type figures, so "all-time" means all time
* on the rollup path too (empty for any account younger than the
* window). It keeps one source per (type, day) the way the rollup half
* and the live path do: without it, a vital recorded by two devices
* counted both of them on every pre-fold day. Each source is folded per
* day first (a hash aggregate, a few rows per day however dense the
* stream), each day then keeps its ladder-canonical source, so the
* sort never sees more than one row per source per day.
* An hourly-mean type folds each source's day as the mean of its
* hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
* the remainder once each, the statistic the DAY rollup stores.
*
* It is a closed block: it aggregates only readings measured before
* `foldSplit`, the start of a UTC day five years back, so a sync of newer
* readings cannot change it. On a long history it was the slice's largest
* statement (every reading older than five years, folded per source and day),
* and the slice is rebuilt after every sync. It is cached under a closed-block
* key (`closedBlockKeyPart`): a measurement write that reaches back before
* `foldSplit`, and every write that does not say how far back it reached,
* drops it; the split moves on at the next UTC day, which names a new key.
* The TTL bounds an entry that a write in another process (a split worker)
* cannot reach.
*/
import { createHash } from "node:crypto";
import { Prisma } from "@/generated/prisma/client";
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
import { prisma } from "@/lib/db";
import { dayWeightedRowsSql, foldMeanSql } from "@/lib/measurements/day-mean";
export type PreFoldRemainderRow = {
type: string;
count: number;
days: number;
min: number;
max: number;
mean: number;
};
/** A closed block's lifetime when no write reaches it. */
const PRE_FOLD_REMAINDER_TTL_MS = 15 * 60_000;
/** The remainder as a live read, uncached. */
export function readPreFoldRemainderLive(
userId: string,
foldSplit: Date,
rankPerSource: string,
): Promise<PreFoldRemainderRow[]> {
return prisma.$queryRaw<PreFoldRemainderRow[]>`
WITH src AS (
SELECT *
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" < ${foldSplit}
),
per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
SUM(m."value" * m.day_weight)::double precision AS wsum,
SUM(m.day_weight)::double precision AS wdays
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
FROM per_source p
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
)
SELECT
c."type"::text AS type,
SUM(c.cnt)::int AS count,
COUNT(*)::int AS days,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max,
${Prisma.raw(
foldMeanSql({
typeColumn: 'c."type"',
total: "c.total",
count: "c.cnt",
weightedSum: "c.wsum",
weightSum: "c.wdays",
}),
)}::double precision AS mean
FROM canon c
GROUP BY c."type"
`;
}
/**
* The remainder through its closed-block cache cell. `rankPerSource` is the
* ladder CASE over `p."type"` / `p."source"`, built from `priorityJson`,
* which names the cell.
*/
export async function readPreFoldRemainder(
userId: string,
foldSplit: Date,
priorityJson: unknown,
rankPerSource: string,
): Promise<PreFoldRemainderRow[]> {
const ladder = createHash("sha256")
.update(JSON.stringify(priorityJson ?? null))
.digest("base64url")
.slice(0, 16);
const cache = caches.analytics as ServerCache<PreFoldRemainderRow[]>;
const { value } = await cache.wrap(
`${userId}|pre-fold-remainder|${closedBlockKeyPart(foldSplit)}|${ladder}`,
() => readPreFoldRemainderLive(userId, foldSplit, rankPerSource),
PRE_FOLD_REMAINDER_TTL_MS,
);
return value;
}
+24 -10
View File
@@ -127,6 +127,16 @@ function assertInterval(value: string): string {
* is whitelisted and, when given, scopes both the inner pick and the outer
* filter to a trailing window. `onlyType`, when given, scopes both halves to
* that one metric.
*
* The pick groups before it sorts. Its order (rank, then source name) depends
* only on a row's `(type, source)`, so every reading of one source on one day
* sorts alike, and the distinct `(type, day, source)` triples carry the whole
* decision. A hash aggregate reduces the window's rows to those triples (a
* few thousand) and `DISTINCT ON` sorts only them; sorting every raw row by
* a CASE expression no index can supply spilled to disk on a large account.
* The picked source per day is the same by construction;
* `tests/integration/canonical-source-pick-parity.test.ts` holds the old
* statement as its reference.
*/
export function canonicalMeasurementsCte(
rankUnqualified: string,
@@ -152,16 +162,20 @@ export function canonicalMeasurementsCte(
SELECT mm.*
FROM measurements mm
JOIN (
SELECT DISTINCT ON ("type", date_trunc('day', "measured_at"))
"type" AS t,
date_trunc('day', "measured_at") AS d,
"source" AS canon
FROM measurements
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
SELECT DISTINCT ON ("type", d)
"type" AS t,
d,
"source" AS canon
FROM (
SELECT "type", date_trunc('day', "measured_at") AS d, "source"
FROM measurements
WHERE "user_id" = $1
AND "deleted_at" IS NULL
${sinceInner}
${typeInner}
GROUP BY "type", date_trunc('day', "measured_at"), "source"
) g
ORDER BY "type", d, (${rankUnqualified}), "source"::text
) c
ON c.t = mm."type"
AND c.d = date_trunc('day', mm."measured_at")
+4 -70
View File
@@ -57,7 +57,6 @@
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
@@ -69,8 +68,6 @@ import {
SESSION_DAY_FRAME,
dayValue,
dayWeightedRows,
dayWeightedRowsSql,
foldMeanSql,
isHourlyMeanTypeSql,
windowMeanSql,
} from "@/lib/measurements/day-mean";
@@ -78,6 +75,7 @@ import { wireSummary, type DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
import { readPreFoldRemainder } from "@/lib/analytics/pre-fold-remainder";
import {
ensureUserRollupsFresh,
readRollupBuckets,
@@ -704,73 +702,9 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
sumYy: true,
},
}),
// v1.37.19 — all-time remainder OLDER than the fold window,
// spliced into the per-type figures below so "all-time" means all time
// on the rollup path too (empty for any account younger than the
// window). It keeps one source per (type, day) the way the rollup half
// and the live path do: without it, a vital recorded by two devices
// counted both of them on every pre-fold day. Each source is folded per
// day first (a hash aggregate, a few rows per day however dense the
// stream), each day then keeps its ladder-canonical source, so the
// sort never sees more than one row per source per day.
// An hourly-mean type folds each source's day as the mean of its
// hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
// the remainder once each, the statistic the DAY rollup stores.
prisma.$queryRaw<
Array<{
type: string;
count: number;
days: number;
min: number;
max: number;
mean: number;
}>
>`
WITH src AS (
SELECT *
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" < ${foldSplit}
),
per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
SUM(m."value" * m.day_weight)::double precision AS wsum,
SUM(m.day_weight)::double precision AS wdays
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
FROM per_source p
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
)
SELECT
c."type"::text AS type,
SUM(c.cnt)::int AS count,
COUNT(*)::int AS days,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max,
${Prisma.raw(
foldMeanSql({
typeColumn: 'c."type"',
total: "c.total",
count: "c.cnt",
weightedSum: "c.wsum",
weightSum: "c.wdays",
}),
)}::double precision AS mean
FROM canon c
GROUP BY c."type"
`,
// The all-time remainder older than the fold window (a closed block,
// see `pre-fold-remainder.ts`).
readPreFoldRemainder(userId, foldSplit, priorityJson, rankPerSource),
]);
const preFoldByType = new Map<
+2 -4
View File
@@ -16,6 +16,7 @@
* out; the operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling the
* policy off) is the recovery path of last resort.
*/
import { readOperatorRow } from "@/lib/settings/operator-row";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
@@ -44,10 +45,7 @@ export async function resolveMfaEnrollmentRequired(
// Already has a confirmed TOTP secret → enrolled, nothing required.
if (user.totpConfirmedAt) return false;
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { mfaRequired: true },
});
const settings = await readOperatorRow();
const policyApplies = Boolean(settings?.mfaRequired) || user.mfaEnforced;
if (!policyApplies) return false;
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
beginBootStep,
bootSteps,
currentBootStep,
endBootSteps,
} from "../boot-steps";
describe("boot steps", () => {
it("times each step until the next begins, and names the running one", () => {
beginBootStep("a", 100);
expect(currentBootStep()).toBe("a");
beginBootStep("b", 130);
expect(currentBootStep()).toBe("b");
const steps = endBootSteps(200);
expect(currentBootStep()).toBeNull();
expect(steps.slice(-2)).toEqual([
{ name: "a", startedAtMs: 100, durationMs: 30 },
{ name: "b", startedAtMs: 130, durationMs: 70 },
]);
expect(bootSteps().slice(-2)).toEqual(steps.slice(-2));
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Named boot steps with their timings, and the step running right now.
*
* A stall at boot used to be one number: "the loop was blocked 718 ms". The
* event-loop monitor now names the step that was running when the late tick
* fired (`currentBootStep`), and the boot writes one `boot.steps` line with
* how long each step took, so a slow boot says where its time went.
*
* Kept on `globalThis`: the steps are marked from the instrumentation bundle
* and from the worker modules it loads, which are separate module copies in
* a production build.
*/
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
export interface BootStep {
name: string;
/** Milliseconds since the process started (`performance.timeOrigin`). */
startedAtMs: number;
durationMs: number | null;
}
const KEY = Symbol.for("healthlog.boot.steps");
interface State {
steps: BootStep[];
current: BootStep | null;
}
function state(): State {
const slot = globalThis as Record<symbol, unknown>;
let current = slot[KEY] as State | undefined;
if (!current) {
current = { steps: [], current: null };
slot[KEY] = current;
}
return current;
}
/**
* Begin `name`, ending the step before it. Steps run one after another; a
* step that starts work in the background ends when the next one begins.
*/
export function beginBootStep(
name: string,
now: number = performance.now(),
): void {
const s = state();
if (s.current && s.current.durationMs === null) {
s.current.durationMs = Math.round(now - s.current.startedAtMs);
}
const step: BootStep = {
name,
startedAtMs: Math.round(now),
durationMs: null,
};
s.steps.push(step);
s.current = step;
}
/** End the running step without starting another. */
export function endBootSteps(now: number = performance.now()): BootStep[] {
const s = state();
if (s.current && s.current.durationMs === null) {
s.current.durationMs = Math.round(now - s.current.startedAtMs);
}
s.current = null;
return [...s.steps];
}
/** The step running now, or null once the boot is through. */
export function currentBootStep(): string | null {
return state().current?.name ?? null;
}
/** Every step so far, for the boot line. */
export function bootSteps(): BootStep[] {
return [...state().steps];
}
/**
* End the boot and write one `boot.steps` line with every step and its
* duration, so a slow start says where its time went. Never throws.
*/
export async function reportBootSteps(
meta: Record<string, string | number> = {},
): Promise<void> {
const steps = endBootSteps();
try {
await withBackgroundEvent("boot.steps", async () => {
annotate({
meta: {
...meta,
boot_steps: steps
.map((s) => `${s.name}=${s.durationMs ?? 0}`)
.join(","),
boot_total_ms: Math.round(performance.now()),
},
});
});
} catch {
// A diagnostic must never break boot.
}
}
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from "vitest";
import { closedBlockKeyPart, closedBlockUntouched } from "../closed-block";
import { invalidateUserMeasurements } from "../invalidate";
import { caches, type ServerCache } from "../server-cache";
const analytics = caches.analytics as ServerCache<{ v: number }>;
const USER = "u-closed";
const SPLIT = Date.UTC(2021, 9, 22);
const closedKey = `${USER}|pre-fold-remainder|${closedBlockKeyPart(SPLIT)}|x`;
const openKey = `${USER}|dashboard-snapshot|en`;
describe("closed analytics blocks", () => {
beforeEach(() => {
analytics.deleteByPrefix(`${USER}|`);
analytics.set(closedKey, { v: 1 });
analytics.set(openKey, { v: 2 });
});
it("reads the instant a closed block ends before", () => {
expect(closedBlockUntouched(closedKey, new Date(SPLIT))).toBe(true);
expect(closedBlockUntouched(closedKey, new Date(SPLIT - 1))).toBe(false);
expect(closedBlockUntouched(openKey, new Date(SPLIT))).toBe(false);
});
it("survives a write of newer readings, evicting or marking stale", () => {
for (const evict of [false, true]) {
invalidateUserMeasurements(USER, {
evict,
touchedFrom: new Date(SPLIT + 86_400_000),
});
expect(analytics.get(closedKey)).toEqual({ v: 1 });
expect(analytics.get(openKey)).toBeNull();
analytics.set(openKey, { v: 2 });
}
});
it("goes with a write that reaches before it, or does not say", () => {
invalidateUserMeasurements(USER, { touchedFrom: new Date(SPLIT - 1) });
expect(analytics.get(closedKey)).toBeNull();
analytics.set(closedKey, { v: 1 });
invalidateUserMeasurements(USER);
expect(analytics.get(closedKey)).toBeNull();
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* The key part that marks an analytics entry as a closed block: a value that
* depends only on readings measured before `before`. Such a block is the
* same however many syncs land after it, as long as none of them touches a
* reading that old, so a measurement write that says how far back it
* reached (`touchedFrom`) leaves it in place. Every other invalidation, and
* every write that does not say, drops it as before.
*/
export function closedBlockKeyPart(before: Date | number): string {
const ms = typeof before === "number" ? before : before.getTime();
return `before:${ms}`;
}
const CLOSED_BLOCK_RE = /\|before:(\d+)(?:\||$)/;
/**
* Whether `key` is a closed block that a write reaching back to
* `touchedFrom` cannot have changed. Exported for its test.
*/
export function closedBlockUntouched(key: string, touchedFrom: Date): boolean {
const match = CLOSED_BLOCK_RE.exec(key);
return match !== null && Number(match[1]) <= touchedFrom.getTime();
}
+16 -3
View File
@@ -20,6 +20,7 @@
import { forgetCoverageProbe } from "@/lib/rollups/coverage-inflight";
import { closedBlockUntouched } from "./closed-block";
import { caches } from "./server-cache";
/**
@@ -74,16 +75,28 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
*/
export function invalidateUserMeasurements(
userId: string,
opts?: { evict?: boolean },
opts?: {
evict?: boolean;
/**
* The earliest `measuredAt` the write created, changed or removed. With
* it, closed blocks of older readings (`closedBlockKeyPart`) stay.
* Leave it out unless the caller knows every row it touched.
*/
touchedFrom?: Date;
},
): void {
// A coverage probe already running read the set before this write; the
// next reader must not join it.
forgetCoverageProbe(userId);
const touchedFrom = opts?.touchedFrom;
const spare = touchedFrom
? (key: string) => closedBlockUntouched(key, touchedFrom)
: undefined;
// The `${userId}|` prefix covers the slim / thick analytics cells, the
// iOS summary cell, AND the v1.7.0 dashboard snapshot
// (`${userId}|dashboard-snapshot`) in one pass.
if (opts?.evict) {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.analytics.deleteByPrefix(`${userId}|`, spare);
} else {
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
@@ -94,7 +107,7 @@ export function invalidateUserMeasurements(
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
caches.analytics.markStaleByPrefix(`${userId}|`, spare);
}
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
+11 -11
View File
@@ -88,15 +88,15 @@ export interface ServerCacheStats {
* fence — invalidation flips `invalidated` and detaches the slot from
* `pending`, so (a) the builder's `.then` skips the `set()` and (b) the
* next read starts a fresh build instead of joining the stale one.
* Callers already awaiting the detached promise still receive its value
* (equivalent to having read a moment before the write); it just never
* enters the cache.
* Callers already awaiting the detached promise still get its value (as
* if read just before the write); it never enters the cache.
*/
interface PendingBuild<T> {
promise: Promise<T>;
invalidated: boolean;
}
type KeyFilter = (key: string) => boolean; // closed blocks, `invalidate.ts`
export class ServerCache<T> {
private readonly map = new Map<
string,
@@ -124,9 +124,9 @@ export class ServerCache<T> {
}
/** Fence + detach every in-flight builder under `prefix`. */
private detachPendingByPrefix(prefix: string): void {
private detachPendingByPrefix(prefix: string, spare?: KeyFilter): void {
for (const [key, slot] of this.pending) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
slot.invalidated = true;
this.pending.delete(key);
}
@@ -283,17 +283,17 @@ export class ServerCache<T> {
* so the next read is a clean miss — identical to the old evict
* behaviour).
*/
markStaleByPrefix(prefix: string): number {
markStaleByPrefix(prefix: string, spare?: KeyFilter): number {
// v1.16.9 — fence in-flight builders too: a refresh that started
// before the write would otherwise complete after this mark and
// re-cache the pre-write body as FRESH for the full TTL. Detaching
// keeps the SWR contract — the marked entry still serves stale, and
// the next read kicks off a fresh (post-write) recompute.
this.detachPendingByPrefix(prefix);
this.detachPendingByPrefix(prefix, spare);
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
entry.expiresAt = now;
marked += 1;
}
@@ -320,12 +320,12 @@ export class ServerCache<T> {
* entire user-bucket from a cache without having to enumerate every
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
deleteByPrefix(prefix: string, spare?: KeyFilter): number {
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
this.detachPendingByPrefix(prefix);
this.detachPendingByPrefix(prefix, spare);
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
const entry = this.map.get(key);
this.map.delete(key);
this.totalWeight -= entry?.weight ?? 0;
+18 -5
View File
@@ -14,6 +14,8 @@
* once in `sections`. Nothing here writes, and nothing here talks to a model;
* `model-projection.ts` is the one way a day leaves for one.
*/
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/enums";
import { readDayMovement } from "@/lib/dawarich/movement";
@@ -35,6 +37,9 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { localDayWindow } from "@/lib/tz/local-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Record sections read at once for one day (see `loadDay`). */
const DAY_SECTION_CONCURRENCY = 6;
const NO_READINGS: DayReadings = {
rowsByType: new Map(),
night: null,
@@ -99,6 +104,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
? await loadUserSourcePriority(recordId)
: null;
const sectionLimit = pLimit(DAY_SECTION_CONCURRENCY);
const recordSections = [...access.readable].filter(
(section) => RECORD_SECTION_READERS[section] !== undefined,
);
@@ -115,13 +121,20 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
priority,
)
: Promise.resolve(NO_READINGS),
// About twenty-five section readers, each a query or two, beside the
// readings, scores and movement: unbounded they took more connections
// than the pool holds, and a neighbouring request waited for a slot.
// Six at a time keeps the day under a third of the pool; the parts come
// back in section order as before.
Promise.all(
recordSections.map((section) =>
(
RECORD_SECTION_READERS[section] as (
frame: DayFrame,
) => Promise<SectionPart>
)(frame),
sectionLimit(() =>
(
RECORD_SECTION_READERS[section] as (
frame: DayFrame,
) => Promise<SectionPart>
)(frame),
),
),
),
scoresReadable
+8 -1
View File
@@ -31,6 +31,7 @@ import type { MeasurementType } from "@/generated/prisma/enums";
import type { DayNotable } from "@/lib/day/contract";
import { readLocalDailyStats, type DailyStats } from "@/lib/day/daily-stats";
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
import { prisma } from "@/lib/db";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
@@ -267,10 +268,16 @@ async function readExtremeHistory(args: {
) {
const last = shiftDateKey(start, HISTORY_BLOCK_DAYS - 1);
const open = last >= today;
// A closed block names the instant its readings end before, with a day
// of margin for a night that runs past midnight, so a sync that only
// touched newer readings leaves it cached (`closedBlockKeyPart`).
const closed = closedBlockKeyPart(
startOfLocalDayKey(shiftDateKey(last, 2), args.tz),
);
blocks.push({
start,
end: open ? today : last,
key: `${prefix}|${start}|${open ? today : "closed"}`,
key: `${prefix}|${start}|${open ? today : closed}`,
});
}
+28 -5
View File
@@ -6,6 +6,7 @@ import {
sessionOptionsDisabled,
TIME_ZONE_OPTION,
} from "@/lib/db-session";
import { shouldRunWorker } from "@/lib/process-type";
export {
pinTimeZoneOnConnect,
@@ -21,7 +22,17 @@ const globalForPrisma = globalThis as unknown as {
const DEFAULT_CONNECTION_BUDGET = 20;
const DEFAULT_POOL_TIMEOUT_SECONDS = 20;
const PG_BOSS_CONNECTIONS = 2;
/**
* pg-boss connections. A web-only process only sends jobs, and two sockets
* carry that. A process that runs the worker polls: about a hundred bindings
* (two dozen of them every two seconds, several with two to five slots), the
* cron timekeeper, supervision, and every job's own start and completion
* writes, plus the web sends when web and worker share the process. On two
* sockets those queued behind each other; four keep a fetch from waiting on
* a completion. The connections come out of the same per-process budget.
*/
const PG_BOSS_PRODUCER_CONNECTIONS = 2;
const PG_BOSS_WORKER_CONNECTIONS = 4;
function positiveInteger(raw: string | undefined): number | null {
if (!raw) return null;
@@ -53,8 +64,14 @@ export function getConnectionBudget(): number {
return Math.max(2, configured);
}
export function getPgBossPoolMax(): number {
return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1);
export function getPgBossPoolMax(
role: "worker" | "producer" = shouldRunWorker() ? "worker" : "producer",
): number {
const wanted =
role === "worker"
? PG_BOSS_WORKER_CONNECTIONS
: PG_BOSS_PRODUCER_CONNECTIONS;
return Math.min(wanted, getConnectionBudget() - 1);
}
export function getPrismaPoolMax(): number {
@@ -129,7 +146,13 @@ export function getWorkMem(): string {
* session carries them from the first query.
*
* The timeouts are left out when disabled (timeout 0); `work_mem` is always
* set. Up to v1.41 the whole string was `undefined` at timeout 0, which would
* set, and so is `jit=off`. Postgres compiles a statement to machine code
* once its estimated cost passes `jit_above_cost`, which every analytics
* fold over a year of readings does; on the benchmark account that
* compilation took 100 to 140 ms of a 220 ms statement and saved nothing,
* because these statements run once and are bound by reading rows, not by
* evaluating expressions. JIT changes how a plan runs, never what it
* returns. Up to v1.41 the whole string was `undefined` at timeout 0, which would
* have taken `work_mem` with it. `undefined` now means exactly one thing:
* the operator turned the startup options off for a connection pooler
* ({@link sessionOptionsDisabled}).
@@ -141,7 +164,7 @@ export function buildSessionOptions(): string | undefined {
timeoutMs > 0
? `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs} `
: "";
return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`;
return `${timeouts}-c work_mem=${getWorkMem()} -c jit=off ${TIME_ZONE_OPTION}`;
}
export function prismaPoolConfig(): PoolConfig {
+11 -3
View File
@@ -152,11 +152,19 @@ function buildAnalyteNamePattern(): RegExp {
);
}
const DERIVED_ANALYTE_NAMES = buildAnalyteNamePattern();
let derivedAnalyteNamesCache:
ReturnType<typeof derivedAnalyteNamesBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function derivedAnalyteNames(): ReturnType<typeof derivedAnalyteNamesBuild> {
return (derivedAnalyteNamesCache ??= derivedAnalyteNamesBuild());
}
function derivedAnalyteNamesBuild() {
return buildAnalyteNamePattern();
}
/** Test seam: the guard suite pins that the derived pattern covers every locale. */
export function derivedAnalyteNamePattern(): RegExp {
return DERIVED_ANALYTE_NAMES;
return derivedAnalyteNames();
}
/**
@@ -175,7 +183,7 @@ export function looksLikeLabDocument(text: string): boolean {
for (const signal of LAB_SIGNALS) {
const hit =
signal.test(folded) ||
(signal === ANALYTE_SIGNAL && DERIVED_ANALYTE_NAMES.test(folded));
(signal === ANALYTE_SIGNAL && derivedAnalyteNames().test(folded));
if (hit && ++hits >= 2) return true;
}
return false;
+2 -11
View File
@@ -1,4 +1,4 @@
import { prisma } from "@/lib/db";
import { readOperatorRow } from "@/lib/settings/operator-row";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -57,16 +57,7 @@ export function loadAssistantSwitches(): Promise<AssistantFlagSet | null> {
"assistant-flags",
async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
},
});
const settings = await readOperatorRow();
return resolveAssistantFlags({
enabled: settings?.assistantEnabled ?? true,
coach: settings?.assistantCoachEnabled ?? true,
+13 -3
View File
@@ -355,14 +355,24 @@ function buildLocalisedAliases(): Record<string, string> {
return index;
}
const LOCALISED_LAB_ALIASES = buildLocalisedAliases();
let memoLocalisedLabAliasesCache:
ReturnType<typeof memoLocalisedLabAliasesBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function memoLocalisedLabAliases(): ReturnType<
typeof memoLocalisedLabAliasesBuild
> {
return (memoLocalisedLabAliasesCache ??= memoLocalisedLabAliasesBuild());
}
function memoLocalisedLabAliasesBuild() {
return buildLocalisedAliases();
}
/**
* Test seam: the guard suite asserts the derived index covers every shipped
* locale and contradicts no hand-written alias. Production reads the constant.
*/
export function localisedLabAliases(): Readonly<Record<string, string>> {
return LOCALISED_LAB_ALIASES;
return memoLocalisedLabAliases();
}
/**
@@ -462,7 +472,7 @@ export function resolveLabCoding(
// every resolution that worked before working the same way.
const resolvedKey = LAB_LOINC_BY_KEY[key]
? key
: (LAB_ALIASES[key] ?? LOCALISED_LAB_ALIASES[key]);
: (LAB_ALIASES[key] ?? memoLocalisedLabAliases()[key]);
if (!resolvedKey) return null;
const mapping = LAB_LOINC_BY_KEY[resolvedKey];
if (!mapping) return null;
+10 -3
View File
@@ -110,7 +110,14 @@ function buildLocalisedIndex(): Record<string, QualitativeConcept> {
return index;
}
const LOCALISED_SNOMED = buildLocalisedIndex();
let localisedSnomedCache: ReturnType<typeof localisedSnomedBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function localisedSnomed(): ReturnType<typeof localisedSnomedBuild> {
return (localisedSnomedCache ??= localisedSnomedBuild());
}
function localisedSnomedBuild() {
return buildLocalisedIndex();
}
/**
* Test seam: the guard suite asserts the derived index covers every shipped
@@ -120,7 +127,7 @@ const LOCALISED_SNOMED = buildLocalisedIndex();
export function localisedQualitativeIndex(): Readonly<
Record<string, QualitativeConcept>
> {
return LOCALISED_SNOMED;
return localisedSnomed();
}
/**
@@ -144,7 +151,7 @@ export function qualitativeValueConcept(
valueText: string,
): FhirCodeableConcept {
const key = normaliseQualitativeKey(valueText);
const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key];
const concept = HAND_WRITTEN_SNOMED[key] ?? localisedSnomed()[key];
if (!concept) {
return { text: valueText };
}
@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { getServerTranslator } from "../server-translator";
import {
lazyServerCatalogs,
loadedServerLocales,
serverCatalog,
} from "../server-catalogs";
describe("server catalogs", () => {
it("reads a locale from messages/ on first use and keeps one copy", () => {
const de = serverCatalog("de");
expect(de).toEqual(
JSON.parse(
readFileSync(join(process.cwd(), "messages", "de.json"), "utf8"),
),
);
expect(serverCatalog("de")).toBe(de);
expect(loadedServerLocales()).toContain("de");
});
it("loads only the locale an entry of the lazy record is read for", () => {
const record = lazyServerCatalogs();
const before = new Set(loadedServerLocales());
void record.it;
const added = loadedServerLocales().filter((l) => !before.has(l));
expect(added.every((l) => l === "it")).toBe(true);
expect(record.it).toBe(serverCatalog("it"));
});
it("lets an assignment replace an entry", () => {
const record = lazyServerCatalogs();
record.ko = { common: { save: "x" } };
expect(record.ko).toEqual({ common: { save: "x" } });
expect(serverCatalog("ko")).not.toEqual({ common: { save: "x" } });
});
it("refuses a locale that is not shipped", () => {
expect(() => serverCatalog("xx" as "de")).toThrow(/unknown locale/);
});
it("serves the server translator from the same catalogs", () => {
const t = getServerTranslator("fr");
const fr = serverCatalog("fr") as { common: { save: string } };
expect(t.t("common.save")).toBe(fr.common.save);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* The match form of a label, without the catalogs.
*
* `localised-label-index.ts` builds its indexes from every shipped catalog
* and is server-only; this fold is the part client code needs too (the
* medication target map folds a free-text name the same way), so it lives
* in a leaf that imports nothing.
*/
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
+11 -5
View File
@@ -7,8 +7,8 @@
* for every user, in every language). The t() chain (active locale →
* EN → raw key) now resolves its bundles through three paths:
*
* 1. SSR pass: this module seeds its cache with the full server-side
* catalog map (`shared-resolve`) inside a `typeof window ===
* 1. SSR pass: this module seeds its cache with the lazy server-side
* catalog map (`server-catalogs`) inside a `typeof window ===
* "undefined"` branch. Turbopack compiles the branch away for the
* browser bundle (verified — zero catalog bytes reach any client
* chunk), while the server render of every client component
@@ -33,6 +33,10 @@
* seed bundles directly (`primeMessages`).
*/
import { type Locale } from "./config";
import type { lazyServerCatalogs } from "./server-catalogs";
/** The SSR branch's view of `./server-catalogs` (types only; see below). */
type ServerCatalogsModule = { lazyServerCatalogs: typeof lazyServerCatalogs };
export type MessageBundle = Record<string, unknown>;
@@ -63,10 +67,12 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
// where a bare require of a TS module throws) — there the cache starts
// empty and `vitest.setup.ts` primes every bundle up front.
try {
const { allMessages } =
const { lazyServerCatalogs } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./shared-resolve") as typeof import("./shared-resolve");
return { ...allMessages };
require("./server-catalogs") as ServerCatalogsModule;
// Entries load on first read, so the render loads the locales it
// renders in, not all of them.
return lazyServerCatalogs();
} catch {
return {};
}
+2 -36
View File
@@ -24,44 +24,10 @@
* what the resolver ACCEPTS.
*/
import { locales, type Locale } from "./config";
import { foldLabel } from "./fold-label";
import { allMessages, resolveKey } from "./shared-resolve";
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
export { foldLabel };
/** One locale's rendering of `key`, or `null` when that bundle omits it. */
export function labelIn(locale: Locale, key: string): string | null {
+91
View File
@@ -0,0 +1,91 @@
/**
* The message catalogs on the server: read from disk on first use, one
* locale at a time, once per process.
*
* The catalogs used to be eight static JSON imports. A production build gives
* every server bundle its own copy of a shared module (route handlers, the
* RSC/SSR renderer, the instrumentation bundle that runs the worker), so each
* of them carried the whole eight-locale chunk as a source string and parsed
* all eight on load: a boot profile of an idle process found 32 parsed
* catalog objects and two 9.7 MB chunk sources, about a fifth of the live
* heap, for a process whose users read one or two locales.
*
* Now nothing is bundled: a catalog is `messages/<locale>.json` read with
* `readFileSync` the first time a translator or a matcher asks for that
* locale, and parked on `globalThis`, so every bundle copy in the process
* shares one parsed object per locale. The read is synchronous because
* `t()` is; it happens once per locale per process and takes a few
* milliseconds.
*
* The directory is `messages/` under the working directory: the checkout in
* development and tests, `/app` in the image, which copies it there.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { locales, type Locale } from "./config";
export type ServerCatalog = Record<string, unknown>;
const KEY = Symbol.for("healthlog.i18n.serverCatalogs");
function cache(): Map<Locale, ServerCatalog> {
const slot = globalThis as unknown as {
[KEY]?: Map<Locale, ServerCatalog>;
};
slot[KEY] ??= new Map();
return slot[KEY];
}
function catalogDirectory(): string {
// Not a bundler input: the catalogs are read at run time, and tracing
// `process.cwd()` would pull the whole checkout into the output.
return join(/* turbopackIgnore: true */ process.cwd(), "messages");
}
/** One locale's catalog, read and parsed on first use. */
export function serverCatalog(locale: Locale): ServerCatalog {
const loaded = cache();
const hit = loaded.get(locale);
if (hit) return hit;
if (!(locales as readonly string[]).includes(locale)) {
throw new Error(`unknown locale: ${String(locale)}`);
}
const parsed = JSON.parse(
readFileSync(join(catalogDirectory(), `${locale}.json`), "utf8"),
) as ServerCatalog;
loaded.set(locale, parsed);
return parsed;
}
/** Locales whose catalog this process has read so far (for tests and logs). */
export function loadedServerLocales(): Locale[] {
return [...cache().keys()];
}
/**
* A `Record<Locale, catalog>` whose entries load on first read. Indexing one
* locale loads only that one; iterating every locale (the matchers that
* accept a word in any language) loads them all, as before. An assignment
* replaces the entry, which is what the SSR cache in `load-locale.ts` and
* the test helpers do.
*/
export function lazyServerCatalogs(): Record<Locale, ServerCatalog> {
const record = {} as Record<Locale, ServerCatalog>;
for (const locale of locales) {
Object.defineProperty(record, locale, {
enumerable: true,
configurable: true,
get: () => serverCatalog(locale),
set: (value: ServerCatalog) => {
Object.defineProperty(record, locale, {
value,
writable: true,
enumerable: true,
configurable: true,
});
},
});
}
return record;
}
+12 -23
View File
@@ -9,38 +9,27 @@
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
* layout picks the active locale's bundle out of it for the RSC handoff
* and `getServerTranslator()` resolves against it. Do NOT import this
* module from client code — the seven static JSON imports would drag
* every locale back into the client chunk (the ~1.4 MiB regression the
* split removed). Client code uses `./load-locale` (static EN fallback
* floor + dynamic per-locale imports) and `./resolve-key` instead.
* module from client code: it reads the catalogs from disk. Client code
* uses `./load-locale` (dynamic per-locale imports) and `./resolve-key`
* instead.
*/
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import koMessages from "../../../messages/ko.json";
import nlMessages from "../../../messages/nl.json";
import { locales, type Locale } from "./config";
import { resolveKey } from "./resolve-key";
import { lazyServerCatalogs } from "./server-catalogs";
export { resolveKey };
/**
* Single source of truth for the per-locale message bundle on the
* server (layout RSC handoff, server translator, jobs).
* server (layout RSC handoff, server translator, jobs). Each entry is read
* from `messages/<locale>.json` the first time it is indexed and shared by
* every bundle in the process (`server-catalogs.ts`), so a process holds the
* locales its users read, not all of them.
*/
export const allMessages: Record<Locale, Record<string, unknown>> = {
de: deMessages,
en: enMessages,
fr: frMessages,
es: esMessages,
it: itMessages,
pl: plMessages,
ko: koMessages,
nl: nlMessages,
};
export const allMessages: Record<
Locale,
Record<string, unknown>
> = lazyServerCatalogs();
/**
* Every shipped translation of one key, in `locales` order, deduplicated and
+6 -6
View File
@@ -74,7 +74,7 @@ import {
import { shiftDateKey } from "@/lib/tz/format";
import {
pickCanonicalWorkoutRows,
workoutDuplicateSlotKey,
workoutDuplicateClusterKeys,
workoutSourceLadder,
type WorkoutPickerRow,
} from "@/lib/measurements/pick-canonical-workout-rows";
@@ -591,7 +591,7 @@ export function canonicalDayActiveEnergy<
* one source's rows count: the best-ranked on the ladder, a source off the
* ladder ranking below every listed one, and among sources off the ladder
* the one with the largest impulse. Rows of that one source still add up
* (two distinct sessions of the same sport in one five-minute slot).
* (two distinct sessions of the same sport in one duplicate cluster).
* Returns the positive impulses, one per session. Pure; exported for tests.
*/
export function canonicalWorkoutTrimps<
@@ -615,9 +615,9 @@ export function canonicalWorkoutTrimps<
string,
{ picked: Map<string, number>; dropped: number }
>();
for (const row of rows) {
const key = workoutDuplicateSlotKey(row);
const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 };
const keys = workoutDuplicateClusterKeys(rows);
for (const [index, row] of rows.entries()) {
const slot = slots.get(keys[index]!) ?? { picked: new Map(), dropped: 0 };
const trimp = trimpOf(row);
if (trimp > 0) {
if (picked.has(row.id)) {
@@ -626,7 +626,7 @@ export function canonicalWorkoutTrimps<
slot.dropped = Math.max(slot.dropped, trimp);
}
}
slots.set(key, slot);
slots.set(keys[index]!, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
+2 -1
View File
@@ -108,9 +108,10 @@ describe("worker pg-boss pool", () => {
it("uses the pg-boss share of the process connection budget", () => {
createWorkerBoss("postgres://healthlog@test/db");
// A worker polls; a web-only producer keeps two (see `db.ts`).
expect(PgBoss).toHaveBeenCalledWith({
connectionString: "postgres://healthlog@test/db",
max: 2,
max: 4,
connectionTimeoutMillis: 20_000,
options: "-c TimeZone=UTC",
});
+53
View File
@@ -9,6 +9,10 @@ import type { Job } from "pg-boss";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() }));
const memory = vi.hoisted(() => ({ pressure: false }));
vi.mock("@/lib/observability/container-memory", () => ({
memoryPressureActive: () => memory.pressure,
}));
import { emitSignal } from "@/lib/logging/signal";
import { heavyLane, LANE_WAIT_WARN_MS, laneWanted } from "../heavy-lane";
@@ -266,3 +270,52 @@ describe("a long wait for the lane", () => {
await held;
});
});
describe("the lane under container memory pressure", () => {
it("sends a heavy job back while the container is near its limit", async () => {
const log: string[] = [];
const a = gate(log, "import");
const { back, defer } = deferrals();
memory.pressure = true;
try {
const outcome = await heavyLane(
"apple-health-import-v2",
a.handler,
undefined,
defer,
)([job("upload")]);
expect(outcome).toMatchObject({ ok: true, did: { lane_deferred: 1 } });
expect(a.handler).not.toHaveBeenCalled();
expect(back).toEqual([
{ queue: "apple-health-import-v2", ids: ["upload"] },
]);
} finally {
memory.pressure = false;
}
});
it("asks a holder to give way at its next boundary", async () => {
let seen: boolean[] = [];
const { defer } = deferrals();
let release!: () => void;
const gateDone = new Promise<void>((r) => (release = r));
const running = heavyLane(
"apple-health-import-v2",
async () => {
seen.push(laneWanted());
await gateDone;
seen.push(laneWanted());
return jobDone({ jobs: 1 });
},
undefined,
defer,
)([job("upload")]);
await tick();
memory.pressure = true;
release();
await running;
memory.pressure = false;
expect(seen).toEqual([false, true]);
seen = [];
});
});
+7 -4
View File
@@ -74,10 +74,13 @@ export function getGlobalBoss(): PgBoss | null {
return ((globalThis as Record<string, unknown>)[BOSS_KEY] as PgBoss) ?? null;
}
function databaseOptions(connectionString: string) {
function databaseOptions(
connectionString: string,
role: "worker" | "producer",
) {
return {
connectionString,
max: getPgBossPoolMax(),
max: getPgBossPoolMax(role),
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.
@@ -86,12 +89,12 @@ function databaseOptions(connectionString: string) {
}
export function createWorkerBoss(connectionString: string): PgBoss {
return new PgBoss(databaseOptions(connectionString));
return new PgBoss(databaseOptions(connectionString, "worker"));
}
function producerOptions(connectionString: string) {
return {
...databaseOptions(connectionString),
...databaseOptions(connectionString, "producer"),
migrate: false,
schedule: false,
supervise: false,
+23 -1
View File
@@ -33,6 +33,13 @@
* job that has stood in line for `LANE_WAIT_WARN_MS` is reported at `warn`
* (`job.heavy_lane.waiting_long`), once per holder, so a lane that is held
* for hours shows in the alerts instead of only in `info` lines.
*
* The container memory guard (`observability/container-memory.ts`) closes
* the lane for a while when the cgroup's working set reaches 85 % of its
* limit: a heavy job that arrives then goes back to the queue the same way,
* and a holder that runs in slices gives way at its next boundary, so the
* peak of an import does not land on top of a memory peak and end in a
* SIGKILL. The guard holds jobs back for fifteen minutes at most.
*/
import { AsyncLocalStorage } from "node:async_hooks";
import type { Job } from "pg-boss";
@@ -40,6 +47,7 @@ import type { Job } from "pg-boss";
import { prisma } from "@/lib/db";
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
import { annotate } from "@/lib/logging/context";
import { memoryPressureActive } from "@/lib/observability/container-memory";
import { emitSignal } from "@/lib/logging/signal";
export interface HeavyQueue {
@@ -155,7 +163,8 @@ const holdScope = new AsyncLocalStorage<LaneHold>();
*/
export function laneWanted(): boolean {
const current = holdScope.getStore();
return current !== undefined && current === hold && current.wantedBy !== null;
if (current === undefined || current !== hold) return false;
return current.wantedBy !== null || memoryPressureActive();
}
function reportLongWait(
@@ -201,6 +210,19 @@ export function heavyLane<J extends Job<unknown>>(
return async (jobs) => {
if (!jobs.some(inLane)) return handler(jobs);
const current = hold;
if (!current && memoryPressureActive()) {
const deferral = await defer(queue, jobs);
annotate({
action: { name: "job.heavy_lane.deferred" },
meta: {
queue,
holder: "memory_pressure",
jobs: jobs.length,
deferred: deferral.deferred,
},
});
return jobDone({ lane_deferred: deferral.deferred });
}
if (current) {
const deferral = await defer(queue, jobs);
if (queue !== current.queue) current.wantedBy ??= queue;
+96 -32
View File
@@ -17,27 +17,35 @@
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { createWorkerBoss, setGlobalBoss } from "@/lib/jobs/boss-instance";
import { reportJobsCutOffAtBoot } from "@/lib/jobs/job-observer";
import { beginBootStep, reportBootSteps } from "@/lib/boot/boot-steps";
import { assertSubsystemEnabled } from "@/lib/process-type";
import { DATABASE_URL, workerLog } from "./reminder/shared";
import {
registerShutdownHandlers,
reconcileImportJobsAtBoot,
probeIntegrationStatusAtBoot,
} from "./reminder/worker-lifecycle";
import {
registerIntegrationSyncQueues,
enqueueIntegrationSyncBootDiscovery,
} from "./reminder/register-integration-sync";
import { registerStatusQueues } from "./reminder/register-status";
import {
registerRollupQueues,
enqueueRollupBootDiscovery,
} from "./reminder/register-rollup";
import { registerReminderQueues } from "./reminder/register-reminders";
import {
registerMaintenanceQueues,
enqueueMaintenanceBootDiscovery,
} from "./reminder/register-maintenance";
import type { PgBoss } from "pg-boss";
/**
* Give the event loop a turn. Each registrar's module graph is evaluated in
* one synchronous stretch when it is first imported; loading the five one
* after another, with a turn in between, keeps the longest stall to the
* largest graph instead of the sum of all five, so requests and the health
* probe are answered in between.
*/
const yieldToLoop = () => new Promise<void>((resolve) => setImmediate(resolve));
/**
* Import one registrar module as its own boot step. The registrars and the
* handler graphs behind them are loaded here, after `boss.start()`, rather
* than when this module is imported.
*/
async function loadRegistrar<T>(
name: string,
load: () => Promise<T>,
): Promise<T> {
beginBootStep(`load_${name}`);
await yieldToLoop();
const mod = await load();
await yieldToLoop();
return mod;
}
export async function startReminderWorker() {
// v1.4: refuse to boot if the operator marked this container as
@@ -58,6 +66,7 @@ export async function startReminderWorker() {
// Read before any `boss.work` binding below can take a job: whatever is
// still active now was started by a process that is gone.
beginBootStep("boss_start");
const bootedAt = new Date();
await boss.start();
await reportJobsCutOffAtBoot(bootedAt);
@@ -65,20 +74,62 @@ export async function startReminderWorker() {
markWorkerStarted();
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT.
registerShutdownHandlers(boss);
const lifecycle = await loadRegistrar(
"worker_lifecycle",
() => import("./reminder/worker-lifecycle"),
);
lifecycle.registerShutdownHandlers(boss);
// Register every domain's queues (createQueue + schedule + boss.work). Each
// registrar returns the queue names it provisioned; the aggregate is the
// boot-level mirror of the per-registrar `allQueues` arrays the dead-queue
// guards pin. Order preserves the monolith's createQueue / schedule / work
// sequence (integration → status → rollup → reminders → maintenance).
const registeredQueues = [
...(await registerIntegrationSyncQueues(boss)),
...(await registerStatusQueues(boss)),
...(await registerRollupQueues(boss)),
...(await registerReminderQueues(boss)),
...(await registerMaintenanceQueues(boss)),
];
const integration = await loadRegistrar(
"integration_sync",
() => import("./reminder/register-integration-sync"),
);
const registeredQueues = await registerInStep(
"integration_sync",
boss,
integration.registerIntegrationSyncQueues,
);
const status = await loadRegistrar(
"status",
() => import("./reminder/register-status"),
);
registeredQueues.push(
...(await registerInStep("status", boss, status.registerStatusQueues)),
);
const rollup = await loadRegistrar(
"rollup",
() => import("./reminder/register-rollup"),
);
registeredQueues.push(
...(await registerInStep("rollup", boss, rollup.registerRollupQueues)),
);
const reminders = await loadRegistrar(
"reminders",
() => import("./reminder/register-reminders"),
);
registeredQueues.push(
...(await registerInStep(
"reminders",
boss,
reminders.registerReminderQueues,
)),
);
const maintenance = await loadRegistrar(
"maintenance",
() => import("./reminder/register-maintenance"),
);
registeredQueues.push(
...(await registerInStep(
"maintenance",
boss,
maintenance.registerMaintenanceQueues,
)),
);
workerLog(
"info",
`[reminder-worker] registered ${registeredQueues.length} queues across 5 domain registrars`,
@@ -86,22 +137,35 @@ export async function startReminderWorker() {
// v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the
// worker last shut down, flipping orphaned rows to `failed`.
await reconcileImportJobsAtBoot();
beginBootStep("boot_reconcile");
await lifecycle.reconcileImportJobsAtBoot();
// v1.4.48 — boot probe for legacy `integration_statuses` rows that still
// carry `consecutive_failures_by_kind = NULL`.
await probeIntegrationStatusAtBoot();
await lifecycle.probeIntegrationStatusAtBoot();
// Fire-and-forget boot discovery for every self-converging backfill /
// consolidation pass. Each is idempotent across reboots and never fails
// worker boot on a miss (errors come back through the helper's result).
await enqueueRollupBootDiscovery();
await enqueueIntegrationSyncBootDiscovery();
await enqueueMaintenanceBootDiscovery();
beginBootStep("boot_discovery");
await rollup.enqueueRollupBootDiscovery();
await integration.enqueueIntegrationSyncBootDiscovery();
await maintenance.enqueueMaintenanceBootDiscovery();
await reportBootSteps({ boot_queue_count: registeredQueues.length });
return boss;
}
/** Run one registrar as its own boot step. */
async function registerInStep(
name: string,
boss: PgBoss,
register: (boss: PgBoss) => Promise<readonly string[]>,
): Promise<string[]> {
beginBootStep(`register_${name}`);
return [...(await register(boss))];
}
// Run standalone
if (
process.argv[1]?.endsWith("reminder-worker.ts") ||
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/jobs/apple-health-import-worker", () => ({
reconcileOrphanImportJobs: vi.fn(),
}));
vi.mock("@/lib/jobs/integration-status-null-probe", () => ({
probeIntegrationStatusNullBuckets: vi.fn(),
}));
vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent: vi.fn() }));
vi.mock("../shared", () => ({ getWorkerPrisma: vi.fn(), workerLog: vi.fn() }));
import {
registerShutdownHandlers,
SHUTDOWN_JOB_TIMEOUT_MS,
} from "../worker-lifecycle";
function fakeProcess() {
const listeners = new Map<string, () => void>();
const exits: Array<number | string | null | undefined> = [];
const proc = {
once: (event: string, fn: () => void) => {
listeners.set(event, fn);
return proc;
},
exit: (code?: number | string | null) => {
exits.push(code);
},
};
return {
proc: proc as unknown as Pick<NodeJS.Process, "once" | "exit">,
signal: (name: string) => listeners.get(name)?.(),
exits,
};
}
describe("graceful shutdown", () => {
it("holds the process exit until running jobs have finished", async () => {
let finishJobs!: () => void;
const stopped = new Promise<void>((r) => (finishJobs = r));
const stop = vi.fn(() => stopped);
const fake = fakeProcess();
registerShutdownHandlers({ stop }, fake.proc);
fake.signal("SIGTERM");
expect(stop).toHaveBeenCalledWith({
graceful: true,
timeout: SHUTDOWN_JOB_TIMEOUT_MS,
});
// Next's handler closes the server and exits; the exit waits.
fake.proc.exit(143);
await new Promise((r) => setTimeout(r, 10));
expect(fake.exits).toEqual([]);
finishJobs();
await new Promise((r) => setTimeout(r, 0));
expect(fake.exits).toEqual([143]);
});
it("exits at the deadline when a job does not finish", async () => {
vi.useFakeTimers();
try {
const fake = fakeProcess();
registerShutdownHandlers(
{ stop: vi.fn(() => new Promise<void>(() => {})) },
fake.proc,
);
fake.signal("SIGTERM");
fake.proc.exit(143);
await vi.advanceTimersByTimeAsync(SHUTDOWN_JOB_TIMEOUT_MS);
expect(fake.exits).toEqual([]);
await vi.advanceTimersByTimeAsync(5_000);
expect(fake.exits).toEqual([143]);
} finally {
vi.useRealTimers();
}
});
it("drains once, whatever signals follow", () => {
const stop = vi.fn(() => Promise.resolve());
const fake = fakeProcess();
registerShutdownHandlers({ stop }, fake.proc);
fake.signal("SIGTERM");
fake.signal("SIGINT");
expect(stop).toHaveBeenCalledTimes(1);
});
});
+43 -18
View File
@@ -16,30 +16,55 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { getWorkerPrisma, workerLog } from "./shared";
/** How long running jobs get to finish on a stop signal. */
export const SHUTDOWN_JOB_TIMEOUT_MS = 30_000;
/**
* Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by Docker
* Compose `docker stop`, Kubernetes pod termination, Coolify redeploys).
* Without this, pending handlers were force-killed and could either be lost
* or replayed on next start. The listeners register once — re-entering the
* worker boot (e.g. on hot-reload in dev) is a no-op for the handlers because
* they capture `boss` by closure and the first signal stops everything.
*
* Next's own signal handler closes the HTTP server (finishing the requests
* in flight) and then calls `process.exit`. That exit used to win the race
* against `boss.stop`, so a running job was cut off mid-way and stayed
* `active` until its expiry, which kept an exclusive queue blocked. The
* handler therefore holds `process.exit` until the boss has stopped: both
* drains run side by side, the process leaves when the slower one is done,
* and never later than {@link SHUTDOWN_JOB_TIMEOUT_MS} plus a margin, so a
* stuck handler cannot block a deploy. Compose gives the container 45 s
* (`stop_grace_period`) for this.
*
* The listeners register once; re-entering the worker boot (hot reload in
* dev) is a no-op for them because the first signal stops everything.
*/
export function registerShutdownHandlers(boss: PgBoss): void {
let shutdownInProgress = false;
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
if (shutdownInProgress) return;
shutdownInProgress = true;
export function registerShutdownHandlers(
boss: Pick<PgBoss, "stop">,
proc: Pick<NodeJS.Process, "once" | "exit"> = process,
): void {
let drained: Promise<void> | null = null;
const onSignal = (signal: "SIGTERM" | "SIGINT") => {
if (drained) return;
workerLog("error", `received ${signal}, draining boss`);
try {
// graceful=true waits for active handlers to finish, then closes the
// pg connection pool. timeout cap so a stuck handler can't block deploys.
await boss.stop({ graceful: true, timeout: 30_000 });
} catch (err) {
workerLog("error", "boss.stop failed during shutdown", err);
}
// graceful=true waits for active handlers to finish, then closes the
// pg connection pool. The timeout caps it.
drained = boss
.stop({ graceful: true, timeout: SHUTDOWN_JOB_TIMEOUT_MS })
.catch((err: unknown) => {
workerLog("error", "boss.stop failed during shutdown", err);
});
const exit = proc.exit.bind(proc);
const deadline = new Promise<void>((resolve) =>
setTimeout(resolve, SHUTDOWN_JOB_TIMEOUT_MS + 5_000).unref(),
);
let exiting = false;
proc.exit = ((code?: number | string | null) => {
if (exiting) return undefined as never;
exiting = true;
void Promise.race([drained, deadline]).finally(() => exit(code));
return undefined as never;
}) as typeof proc.exit;
};
process.once("SIGTERM", () => void onSignal("SIGTERM"));
process.once("SIGINT", () => void onSignal("SIGINT"));
proc.once("SIGTERM", () => onSignal("SIGTERM"));
proc.once("SIGINT", () => onSignal("SIGINT"));
}
/**
+2 -2
View File
@@ -42,7 +42,7 @@ import {
getLabHistory,
resolveRichMetric,
MCP_METRIC_STATUS_DISCOVERY,
LOCALISED_METRIC_NAME_COLLISIONS,
localisedMetricNameCollisions,
metricStatusDiscoveryRows,
} from "../rich-reads";
import { locales } from "@/lib/i18n/config";
@@ -292,7 +292,7 @@ describe("resolveRichMetric — localised metric names", () => {
// the set is empty and pinned empty: a NEW collision fails here instead of
// silently resolving to whichever entry happened to be built first.
it("refuses a word two different metrics claim, and only those words", () => {
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([]);
expect([...localisedMetricNameCollisions()].sort()).toEqual([]);
});
// The property, not a sample: over every metric and every shipped locale,
+16 -8
View File
@@ -85,13 +85,21 @@ export const NUTRIENT_LABELS: Readonly<Record<NutrientCode, string>> =
* `locales`, so a seventh language starts resolving the moment its bundle
* lands, with no edit here.
*/
const NUTRIENT_NAME_INDEX = buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
let nutrientNameIndexCache:
ReturnType<typeof nutrientNameIndexBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function nutrientNameIndex(): ReturnType<typeof nutrientNameIndexBuild> {
return (nutrientNameIndexCache ??= nutrientNameIndexBuild());
}
function nutrientNameIndexBuild() {
return buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
}
/** Overview mode (no `nutrient` arg) mirrors `GET /api/nutrients`'s bounds. */
const OVERVIEW_DEFAULT_DAYS = 14;
@@ -148,7 +156,7 @@ export function resolveNutrientCode(input: string): NutrientCode | null {
if (!raw) return null;
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
if (isNutrientCode(key)) return key;
return NUTRIENT_NAME_INDEX.index.get(foldLabel(raw)) ?? null;
return nutrientNameIndex().index.get(foldLabel(raw)) ?? null;
}
/**
+13 -14
View File
@@ -551,16 +551,14 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
//
// Every OTHER measurement type is still entered, value-less, because
// ambiguity has to be judged over the whole label space rather than over the
// exposed slice of it. French and Italian once called body fat and fat mass
// by one phrase ("masse grasse" / "massa grassa") while only one of the two
// was exposed, so an index that knew about the exposed one alone would have
// seen no collision and answered kilograms to someone who asked for a
// percentage. Those labels are distinct now; the whole-space check stays so a
// future bundle cannot reintroduce the trap unseen.
//
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
// `vo2max` are protocol slugs, not translations of anything.
const LOCALISED_METRIC_INDEX = (() => {
// exposed slice of it: French and Italian once named body fat and fat mass
// alike while only one was exposed, which answered kilograms to a question
// about a percentage. The alias tables stay English (`resting_hr`, `spo2`
// are protocol slugs). Built on first use: the index reads every catalog.
let metricIndex: ReturnType<typeof buildLocalisedMetricIndex> | undefined;
const localisedMetricIndex = () =>
(metricIndex ??= buildLocalisedMetricIndex());
function buildLocalisedMetricIndex() {
const exposed = new Map<MeasurementType, RichMetric>();
const claim = (metric: RichMetric | null) => {
if (metric && !exposed.has(metric.measurementType)) {
@@ -579,7 +577,7 @@ const LOCALISED_METRIC_INDEX = (() => {
value: exposed.get(type as MeasurementType) ?? null,
}));
return buildLocalisedLabelIndex(entries);
})();
}
/**
* Words two different measurement types claim in some bundle, and which
@@ -587,8 +585,9 @@ const LOCALISED_METRIC_INDEX = (() => {
* is empty: a NEW member is a regression to look at rather than something to
* resolve by coin flip.
*/
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
LOCALISED_METRIC_INDEX.ambiguous;
export function localisedMetricNameCollisions(): ReadonlySet<string> {
return localisedMetricIndex().ambiguous;
}
/**
* Resolve a free-text metric name to a single scalar series. Forgiving for an
@@ -632,7 +631,7 @@ export function resolveRichMetric(input: string): RichMetric | null {
// on PULSE_WAVE_VELOCITY, because "pulse-wave velocity" contains "puls".
// A value-less hit (a metric this surface does not expose) falls through
// rather than short-circuiting, so the English behaviour below is untouched.
const localised = LOCALISED_METRIC_INDEX.index.get(foldLabel(raw));
const localised = localisedMetricIndex().index.get(foldLabel(raw));
if (localised) return localised;
// 5. English display-name substring match (e.g. "heart-rate variability").
@@ -0,0 +1,329 @@
/**
* The workout picker as it stood while duplicates were matched by fixed
* five-minute clock slots. Kept only as the reference the parity tests in
* `pick-canonical-workout-rows.property.test.ts` compare the duplicate window
* against: on every input where no two workouts of one sport within five
* minutes straddle a slot boundary, both must return the same rows.
*/
import type { MeasurementSource } from "@/generated/prisma/client";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/**
* Minimum row shape — same posture as `SourcePickerRow`. The helper
* only consults the three fields; downstream callers can read every
* other column they fetched alongside.
*/
export interface WorkoutPickerRow {
userId?: string;
startedAt: Date;
/**
* Session length. Together with `sportType` + `startedAt` this is the
* exact-session identity used to collapse repeated same-source sync rows.
* Optional only so fixtures can omit it; every production call site MUST
* select it, and `workout-canonical-identity-guard.test.ts` freezes that set.
*/
durationSec?: number;
sportType: string;
source: MeasurementSource;
avgHeartRate?: number | null;
maxHeartRate?: number | null;
metadata?: unknown;
}
/**
* HealthKit can resend the same session with a new external UUID. The source
* ladder correctly chooses Apple over WHOOP, but previously kept every Apple
* row in that winning bucket, which made the web list show the same workout
* two or three times.
*
* The identity is `(sportType, startedAt, durationSec)`. `endedAt` was part of
* this key when the collapse first shipped and has been dropped deliberately:
*
* - It is not a free extra check. On a workout with pauses, `endedAt` is the
* wall-clock end while `durationSec` counts only moving time, so the two
* legitimately disagree, and a stored row can carry either convention. Two
* re-sends of one session can therefore differ in `endedAt` alone, and the
* stricter key let that duplicate through.
* - It bought nothing in exchange. Nobody starts a second workout of the
* same sport in the same instant with the same duration, so start plus
* duration already identifies the session uniquely.
* - Requiring it split the callers. Four of the five call sites select
* `durationSec` but not `endedAt`, so the collapse ran on the workout list
* and silently did nothing for the Coach narration, the per-sport baseline
* and the deep-link cluster. One list, one number, two answers.
*
* A row without `durationSec` cannot be identified and passes through
* uncollapsed. That is a fixture-only path: every production call site selects
* the field and `workout-canonical-identity-guard.test.ts` keeps it that way.
*/
function exactSessionKey(row: WorkoutPickerRow): string | null {
if (row.durationSec == null) return null;
return [row.sportType, row.startedAt.getTime(), row.durationSec].join(":");
}
function rowRichness(row: WorkoutPickerRow): number {
return (
(row.avgHeartRate != null ? 2 : 0) +
(row.maxHeartRate != null ? 2 : 0) +
(row.metadata != null ? 1 : 0)
);
}
function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
const bySession = new Map<string, T>();
const passthrough: T[] = [];
for (const row of rows) {
const key = exactSessionKey(row);
if (key == null) {
passthrough.push(row);
continue;
}
const existing = bySession.get(key);
if (!existing || rowRichness(row) > rowRichness(existing)) {
bySession.set(key, row);
}
}
return [...passthrough, ...bySession.values()];
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
/**
* The cross-source duplicate slot a workout falls in. Exported for the one
* reader that has to look past the picked row: the strain engine, whose winner
* may carry no heart-rate series while its dropped twin does.
*/
export function legacyWorkoutDuplicateSlotKey(row: WorkoutPickerRow): string {
return bucketKey(row);
}
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
}
function jsonRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
const WHOOP_ZONE_DURATION_KEYS = [
"zone_zero_milli",
"zone_one_milli",
"zone_two_milli",
"zone_three_milli",
"zone_four_milli",
"zone_five_milli",
] as const;
function sanitizedWhoopZoneDurations(
metadata: unknown,
): Record<string, number> | null {
const raw = jsonRecord(jsonRecord(metadata)?.zoneDurations);
if (!raw) return null;
const sanitized: Record<string, number> = {};
for (const key of WHOOP_ZONE_DURATION_KEYS) {
const value = raw[key];
if (value === undefined) continue;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return null;
}
sanitized[key] = value;
}
return Object.keys(sanitized).length > 0 ? sanitized : null;
}
function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
winner: T,
donor: T,
): T {
const winnerMetadata = jsonRecord(winner.metadata);
const donorZones = sanitizedWhoopZoneDurations(donor.metadata);
const needsZones =
donorZones != null && winnerMetadata?.zoneDurations == null;
const needsAvg = winner.avgHeartRate == null && donor.avgHeartRate != null;
const needsMax = winner.maxHeartRate == null && donor.maxHeartRate != null;
if (!needsAvg && !needsMax && !needsZones) return winner;
return {
...winner,
...(needsAvg ? { avgHeartRate: donor.avgHeartRate } : {}),
...(needsMax ? { maxHeartRate: donor.maxHeartRate } : {}),
...(needsZones
? {
metadata: {
...(winnerMetadata ?? {}),
zoneDurations: donorZones,
},
}
: {}),
};
}
/**
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
* donor can never enrich two same-source occurrences that happen to share a
* fixed slot. Stable input indexes break equal-distance ties.
*/
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
winners: readonly T[],
donors: readonly T[],
): T[] {
if (winners.length === 0 || donors.length === 0) return [...winners];
const candidates: Array<{
winnerIndex: number;
donorIndex: number;
deltaMs: number;
}> = [];
winners.forEach((winner, winnerIndex) => {
donors.forEach((donor, donorIndex) => {
candidates.push({
winnerIndex,
donorIndex,
deltaMs: Math.abs(
winner.startedAt.getTime() - donor.startedAt.getTime(),
),
});
});
});
candidates.sort(
(left, right) =>
left.deltaMs - right.deltaMs ||
left.winnerIndex - right.winnerIndex ||
left.donorIndex - right.donorIndex,
);
const enriched = [...winners];
const matchedWinners = new Set<number>();
const matchedDonors = new Set<number>();
for (const candidate of candidates) {
if (
matchedWinners.has(candidate.winnerIndex) ||
matchedDonors.has(candidate.donorIndex)
) {
continue;
}
enriched[candidate.winnerIndex] = enrichFromWhoopTwin(
winners[candidate.winnerIndex],
donors[candidate.donorIndex],
);
matchedWinners.add(candidate.winnerIndex);
matchedDonors.add(candidate.donorIndex);
}
return enriched;
}
/**
* The source order for workouts: the `steps` metric's ladder, the
* canonical-activity bucket the Sources settings configure, falling back to
* its default when the user saved none or it does not parse.
*/
function workoutSourceLadder(
userPriorityJson: unknown,
): readonly MeasurementSource[] {
return getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
}
/**
* Pick canonical rows across sources for a user's workouts.
*
* @param rows input rows (deterministic order required)
* @param userPriorityJson the user's `sourcePriorityJson` blob — same
* shape the measurement picker consults.
* Falls back to the canonical default ladder
* when null / unparseable.
* @returns the subset of input rows that survives the picker. Insertion
* order preserved.
*/
export function legacyPickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
rows: readonly T[],
userPriorityJson: unknown = null,
): T[] {
if (rows.length === 0) return [];
const sourceLadder = workoutSourceLadder(userPriorityJson);
const buckets = new Map<
string,
{
rows: T[];
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
buckets.set(key, slot);
}
const canonical: T[] = [];
for (const [, slot] of buckets) {
if (slot.rows.length === 1) {
// No cross-source overlap → keep the single row regardless of
// ladder membership.
canonical.push(slot.rows[0]);
continue;
}
let picked: MeasurementSource | undefined;
for (const source of sourceLadder) {
if (slot.sources.has(source)) {
picked = source;
break;
}
}
if (!picked) {
// None of the bucket's sources are listed in the ladder — never
// silently drop. Keep every row in the bucket; downstream
// callers can choose to surface a "duplicate from unrecognised
// sources" diagnostic.
canonical.push(...slot.rows);
continue;
}
const winners = deduplicateWinningRows(
slot.rows.filter((row) => row.source === picked),
);
const whoopDonors =
picked === "WHOOP"
? []
: slot.rows.filter((row) => row.source === "WHOOP");
canonical.push(...enrichWinnersFromWhoop(winners, whoopDonors));
}
// Re-sort to preserve input order — the bucket map iterates in
// insertion order but the per-bucket fan-out can interleave rows
// from later buckets ahead of earlier-bucket survivors. Caller
// wants the same `startedAt ASC` order it passed in.
canonical.sort((a, b) => {
const delta = a.startedAt.getTime() - b.startedAt.getTime();
if (delta !== 0) return delta;
// Stable-tiebreak on sportType so the output is deterministic
// even when two surviving rows share startedAt (rare; manual
// entries on the same second).
return a.sportType.localeCompare(b.sportType);
});
return canonical;
}
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { canonicalWorkoutTrimps } from "@/lib/insights/strain-score";
import {
pickCanonicalWorkoutRows,
workoutDuplicateClusterKeys,
workoutSourceLadder,
} from "../pick-canonical-workout-rows";
import {
legacyPickCanonicalWorkoutRows,
legacyWorkoutDuplicateSlotKey,
} from "./pick-canonical-workout-rows.legacy";
type Source = "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "STRAVA";
interface Row {
id: string;
userId?: string;
startedAt: Date;
durationSec?: number;
sportType: string;
source: Source;
avgHeartRate: number | null;
maxHeartRate: number | null;
metadata: Record<string, unknown> | null;
}
const MIN = 60_000;
const WINDOW = 5 * MIN;
const SOURCES: Source[] = [
"APPLE_HEALTH",
"WHOOP",
"WITHINGS",
"MANUAL",
"STRAVA",
];
const SPORTS = ["running", "cycling", "walking"];
/** Small seeded generator, so a failure names a reproducible case. */
function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (lo: number, hi: number) => lo + Math.floor(next() * (hi - lo + 1)),
pick: <T>(list: readonly T[]) => list[Math.floor(next() * list.length)]!,
};
}
function randomRows(seed: number): Row[] {
const r = rng(seed);
const base = Date.UTC(2026, 4, 16, 6, 0, 0) + r.int(0, 86_400_000);
const count = r.int(1, 14);
const rows: Row[] = [];
for (let i = 0; i < count; i++) {
// Mostly near each other, so clusters form; sometimes a re-send of an
// earlier row (same start and duration) under another id.
const twin = i > 0 && r.next() < 0.2 ? rows[r.int(0, i - 1)]! : null;
const startedAt = twin
? new Date(
twin.startedAt.getTime() + (r.next() < 0.5 ? 0 : r.int(1, 90) * 1000),
)
: new Date(base + r.int(0, 40 * MIN));
rows.push({
id: `w-${seed}-${i}`,
userId: r.next() < 0.15 ? "other" : "u1",
startedAt,
durationSec:
r.next() < 0.1 ? undefined : (twin?.durationSec ?? r.int(10, 90) * 60),
sportType: twin && r.next() < 0.8 ? twin.sportType : r.pick(SPORTS),
source: r.pick(SOURCES),
avgHeartRate: r.next() < 0.5 ? r.int(100, 160) : null,
maxHeartRate: r.next() < 0.5 ? r.int(160, 190) : null,
metadata:
r.next() < 0.3
? { zoneDurations: { zone_two_milli: r.int(1, 60) * MIN } }
: null,
});
}
// Callers pass `startedAt ASC, id ASC`.
rows.sort(
(a, b) =>
a.startedAt.getTime() - b.startedAt.getTime() || a.id.localeCompare(b.id),
);
return rows;
}
/**
* True when some pair of one (user, sport) lies within the window but in two
* different fixed slots: the only inputs on which the old picker and the
* duplicate window may disagree.
*/
function straddlesASlot(rows: readonly Row[]): boolean {
for (let i = 0; i < rows.length; i++) {
for (let j = i + 1; j < rows.length; j++) {
const a = rows[i]!;
const b = rows[j]!;
if (a.userId !== b.userId || a.sportType !== b.sportType) continue;
if (Math.abs(a.startedAt.getTime() - b.startedAt.getTime()) >= WINDOW)
continue;
if (legacyWorkoutDuplicateSlotKey(a) !== legacyWorkoutDuplicateSlotKey(b))
return true;
}
}
return false;
}
const LADDERS: unknown[] = [
null,
{ steps: ["WITHINGS", "APPLE_HEALTH"] },
{ steps: ["WHOOP"] },
];
/** The strain fold as it stood on fixed slots, for the parity check. */
function legacyTrimps(
rows: readonly Row[],
ladderJson: unknown,
trimpOf: (r: Row) => number,
) {
const picked = new Set(
legacyPickCanonicalWorkoutRows(rows, ladderJson).map((r) => r.id),
);
const ladder = workoutSourceLadder(ladderJson);
const rankOf = (source: string) => {
const index = ladder.indexOf(source as (typeof ladder)[number]);
return index === -1 ? ladder.length : index;
};
const slots = new Map<
string,
{ picked: Map<string, number>; dropped: number }
>();
for (const row of rows) {
const key = legacyWorkoutDuplicateSlotKey(row);
const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 };
const t = trimpOf(row);
if (t > 0) {
if (picked.has(row.id))
slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + t);
else slot.dropped = Math.max(slot.dropped, t);
}
slots.set(key, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
let best: { rank: number; trimp: number } | null = null;
for (const [source, trimp] of slot.picked) {
const rank = rankOf(source);
if (
best === null ||
rank < best.rank ||
(rank === best.rank && trimp > best.trimp)
) {
best = { rank, trimp };
}
}
const trimp = best !== null ? best.trimp : slot.dropped;
if (trimp > 0) out.push(trimp);
}
return out;
}
describe("workout duplicate window", () => {
it("returns exactly the old rows wherever no pair straddles a slot boundary", () => {
let compared = 0;
for (let seed = 1; seed <= 4000; seed++) {
const rows = randomRows(seed);
if (straddlesASlot(rows)) continue;
compared++;
for (const ladder of LADDERS) {
expect(pickCanonicalWorkoutRows(rows, ladder), `seed ${seed}`).toEqual(
legacyPickCanonicalWorkoutRows(rows, ladder),
);
}
}
// The generator must actually produce comparable inputs, clusters
// included, or the loop above proves nothing.
expect(compared).toBeGreaterThan(1000);
});
it("gives the strain fold the same impulses wherever no pair straddles a slot", () => {
const trimpOf = (row: Row) => row.avgHeartRate ?? 0;
let compared = 0;
for (let seed = 1; seed <= 2000; seed++) {
const rows = randomRows(seed);
if (straddlesASlot(rows)) continue;
compared++;
for (const ladder of LADDERS) {
expect(
canonicalWorkoutTrimps(rows, ladder, trimpOf),
`seed ${seed}`,
).toEqual(legacyTrimps(rows, ladder, trimpOf));
}
}
expect(compared).toBeGreaterThan(500);
});
it("collapses two sources' copies of one session at any offset under five minutes", () => {
const r = rng(7);
let legacyMissed = 0;
for (let i = 0; i < 3000; i++) {
const start = Date.UTC(2026, 4, 16) + r.int(0, 86_400_000);
const offset = r.int(0, WINDOW - 1);
const rows: Row[] = [
{
id: "apple",
userId: "u1",
startedAt: new Date(start),
durationSec: 1800,
sportType: "running",
source: "APPLE_HEALTH",
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
{
id: "withings",
userId: "u1",
startedAt: new Date(start + offset),
durationSec: 1790,
sportType: "running",
source: "WITHINGS",
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
];
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
"apple",
]);
if (legacyPickCanonicalWorkoutRows(rows).length === 2) legacyMissed++;
}
// The fixed slots missed a share of these; the window misses none.
expect(legacyMissed).toBeGreaterThan(0);
});
it("never collapses two workouts five minutes or more apart", () => {
const r = rng(11);
for (let i = 0; i < 2000; i++) {
const start = Date.UTC(2026, 4, 16) + r.int(0, 86_400_000);
const gap = WINDOW + r.int(0, 30 * MIN);
const rows: Row[] = ["APPLE_HEALTH", "WITHINGS"].map((source, k) => ({
id: `w${k}`,
userId: "u1",
startedAt: new Date(start + k * gap),
durationSec: 1200,
sportType: "running",
source: source as Source,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
}));
expect(pickCanonicalWorkoutRows(rows)).toHaveLength(2);
}
});
it("does not depend on where the clock's five-minute marks fall", () => {
// Shifting every start by the same amount keeps every gap, so it must
// keep the picked set. The fixed slots broke this property.
for (let seed = 1; seed <= 1500; seed++) {
const rows = randomRows(seed);
const shift = rng(seed * 31).int(1, WINDOW - 1);
const shifted = rows.map((row) => ({
...row,
startedAt: new Date(row.startedAt.getTime() + shift),
}));
const ids = (list: Row[]) => list.map((row) => row.id).sort();
expect(ids(pickCanonicalWorkoutRows(shifted)), `seed ${seed}`).toEqual(
ids(pickCanonicalWorkoutRows(rows)),
);
}
});
it("anchors on the first start, so a row of workouts does not chain", () => {
const at = (m: number) => new Date(Date.UTC(2026, 4, 16, 8, m, 0));
const rows: Row[] = [0, 4, 8, 12].map((m, k) => ({
id: `w${m}`,
userId: "u1",
startedAt: at(m),
durationSec: 600,
sportType: "running",
source: (k % 2 === 0 ? "APPLE_HEALTH" : "WITHINGS") as Source,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
}));
const keys = workoutDuplicateClusterKeys(rows);
expect(new Set(keys).size).toBe(2);
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
"w0",
"w8",
]);
});
});
@@ -450,7 +450,12 @@ describe("pickCanonicalWorkoutRows", () => {
]);
});
it("does not chain neighbouring workouts across fixed canonical slots", () => {
it("does not chain neighbouring workouts past the first start's window", () => {
// `middle` starts four minutes after `first`: inside the duplicate
// window, so it is the same run from a lower-ranked source. `last` is
// eight minutes after `first`, outside the window measured from the
// cluster's first start, so it stays even though it is only four minutes
// after `middle`.
const rows: RowFixture[] = [
{
id: "first",
@@ -474,7 +479,6 @@ describe("pickCanonicalWorkoutRows", () => {
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
"first",
"middle",
"last",
]);
});
+177
View File
@@ -0,0 +1,177 @@
/**
* The two reads `POST /api/measurements/batch` makes up front about rows the
* batch may already hold, one statement each for the whole batch.
*
* Samples: a re-sent sample used to cost one interactive reconciler
* transaction (about ten round trips) only for the reconciler to answer
* `duplicate`. One read over the batch's sample ids answers it for every row
* at once (the route's exact-duplicate prefilter).
*
* `stats:` rows: the phone re-sends the same daily totals on every sync, and
* each went through the reconciler only to write the same values back, then
* counted as `updated`, which started the whole tail: the DAY refold, rollup
* jobs, PR detection and a cache stale for the account. Such a row is still
* reported `updated`, exactly as before, but nothing is written and it starts
* nothing. "Unchanged" is the reconciler's own update with nothing to change:
* a live row holds the entry's external key AND its natural identity (both
* indexes are unique, so that row is the only candidate), already carries the
* HealthKit-statistics provenance, and every column the update would write
* already has the value it would write. The route applies it only where
* neither key occurs a second time in the batch, and never to a value the
* plausibility gate would refuse. A concurrent writer is no different from
* one that ran a moment later: its write lands after this no-op either way.
*/
import type { PrismaClient } from "@/generated/prisma/client";
/** `type::source::externalId`, the external identity of a row. */
export function externalKeyOf(
type: unknown,
source: unknown,
externalId: unknown,
): string {
return `${type}::${source}::${externalId}`;
}
/** Stored rows (live or tombstoned) at the batch's sample ids. */
export async function readStoredSampleIdentities(
db: Pick<PrismaClient, "measurement">,
userId: string,
externalIds: readonly string[],
): Promise<Map<string, { measuredAt: Date; sleepStage: unknown }>> {
const out = new Map<string, { measuredAt: Date; sleepStage: unknown }>();
if (externalIds.length === 0) return out;
const rows = await db.measurement.findMany({
where: { userId, externalId: { in: [...externalIds] } },
select: {
type: true,
source: true,
externalId: true,
measuredAt: true,
sleepStage: true,
},
});
for (const row of rows) {
out.set(externalKeyOf(row.type, row.source, row.externalId), {
measuredAt: row.measuredAt,
sleepStage: row.sleepStage,
});
}
return out;
}
/** The columns the reconciler's update writes, besides the identity. */
const STATS_COLUMNS = [
"value",
"valueMin",
"valueMax",
"unit",
"sleepStage",
"externalSourceVersion",
"glucoseContext",
"rhythmClassification",
"deviceType",
] as const;
export type StoredStatsRow = Record<
(typeof STATS_COLUMNS)[number] | "aggregationProvenance",
unknown
> & { measuredAt: Date };
/** Live stored rows at the batch's `stats:` ids, with every written column. */
export async function readStoredStatsRows(
db: Pick<PrismaClient, "measurement">,
userId: string,
externalIds: readonly string[],
): Promise<Map<string, StoredStatsRow>> {
const out = new Map<string, StoredStatsRow>();
if (externalIds.length === 0) return out;
const rows = await db.measurement.findMany({
where: { userId, externalId: { in: [...externalIds] }, deletedAt: null },
select: {
type: true,
source: true,
externalId: true,
measuredAt: true,
aggregationProvenance: true,
...Object.fromEntries(STATS_COLUMNS.map((c) => [c, true])),
},
});
for (const row of rows as unknown as Array<
StoredStatsRow & { type: string; source: string; externalId: string }
>) {
out.set(externalKeyOf(row.type, row.source, row.externalId), row);
}
return out;
}
/**
* Whether the update a `stats:` entry would make to `stored` writes nothing
* new: same instant, the statistics provenance already, every column equal.
*/
export function statsWriteUnchanged(
stored: StoredStatsRow | undefined,
desired: Record<(typeof STATS_COLUMNS)[number], unknown> & {
measuredAt: Date;
},
): boolean {
if (!stored || !(stored.measuredAt instanceof Date)) return false;
if (stored.measuredAt.getTime() !== desired.measuredAt.getTime()) {
return false;
}
if (stored.aggregationProvenance !== "HEALTHKIT_STATISTICS") return false;
return STATS_COLUMNS.every(
(column) => (stored[column] ?? null) === (desired[column] ?? null),
);
}
/** The identity columns of one batch entry. */
export interface BatchKeyRow {
type?: unknown;
source?: unknown;
externalId?: unknown;
measuredAt?: unknown;
sleepStage?: unknown;
}
/** `type::source::epochMs::sleepStage`, the natural identity of a row. */
export function naturalKeyOf(row: BatchKeyRow): string {
return `${row.type}::${row.source}::${(row.measuredAt as Date).getTime()}::${row.sleepStage ?? "null"}`;
}
/**
* Whether an entry's external key and natural identity each occur once in
* its batch. Only such an entry may be decided ahead of the reconciler: an
* earlier entry sharing either key could move or retire the stored row
* before this one is reached.
*/
export function onceInBatch(
rows: readonly BatchKeyRow[],
): (row: BatchKeyRow) => boolean {
const external = new Map<string, number>();
const natural = new Map<string, number>();
for (const row of rows) {
const e = externalKeyOf(row.type, row.source, row.externalId);
external.set(e, (external.get(e) ?? 0) + 1);
const n = naturalKeyOf(row);
natural.set(n, (natural.get(n) ?? 0) + 1);
}
return (row) =>
external.get(externalKeyOf(row.type, row.source, row.externalId)) === 1 &&
natural.get(naturalKeyOf(row)) === 1;
}
/**
* Whether a sample entry is an exact duplicate of `stored`, the row at its
* external key: the same instant and sleep stage, which makes that row its
* natural match too, the reconciler's `duplicate`.
*/
export function sampleIsStoredDuplicate(
stored: { measuredAt: Date; sleepStage: unknown } | undefined,
row: BatchKeyRow,
): boolean {
return (
stored !== undefined &&
stored.measuredAt.getTime() === (row.measuredAt as Date).getTime() &&
(stored.sleepStage ?? null) === (row.sleepStage ?? null)
);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* The long-window read of a sample-dense series (pulse, CGM glucose, and the
* other dense kinds of `/api/measurements/series`): day or hour buckets in
* the account's zone, and the strip's figures over the raw readings.
*
* Both used to be separate statements over the same canonical rows, so a
* ten-year pulse read picked the canonical source per day and scanned the
* readings twice. Now the window's rows are materialised once (`src`) and
* both aggregates run over them in one statement, with exactly the SQL they
* had: `tests/integration/dense-series-read-parity.test.ts` holds the two old
* statements as the reference.
*
* Buckets: a day of a type with an activity-dependent sampling rate (pulse)
* is the weighted mean of its local hours' means, so a workout hour with
* hundreds of times the readings does not outweigh the rest of the day (see
* `day-statistic.ts`); the hour grain is already one mean per hour.
*
* Figures: aggregated over the raw rows, not the buckets, so the strip's
* mean, min, max, spread and count are what the raw path computes (`AVG`,
* `MIN`, `MAX`, `STDDEV_POP`, the exact row count). For an hourly-mean type
* the mean is the mean of the local days' values, each day the mean of its
* local hours' means, so it is the mean of the day points the chart draws;
* count, min, max and the spread stay over every reading.
*
* Parameters: `$1` the user (inside `seriesRows`), `$2` the grain, `$3` the
* zone, `$4` the window start; `seriesRows` is the FROM-clause subquery of
* `seriesRowsFrom`, its literals whitelisted there.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
dayWeightedRows,
hourWeightSql,
weightedHourMeanSql,
windowMeanSql,
zoneDayFrame,
} from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
export interface DenseBucketRow {
bucket_start: Date;
mean: number;
min_value: number;
max_value: number;
}
export interface DenseFigures {
n: number;
mean: number | null;
min: number | null;
max: number | null;
sd: number | null;
}
/** The bucket aggregate over `src`, in the original statement's SQL. */
export function denseBucketsSql(hourWeighted: boolean, from: string): string {
return hourWeighted
? `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
'hour',
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_hour
FROM ${from}
),
hourly AS (
SELECT
date_trunc($2, local_hour) AS local_day,
AVG("value") AS hour_mean,
COUNT(*) AS n,
MIN(COALESCE("value_min", "value")) AS min_value,
MAX(COALESCE("value_max", "value")) AS max_value
FROM localized
GROUP BY local_hour
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
${weightedHourMeanSql("hour_mean", hourWeightSql("n"))}::double precision AS mean,
MIN(min_value)::double precision AS min_value,
MAX(max_value)::double precision AS max_value
FROM hourly
GROUP BY local_day`
: `
WITH localized AS (
SELECT
m."value",
m."value_min",
m."value_max",
date_trunc(
$2,
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
) AS local_day
FROM ${from}
)
SELECT
local_day AT TIME ZONE $3 AS bucket_start,
AVG("value")::double precision AS mean,
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
MAX(COALESCE("value_max", "value"))::double precision AS max_value
FROM localized
GROUP BY local_day`;
}
/** The figures aggregate over the CTE `src`, in the original SQL. */
export function denseFiguresSql(hourlyMean: boolean): string {
return hourlyMean
? `
SELECT
COUNT(*)::int AS n,
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
GROUP BY m."type"`
: `
SELECT
COUNT(*)::int AS n,
AVG(m."value")::double precision AS mean,
MIN(m."value")::double precision AS min,
MAX(m."value")::double precision AS max,
STDDEV_POP(m."value")::double precision AS sd
FROM src m`;
}
/** Buckets and figures of one dense series window, in one statement. */
export async function readDenseSeries(args: {
userId: string;
type: MeasurementType;
grain: "day" | "hour";
userTz: string;
since: Date;
seriesRows: string;
}): Promise<{
bucketRows: DenseBucketRow[];
rawAgg: DenseFigures | undefined;
}> {
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
const rows = await prisma.$queryRawUnsafe<
Array<
{ kind: "bucket" | "figures" } & Partial<DenseBucketRow> &
Partial<DenseFigures>
>
>(
`
WITH src AS MATERIALIZED (
SELECT m.*
FROM ${args.seriesRows}
WHERE m."measured_at" >= $4
),
buckets AS (${denseBucketsSql(hourWeighted, "src m")}
),
figures AS (${denseFiguresSql(usesHourlyMeanDay(args.type))}
)
SELECT 'bucket' AS kind, b.bucket_start, b.mean, b.min_value, b.max_value,
NULL::int AS n, NULL::double precision AS min,
NULL::double precision AS max, NULL::double precision AS sd
FROM buckets b
UNION ALL
SELECT 'figures', NULL, f.mean, NULL, NULL, f.n, f.min, f.max, f.sd
FROM figures f
ORDER BY kind ASC, bucket_start ASC
`,
args.userId,
args.grain,
args.userTz,
args.since,
);
const bucketRows: DenseBucketRow[] = [];
let rawAgg: DenseFigures | undefined;
for (const row of rows) {
if (row.kind === "bucket") {
bucketRows.push({
bucket_start: row.bucket_start!,
mean: row.mean!,
min_value: row.min_value!,
max_value: row.max_value!,
});
} else {
rawAgg = {
n: row.n!,
mean: row.mean ?? null,
min: row.min ?? null,
max: row.max ?? null,
sd: row.sd ?? null,
};
}
}
return { bucketRows, rawAgg };
}
@@ -19,6 +19,10 @@
*
* Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
*/
import {
adoptNaturalKeyTwin,
storedUnchangedKeys,
} from "@/lib/measurements/import-unchanged-rows";
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import { StringDecoder } from "node:string_decoder";
@@ -856,6 +860,15 @@ export async function streamParseExportXml(
else createdByKey.set(key, [created]);
}
// Rows already stored as they are: counted, not written (see module).
const unchanged = await storedUnchangedKeys(
prisma,
userId,
chunk,
(r, i) =>
failedInsertIndexes.has(i) ||
createdByKey.has(`${r.type}::${r.externalId}`),
);
for (let index = 0; index < chunk.length; index += 1) {
const row = chunk[index];
const stat = bumpStat(row.type);
@@ -868,6 +881,11 @@ export async function streamParseExportXml(
}
const key = `${row.type}::${row.externalId}`;
if (unchanged.has(key)) {
stat.updated += 1;
rowsUpserted += 1;
continue;
}
const inserted = createdByKey.get(key)?.shift();
if (inserted) {
stat.inserted += 1;
@@ -906,38 +924,13 @@ export async function streamParseExportXml(
err instanceof Prisma.PrismaClientKnownRequestError &&
(err.code === "P2002" || err.code === "P2025")
) {
try {
const twin = await prisma.measurement.findFirst({
where: {
userId,
type: row.type,
source: "APPLE_HEALTH",
measuredAt: row.measuredAt,
sleepStage: row.sleepStage ?? null,
},
select: { id: true },
});
if (twin) {
await prisma.measurement.update({
where: { id: twin.id },
data: {
value: row.value,
unit: row.unit,
externalId: row.externalId,
externalSourceVersion: row.externalSourceVersion,
deviceType: row.deviceType,
deletedAt: null,
},
});
stat.updated += 1;
rowsUpserted += 1;
} else {
unknown[`${row.type}::natural_key_unresolved`] =
(unknown[`${row.type}::natural_key_unresolved`] ?? 0) + 1;
}
} catch {
unknown[`${row.type}::natural_key_rescue_failed`] =
(unknown[`${row.type}::natural_key_rescue_failed`] ?? 0) + 1;
const adopted = await adoptNaturalKeyTwin(prisma, userId, row);
if (adopted === "updated") {
stat.updated += 1;
rowsUpserted += 1;
} else {
unknown[`${row.type}::${adopted}`] =
(unknown[`${row.type}::${adopted}`] ?? 0) + 1;
}
} else {
unknown[`${row.type}::upsert_failed`] =
@@ -0,0 +1,124 @@
/**
* Rows of an Apple Health import chunk that the account already holds with
* exactly the values the import's update would write.
*
* A re-import of an export the account already holds sent one UPDATE per
* row the insert skipped, about two million statements for a large export,
* nearly all of them writing back what was stored. The import reads the
* stored rows of a chunk once and leaves those out; it still counts them as
* updated. A key that occurs twice in the chunk is never left out: its rows
* are written in order, as before, so the last one stands.
*/
import type {
MeasurementType,
PrismaClient,
SleepStage,
} from "@/generated/prisma/client";
export interface ImportUpdateRow {
type: MeasurementType;
externalId: string;
value: number;
measuredAt: Date;
externalSourceVersion: string | null;
sleepStage?: SleepStage | null;
deviceType: string | null;
}
/** `type::externalId` of every row whose update would change nothing. */
export async function storedUnchangedKeys(
db: Pick<PrismaClient, "measurement">,
userId: string,
chunk: readonly ImportUpdateRow[],
/** Rows the chunk already settled (inserted, or failed to insert). */
settled: (row: ImportUpdateRow, index: number) => boolean = () => false,
): Promise<Set<string>> {
const out = new Set<string>();
const rows = chunk.filter((row, index) => !settled(row, index));
if (rows.length === 0) return out;
const counts = new Map<string, number>();
for (const row of rows) {
const key = `${row.type}::${row.externalId}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const single = rows.filter(
(row) => counts.get(`${row.type}::${row.externalId}`) === 1,
);
if (single.length === 0) return out;
const stored = await db.measurement.findMany({
where: {
userId,
source: "APPLE_HEALTH",
externalId: { in: single.map((row) => row.externalId) },
},
select: {
type: true,
externalId: true,
value: true,
measuredAt: true,
externalSourceVersion: true,
sleepStage: true,
deviceType: true,
},
});
const byKey = new Map<string, (typeof stored)[number]>(
stored.map((row) => [`${row.type}::${row.externalId}`, row]),
);
for (const row of single) {
const key = `${row.type}::${row.externalId}`;
const was = byKey.get(key);
if (
was &&
was.value === row.value &&
was.measuredAt.getTime() === row.measuredAt.getTime() &&
(was.externalSourceVersion ?? null) ===
(row.externalSourceVersion ?? null) &&
(was.sleepStage ?? null) === (row.sleepStage ?? null) &&
(was.deviceType ?? null) === (row.deviceType ?? null)
) {
out.add(key);
}
}
return out;
}
/**
* The rescue when an imported row's update finds no row at its external id,
* or collides on the natural identity: a skipped insert can mean another
* external id holds the row's `(type, measuredAt, source, sleepStage)`.
* Adopt that row in place, as the old upsert rescue did. Answers what
* happened, as the import's per-type tally names it.
*/
export async function adoptNaturalKeyTwin(
db: Pick<PrismaClient, "measurement">,
userId: string,
row: ImportUpdateRow & { unit: string },
): Promise<"updated" | "natural_key_unresolved" | "natural_key_rescue_failed"> {
try {
const twin = await db.measurement.findFirst({
where: {
userId,
type: row.type,
source: "APPLE_HEALTH",
measuredAt: row.measuredAt,
sleepStage: row.sleepStage ?? null,
},
select: { id: true },
});
if (!twin) return "natural_key_unresolved";
await db.measurement.update({
where: { id: twin.id },
data: {
value: row.value,
unit: row.unit,
externalId: row.externalId,
externalSourceVersion: row.externalSourceVersion,
deviceType: row.deviceType,
deletedAt: null,
},
});
return "updated";
} catch {
return "natural_key_rescue_failed";
}
}
@@ -9,11 +9,14 @@
*
* Algorithm (v1.4.30 — matches the measurement ladder per R-F open
* question #4):
* 1. Bucket workouts by the rounded start timestamp + sport-type
* (`<5-minute slot>:<sport>`). Two HKWorkout entries for the same
* logical run start within a minute of each other on both
* sources; the rounding gives the cross-source matcher slack
* without collapsing distinct back-to-back workouts.
* 1. Cluster workouts per user and sport-type by start time: sorted by
* start, a workout joins the open cluster when it starts less than
* five minutes after the cluster's first workout, and opens a new
* one otherwise. Two HKWorkout entries for the same logical run
* start within a minute of each other on both sources; the window
* gives the cross-source matcher slack without collapsing distinct
* back-to-back workouts, and anchoring on the first start keeps a
* row of workouts a few minutes apart from chaining into one.
* 2. For each bucket, walk the source ladder (`workoutSourceLadder`)
* and pick the FIRST source that has any row in that bucket. Keep
* every row from the winning source; drop the rest.
@@ -115,30 +118,58 @@ function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
* The cross-source duplicate window. Two workouts of the same sport whose
* starts lie less than five minutes apart are treated as the "same" workout
* from different sources. iOS posts the HealthKit `startDate` rounded to the
* second while Withings stamps its `startdate` independently; five minutes
* gives both heads enough slack on a heavy-traffic morning sync without
* crossing the "two back-to-back runs" boundary (a user does NOT do two
* ten-minute runs five minutes apart in practice).
*
* This used to be a fixed five-minute clock slot (`floor(start / 5 min)`),
* so two copies of one session thirty seconds apart that straddled a slot
* boundary (08:04:50 and 08:05:20) were never compared. The window is now
* measured from the cluster's first start. Where no two workouts of one sport
* within the window straddle a slot boundary, the clusters are exactly the
* old slots: every pair inside one slot is less than five minutes apart, and
* a cluster cannot reach past its slot without such a pair.
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
const DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
/**
* The cross-source duplicate slot a workout falls in. Exported for the one
* reader that has to look past the picked row: the strain engine, whose winner
* may carry no heart-rate series while its dropped twin does.
* The duplicate cluster of every row, aligned with `rows`. Rows of different
* users or sports never share a cluster. Within one (user, sport), rows are
* taken in start order (input order on equal starts) and a row joins the open
* cluster while it starts less than {@link DUPLICATE_WINDOW_MS} after the
* cluster's first row. The key names the cluster by that first row's start,
* so it is stable for a given set of rows. Exported for the one reader that
* has to look past the picked row: the strain engine, whose winner may carry
* no heart-rate series while its dropped twin does.
*/
export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string {
return bucketKey(row);
}
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
export function workoutDuplicateClusterKeys(
rows: readonly WorkoutPickerRow[],
): string[] {
const groups = new Map<string, number[]>();
rows.forEach((row, index) => {
const group = `${row.userId ?? ""}:${row.sportType}`;
const list = groups.get(group);
if (list) list.push(index);
else groups.set(group, [index]);
});
const keys = new Array<string>(rows.length);
for (const [group, indexes] of groups) {
indexes.sort(
(a, b) =>
rows[a]!.startedAt.getTime() - rows[b]!.startedAt.getTime() || a - b,
);
let anchor = Number.NaN;
for (const index of indexes) {
const start = rows[index]!.startedAt.getTime();
if (!(start - anchor < DUPLICATE_WINDOW_MS)) anchor = start;
keys[index] = `${group}:${anchor}`;
}
}
return keys;
}
function jsonRecord(value: unknown): Record<string, unknown> | null {
@@ -205,7 +236,7 @@ function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
/**
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
* donor can never enrich two same-source occurrences that happen to share a
* fixed slot. Stable input indexes break equal-distance ties.
* duplicate cluster. Stable input indexes break equal-distance ties.
*/
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
winners: readonly T[],
@@ -293,8 +324,9 @@ export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const clusterKeys = workoutDuplicateClusterKeys(rows);
for (const [index, row] of rows.entries()) {
const key = clusterKeys[index]!;
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
@@ -64,7 +64,14 @@ function buildIndex(): Map<string, Glp1SideEffectTag> {
return index;
}
const TAG_INDEX = buildIndex();
let tagIndexCache: ReturnType<typeof tagIndexBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function tagIndex(): ReturnType<typeof tagIndexBuild> {
return (tagIndexCache ??= tagIndexBuild());
}
function tagIndexBuild() {
return buildIndex();
}
/**
* Test seam: the guard suite asserts the index covers every locale and that no
@@ -74,7 +81,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
string,
Glp1SideEffectTag
> {
return TAG_INDEX;
return tagIndex();
}
/**
@@ -89,7 +96,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
export function resolveGlp1SideEffectTag(
raw: string,
): Glp1SideEffectTag | null {
return TAG_INDEX.get(normaliseSideEffectTag(raw)) ?? null;
return tagIndex().get(normaliseSideEffectTag(raw)) ?? null;
}
/** What one `MoodEntry.tags` column contributed. */
+1 -1
View File
@@ -28,7 +28,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { findDrugIdByBrand } from "@/lib/medications/glp1-knowledge";
import { foldLabel } from "@/lib/i18n/localised-label-index";
import { foldLabel } from "@/lib/i18n/fold-label";
/** The medication classes the storyline can reason about (closed set). */
export type MedTargetClass = "antihypertensive" | "antidiabetic" | "glp1";
+2 -5
View File
@@ -36,7 +36,7 @@
* 0343 folded the blob's `coach` key into the switch and removed it, and a
* `coach` key that still turns up in the blob is ignored.
*/
import { prisma } from "@/lib/db";
import { readOperatorRow } from "@/lib/settings/operator-row";
import { getAssistantFlags } from "@/lib/feature-flags";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
@@ -84,10 +84,7 @@ export function resolveOperatorAvailability(
/** The raw availability blob; a read error reads as "nothing disabled". */
async function loadAvailabilityBlob(): Promise<unknown> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moduleAvailabilityJson: true },
});
const settings = await readOperatorRow();
return settings?.moduleAvailabilityJson ?? null;
} catch {
getEvent()?.addWarning(
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { InFlightRegistry } from "../in-flight";
import {
CONTAINER_BACKOFF_MAX_MS,
CONTAINER_MEMORY_TASK,
ContainerMemoryGuard,
memoryPressureActive,
readContainerMemory,
type ContainerMemoryReading,
} from "../container-memory";
const MB = 1024 * 1024;
function files(map: Record<string, string>) {
return (path: string) => {
if (!(path in map)) throw new Error(`ENOENT ${path}`);
return map[path]!;
};
}
describe("readContainerMemory", () => {
it("reads cgroup v2 and subtracts the reclaimable page cache", () => {
const reading = readContainerMemory(
files({
"/sys/fs/cgroup/memory.current": `${900 * MB}\n`,
"/sys/fs/cgroup/memory.max": `${1536 * MB}\n`,
"/sys/fs/cgroup/memory.stat": `anon 1\ninactive_file ${100 * MB}\nactive_file 5\n`,
}),
);
expect(reading).toEqual({ workingSet: 800 * MB, limit: 1536 * MB });
});
it("falls back to cgroup v1", () => {
const reading = readContainerMemory(
files({
"/sys/fs/cgroup/memory/memory.usage_in_bytes": `${500 * MB}`,
"/sys/fs/cgroup/memory/memory.limit_in_bytes": `${1024 * MB}`,
"/sys/fs/cgroup/memory/memory.stat": `total_inactive_file ${20 * MB}`,
}),
);
expect(reading).toEqual({ workingSet: 480 * MB, limit: 1024 * MB });
});
it("reads nothing without a limit or outside a cgroup", () => {
expect(
readContainerMemory(
files({
"/sys/fs/cgroup/memory.current": "123",
"/sys/fs/cgroup/memory.max": "max",
}),
),
).toBeNull();
expect(readContainerMemory(files({}))).toBeNull();
});
});
describe("ContainerMemoryGuard", () => {
function guard(readings: Array<ContainerMemoryReading | null>) {
let at = 0;
let i = 0;
const lines: Array<{ task: string; meta: Record<string, unknown> }> = [];
const g = new ContainerMemoryGuard({
now: () => at,
read: () => readings[Math.min(i++, readings.length - 1)] ?? null,
registry: new InFlightRegistry(),
emit: (task, _level, meta) => lines.push({ task, meta }),
figures: () => ({ heap_used_mb: 1 }),
});
return {
tick: (ms = 5_000) => {
at += ms;
g.check();
},
lines,
};
}
const reading = (ratio: number) => ({
workingSet: Math.round(ratio * 1000 * MB),
limit: 1000 * MB,
});
it("reports once at 85 % and holds heavy jobs back while it lasts", () => {
const g = guard([reading(0.5), reading(0.9), reading(0.9), reading(0.6)]);
g.tick();
expect(memoryPressureActive()).toBe(false);
g.tick();
expect(memoryPressureActive()).toBe(true);
expect(g.lines).toHaveLength(1);
expect(g.lines[0]!.task).toBe(CONTAINER_MEMORY_TASK);
expect(g.lines[0]!.meta).toMatchObject({
container_working_set_mb: 900,
container_limit_mb: 1000,
heap_used_mb: 1,
});
g.tick();
expect(g.lines).toHaveLength(1);
g.tick();
expect(memoryPressureActive()).toBe(false);
});
it("lets heavy jobs through once the pressure has lasted fifteen minutes", () => {
const g = guard([reading(0.9)]);
g.tick();
expect(memoryPressureActive()).toBe(true);
g.tick(CONTAINER_BACKOFF_MAX_MS);
expect(memoryPressureActive()).toBe(false);
});
it("never raises the flag outside a cgroup", () => {
const g = guard([null]);
g.tick();
expect(memoryPressureActive()).toBe(false);
expect(g.lines).toHaveLength(0);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { readFileSync } from "node:fs";
import { megabytes } from "@/lib/support/heap-limit";
import type { InFlightRegistry } from "./in-flight";
/**
* Container memory guard: the process's whole footprint against the cgroup
* limit, not just the V8 heap.
*
* The heap monitor sees the V8 heap only. A large share of this process's
* memory lives outside it: the GeoLite2 databases read into Buffers, the
* query compiler's WebAssembly memory, canvas and pdf.js for document
* thumbnails, zlib streams of a backup. When the cgroup runs out the kernel
* kills the process with SIGKILL and nothing is logged at all, however calm
* the heap line looked.
*
* Every five seconds (the heap monitor's tick) the guard reads the cgroup's
* working set: `memory.current` minus the reclaimable `inactive_file` page
* cache, the figure `docker stats` and the kubelet report. At 85 % of the
* limit it writes one `runtime.memory.pressure` line at `warn` with the work
* in flight, re-armed once the working set is back under 70 % and thirty
* minutes have passed, and raises a flag the heavy job lane reads to send
* new heavy jobs back to the queue for a minute instead of starting them
* (`memoryPressureActive`). A pressure that does not ease within fifteen
* minutes is taken as the container's normal size rather than a peak: the
* flag drops, so a container sized at its limit is never starved of its
* nightly jobs, and the pressure line has said so.
*
* Outside a cgroup with a limit (a developer machine, a host without one)
* the guard reads nothing and never raises the flag.
*/
export const CONTAINER_MEMORY_TASK = "runtime.memory.pressure";
export const CONTAINER_PRESSURE_RATIO = 0.85;
export const CONTAINER_REARM_RATIO = 0.7;
export const CONTAINER_PRESSURE_COOLDOWN_MS = 30 * 60_000;
/** How long new heavy jobs are held back before the pressure counts as normal. */
export const CONTAINER_BACKOFF_MAX_MS = 15 * 60_000;
export interface ContainerMemoryReading {
/** Working set: usage minus reclaimable page cache, bytes. */
workingSet: number;
/** The cgroup's limit, bytes. */
limit: number;
}
const UNLIMITED_BYTES = 1024 ** 4;
function readNumber(raw: string): number | null {
const text = raw.trim();
if (!/^\d+$/.test(text)) return null;
const n = Number(text);
return Number.isFinite(n) ? n : null;
}
function statField(stat: string, field: string): number {
const line = stat.split("\n").find((l) => l.startsWith(`${field} `));
return line ? (readNumber(line.slice(field.length + 1)) ?? 0) : 0;
}
/**
* The cgroup working set and limit, cgroup v2 first, then v1. `null` when
* there is no limit or the files cannot be read.
*/
export function readContainerMemory(
read: (path: string) => string = (path) => readFileSync(path, "utf8"),
): ContainerMemoryReading | null {
const layouts = [
{
usage: "/sys/fs/cgroup/memory.current",
limit: "/sys/fs/cgroup/memory.max",
stat: "/sys/fs/cgroup/memory.stat",
inactive: "inactive_file",
},
{
usage: "/sys/fs/cgroup/memory/memory.usage_in_bytes",
limit: "/sys/fs/cgroup/memory/memory.limit_in_bytes",
stat: "/sys/fs/cgroup/memory/memory.stat",
inactive: "total_inactive_file",
},
];
for (const layout of layouts) {
let usage: number | null;
let limit: number | null;
try {
usage = readNumber(read(layout.usage));
limit = readNumber(read(layout.limit));
} catch {
continue;
}
if (usage === null || limit === null) return null;
if (limit <= 0 || limit >= UNLIMITED_BYTES) return null;
let inactive = 0;
try {
inactive = statField(read(layout.stat), layout.inactive);
} catch {
// Without the stat file the usage stands as it is.
}
return { workingSet: Math.max(0, usage - inactive), limit };
}
return null;
}
const PRESSURE_FLAG = Symbol.for("healthlog.containerMemory.pressure");
type FlagSlot = { [PRESSURE_FLAG]?: boolean };
/**
* Whether new heavy jobs should wait: the working set is at or above 85 % of
* the limit and has not stayed there for fifteen minutes yet. Kept on
* `globalThis` because the lane and the guard can sit in different server
* bundles of one process.
*/
export function memoryPressureActive(): boolean {
return (globalThis as FlagSlot)[PRESSURE_FLAG] === true;
}
export interface ContainerMemoryDeps {
now: () => number;
read: () => ContainerMemoryReading | null;
registry: InFlightRegistry;
emit: (
taskName: string,
level: "info" | "warn",
meta: Record<string, unknown>,
) => void;
/** Extra figures for the line (the heap's). */
figures: () => Record<string, unknown>;
}
/** The guard's decisions, one `check()` per tick; separate for the tests. */
export class ContainerMemoryGuard {
private armed = true;
private lastReportAt: number | null = null;
private pressureSince: number | null = null;
constructor(private readonly deps: ContainerMemoryDeps) {}
/** The latest reading, for the heap line. */
last: ContainerMemoryReading | null = null;
check(): void {
const { now, read } = this.deps;
const at = now();
const reading = read();
this.last = reading;
const slot = globalThis as FlagSlot;
if (!reading) {
slot[PRESSURE_FLAG] = false;
return;
}
const ratio = reading.workingSet / reading.limit;
if (ratio >= CONTAINER_PRESSURE_RATIO) {
this.pressureSince ??= at;
slot[PRESSURE_FLAG] = at - this.pressureSince < CONTAINER_BACKOFF_MAX_MS;
} else {
this.pressureSince = null;
slot[PRESSURE_FLAG] = false;
}
if (
!this.armed &&
ratio < CONTAINER_REARM_RATIO &&
this.lastReportAt !== null &&
at - this.lastReportAt >= CONTAINER_PRESSURE_COOLDOWN_MS
) {
this.armed = true;
}
if (this.armed && ratio >= CONTAINER_PRESSURE_RATIO) {
this.armed = false;
this.lastReportAt = at;
const inFlight = this.deps.registry.report(at, 50);
this.deps.emit(CONTAINER_MEMORY_TASK, "warn", {
...this.deps.figures(),
container_working_set_mb: megabytes(reading.workingSet),
container_limit_mb: megabytes(reading.limit),
container_used_ratio: Math.round(ratio * 1000) / 1000,
pressure_ratio: CONTAINER_PRESSURE_RATIO,
heavy_jobs_held_back_max_minutes: CONTAINER_BACKOFF_MAX_MS / 60_000,
in_flight_oldest: inFlight.oldest,
in_flight_by_route: inFlight.by_route,
});
}
}
}
/** The working set and limit as heap-line fields, or nothing outside a cgroup. */
export function containerMemoryFields(
reading: ContainerMemoryReading | null,
): Record<string, number> {
if (!reading) return {};
return {
container_working_set_mb: megabytes(reading.workingSet),
container_limit_mb: megabytes(reading.limit),
};
}
+51 -6
View File
@@ -1,6 +1,10 @@
import { currentBootStep } from "@/lib/boot/boot-steps";
import { startupPhase } from "@/lib/boot/startup-state";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import { emitIfSampled } from "@/lib/logging/transports";
import { inFlightRegistry } from "./in-flight";
/**
* Event-loop lag monitor — the instance-level "was the PROCESS stalled"
* signal that per-request durations cannot carry.
@@ -24,6 +28,13 @@ import { emitIfSampled } from "@/lib/logging/transports";
* same timers phase, and the stall sample can vanish into the window
* that was just reset (reproduced under vitest).
*
* Attribution: a stall line says what the process was doing. It carries the
* process uptime, the background role's startup phase, the boot step that
* was running (`boot-steps.ts`), and the oldest requests and jobs in flight
* with the routes most of them share, the same report the heap pressure line
* writes. The monitor is the first thing `register()` starts, so the loading
* of the database client and of the worker graph is inside its view.
*
* The snapshot is published on a `globalThis` slot rather than imported
* by the event builder: the builder is bundled into the Edge-runtime
* proxy, and reading a global keeps the dependency one-directional
@@ -56,19 +67,52 @@ type GlobalSlots = {
[STARTED]?: boolean;
};
/** What a stall line says. */
export interface EventLoopStall {
lag_ms: number;
tick_interval_ms: number;
uptime_s: number;
startup_phase: string | null;
boot_step: string | null;
in_flight_oldest: unknown[];
in_flight_by_route: unknown[];
}
export interface EventLoopLagMonitorOptions {
tickIntervalMs?: number;
stallThresholdMs?: number;
/** Test seam — replaces the wide-event emit. */
emitStall?: (stall: { lag_ms: number; tick_interval_ms: number }) => void;
emitStall?: (stall: EventLoopStall) => void;
}
function defaultEmitStall(stall: {
lag_ms: number;
tick_interval_ms: number;
}): void {
/** The attribution a stall carries; never throws. */
export function stallAttribution(
now: number = Date.now(),
): Omit<EventLoopStall, "lag_ms" | "tick_interval_ms"> {
let inFlight: { oldest: unknown[]; by_route: unknown[] } = {
oldest: [],
by_route: [],
};
try {
inFlight = inFlightRegistry().report(now, 5);
} catch {
// The report is a garnish.
}
return {
uptime_s: Math.round(process.uptime() * 10) / 10,
startup_phase: startupPhase(now),
boot_step: currentBootStep(),
in_flight_oldest: inFlight.oldest,
in_flight_by_route: inFlight.by_route,
};
}
function defaultEmitStall(stall: EventLoopStall): void {
const evt = new WideEventBuilder("background");
evt.setBackground({ task_name: "event_loop_stall", result: stall });
evt.setBackground({
task_name: "event_loop_stall",
result: stall as unknown as Record<string, unknown>,
});
evt.elevateLevel("warn");
evt.finish();
emitIfSampled(evt.toJSON());
@@ -110,6 +154,7 @@ export function startEventLoopLagMonitor(
emitStall({
lag_ms: Math.round(lagMs),
tick_interval_ms: tickIntervalMs,
...stallAttribution(),
});
}
+23 -1
View File
@@ -11,6 +11,11 @@ import { WideEventBuilder } from "@/lib/logging/event-builder";
import { emitIfSampled } from "@/lib/logging/transports";
import { megabytes } from "@/lib/support/heap-limit";
import {
ContainerMemoryGuard,
containerMemoryFields,
readContainerMemory,
} from "./container-memory";
import { inFlightRegistry, type InFlightRegistry } from "./in-flight";
/**
@@ -351,10 +356,22 @@ export function startHeapMonitor(overrides: Partial<HeapMonitorDeps> = {}) {
slots[STARTED] = true;
const registry = overrides.registry ?? inFlightRegistry();
// The container guard rides the same tick, and its latest reading rides
// every heap line, so the trend shows the cgroup beside the heap.
const guard = new ContainerMemoryGuard({
now: Date.now,
read: () => readContainerMemory(),
registry,
emit: emitHeapLine,
figures: () => ({ ...defaultReadFigures(registry) }),
});
const monitor = new HeapMonitor({
now: Date.now,
readHeap: defaultReadHeap,
readFigures: () => defaultReadFigures(registry),
readFigures: () => ({
...defaultReadFigures(registry),
...containerMemoryFields(guard.last),
}),
registry,
emit: emitHeapLine,
snapshotEnabled: envFlag("HEALTHLOG_HEAP_SNAPSHOT"),
@@ -363,6 +380,11 @@ export function startHeapMonitor(overrides: Partial<HeapMonitorDeps> = {}) {
});
const timer = setInterval(() => {
try {
guard.check();
} catch {
// A diagnostic must never take the process down.
}
try {
monitor.check();
} catch {
@@ -115,10 +115,10 @@ describe("the provenance query orders like the canonical pick", () => {
`ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`,
),
);
// The shared pick groups to (type, day, source) first and orders the
// groups by the same keys, the UTC day named `d`.
expect(squash(canonicalMeasurementsCte(rank))).toContain(
squash(
`ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`,
),
squash(`ORDER BY "type", d, (${rank}), "source"::text`),
);
// Values are bound, never spliced.
expect(userId).toBe("u1");
+56
View File
@@ -0,0 +1,56 @@
/**
* The operator's settings that every app boot reads, in one statement per
* request.
*
* `/api/auth/me` resolved six answers out of the `AppSettings` singleton (the
* MFA policy, the Assistant switches, the module availability, the provider
* presence, the AI usage bounds and the reasoning controls), each with its own
* `SELECT` of a few columns, so one client boot read the same row six times.
* Each reader now takes its columns from this one read, memoised for the
* request (and read afresh for every call inside a background job, whose one
* event spans a whole multi-user pass, like the readers it serves).
*
* A reader keeps its own error handling: a failed read rejects here, and the
* memo forgets a rejected read so the next caller tries again.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { memoizePerRequest } from "@/lib/request-cache";
export const OPERATOR_ROW_SELECT = {
mfaRequired: true,
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
moduleAvailabilityJson: true,
adminAiKeyEncrypted: true,
adminAiModel: true,
adminAiBaseUrl: true,
adminCodexConnectionStatus: true,
adminCodexAccessTokenEncrypted: true,
adminCodexRefreshTokenEncrypted: true,
adminCodexAccountIdEncrypted: true,
aiUsageMaxLevel: true,
aiUsageDefaultLevel: true,
aiSyncRegenGapHours: true,
aiReasoningEnabled: true,
aiReasoningMaxEffort: true,
} as const satisfies Prisma.AppSettingsSelect;
export type OperatorRow = Prisma.AppSettingsGetPayload<{
select: typeof OPERATOR_ROW_SELECT;
}>;
export function readOperatorRow(): Promise<OperatorRow | null> {
return memoizePerRequest(
"operator-settings-row",
() =>
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: OPERATOR_ROW_SELECT,
}),
{ freshInBackground: true },
);
}