chore(release): v1.4.38.7

Rollup recompute observability + admin trigger.

- POST /api/admin/rollups/recompute synchronously folds one user or
  re-runs the boot-discovery loop on demand. Admin-gated.
- slim_summaries live-fallback now annotates `fallback_reason` and
  `missing_types[]` so the operator can map a slow cold-mount to
  the type missing DAY-bucket coverage without DB shell access.
- Boot-backfill discovery now logs every result, including the
  silent "no users to backfill" case — the previous shape made it
  impossible to tell "discovery ran cleanly + found nothing" apart
  from "discovery never executed".

See CHANGELOG.md.
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 06:27:02 +02:00
1 parent 80b1fcb8d0
commit 9cc5ececa7
5 files changed
+175 -2

No files matched your search

+36
View File
@@ -1,5 +1,41 @@
# Changelog
## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger
The v1.4.38.5 / v1.4.38.6 chain promised a fast-path recovery for
power-user accounts but gave the operator no way to confirm whether
the boot-time discovery had actually found a stranded user — the
success log only fired when `enqueued > 0 || skipped > 0`, so the
silent "found nothing" case looked identical to the silent "boot hook
never ran" case. And once the worker was up there was no way to
re-trigger discovery short of bouncing the container.
### Added
- **`POST /api/admin/rollups/recompute`** — operator-triggered ad-hoc
rollup recompute. Body `{ userId: string }` synchronously folds
one user (`recomputeUserRollups` awaited inside the request). Body
`{}` re-runs `enqueueBootTimeRollupBackfill()` to kick the
boot-discovery loop across every user. Admin gate via
`requireAdmin()` (cookie-only, never Bearer).
- **`fallback_reason` + `missing_types` annotate** on the
`slim_summaries` live-fallback path. When the rollup-fast-path
declines, the wide-event now reports exactly which measurement
types are missing DAY-bucket coverage so the operator can match
a slow `/api/analytics` cold-mount to the responsible type without
touching the DB.
### Changed
- **Boot-backfill discovery now logs every result, including the
silent "no users to backfill" case.** The line cost is one row per
worker boot; the operator gain is the ability to tell discovery
ran cleanly from discovery silently no-op'd.
### Operator notes
- No new migration. No env-var change.
## [1.4.38.6] — 2026-05-18 — Boot-backfill discovery SQL fix
The v1.4.38.5 discovery rewrite filtered the LEFT JOIN with
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.38.6",
"version": "1.4.38.7",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,111 @@
/**
* `POST /api/admin/rollups/recompute` — operator-triggered ad-hoc
* rollup recompute.
*
* Surface added in v1.4.38.7 after the boot-time backfill could not
* be re-triggered without a deploy. A power-user account whose
* `isFullyCovered(coverage)` probe returned `false` (typically
* because a brand-new measurement type landed between the last fold
* and the next analytics read) gets stranded on the live SQL
* aggregator until the next worker boot. This endpoint lets the
* operator force a fold for one user or kick the boot-discovery loop
* across every user without bouncing the worker container.
*
* Body:
* { userId?: string }
*
* - `userId` — single user mode. Synchronously calls
* `recomputeUserRollups(userId)` and returns the row count when
* the fold completes. The call is awaited inside the request, so
* the operator sees the duration in the response.
* - `userId` omitted — calls `enqueueBootTimeRollupBackfill()` to
* re-run the worker-boot discovery (`{ enqueued, skipped }` shape).
*
* Admin gate via `requireAdmin()` (cookie-only — Bearer never elevates
* to admin per the v1.4.25 security boundary).
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
enqueueBootTimeRollupBackfill,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
interface RecomputeBody {
userId?: string;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.rollups.recompute" } });
const { data: rawBody, error: jsonError } =
await safeJson<RecomputeBody>(request);
if (jsonError) return jsonError;
const body: RecomputeBody =
rawBody && typeof rawBody === "object" ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
: undefined;
if (userId) {
await auditLog("admin.rollups.recompute.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: userId, mode: "single" },
});
let result: Awaited<ReturnType<typeof recomputeUserRollups>>;
try {
result = await recomputeUserRollups(userId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return apiError(message, 500);
}
await auditLog("admin.rollups.recompute.complete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: userId, ...result },
});
return apiSuccess({
mode: "single",
targetUserId: userId,
rowsUpserted: result.rowsUpserted,
durationMs: result.durationMs,
});
}
await auditLog("admin.rollups.recompute.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { mode: "boot-discovery" },
});
const discovery = await enqueueBootTimeRollupBackfill();
await auditLog("admin.rollups.recompute.complete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { mode: "boot-discovery", ...discovery },
});
return apiSuccess({
mode: "boot-discovery",
enqueued: discovery.enqueued,
skipped: discovery.skipped,
error: discovery.error,
});
});
+20
View File
@@ -217,6 +217,26 @@ export async function computeSummariesSlice(
if (isFullyCovered(coverage)) {
return computeFromRollups(userId);
}
// v1.4.38.7 — annotate the live fallback with the per-type
// coverage map so the operator can see WHICH type stranded the
// request on the live aggregator. Without this the wide-event only
// says `path:"live"`, and "why" requires shell access to the DB.
const missing: string[] = [];
for (const [type, hasBuckets] of coverage.entries()) {
if (!hasBuckets) missing.push(type);
}
annotate({
meta: {
analytics: {
slim_summaries: {
fallback_reason: "partial_rollup_coverage",
missing_types: missing,
covered_count: coverage.size - missing.length,
total_types: coverage.size,
},
},
},
});
return computeFromLiveAggregate(userId);
}
+7 -1
View File
@@ -1960,7 +1960,13 @@ export async function startReminderWorker() {
"error",
`[rollup-full-backfill] boot discovery failed: ${error}`,
);
} else if (enqueued > 0 || skipped > 0) {
} else {
// v1.4.38.7 — log the discovery result on every boot, including
// the silent `enqueued=0 skipped=0` case. Without this, an
// operator chasing "analytics is slow" cannot tell whether the
// discovery query ran successfully (and found nothing to fold)
// vs. silently no-op'd. The line is one row per worker boot, so
// the log cost is negligible.
workerLog(
"info",
`[rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,