feat(moodlog): reverse-sync push HealthLog → MoodLog (v1.4.50)

Marc reported that mood entries logged inside HealthLog — specifically
via the iOS app — never reached MoodLog. The historic integration
polled MoodLog every 15 minutes and pulled new rows, but nothing
flowed the other direction. iOS users who tracked mood in HealthLog
ended up with one log per app and no overlap.

`src/lib/moodlog/push.ts` adds `pushMoodEntriesToMoodLog(userId,
entries)`. Best-effort reverse-sync push to MoodLog's new POST
`/api/integrations/health-log/mood` endpoint (shipped in MoodLog
v0.2.0). Same per-user moodLogUrlEncrypted + moodLogApiKeyEncrypted
credentials the pull side uses, same isPublicUrl SSRF guard, same
manual-redirect policy. Wraps every failure mode in a wide-event
warning so a network blip or a stale MoodLog deploy can never bubble
back to the user's mood-create request.

`POST /api/mood-entries` (single) and `POST /api/mood-entries/bulk`
now fire the push as a fire-and-forget side-effect after a successful
create. Entries with source === "MOODLOG" skip inside the helper to
avoid an echo loop. Bulk push only includes rows that landed as
`inserted` — duplicates and skips don't retry a known failure.

Pull side filters echoes:
`syncMoodLogEntries` now skips entries where MoodLog reports
`loggedVia: "HEALTHLOG"`. Those are reflections of rows we just
pushed; re-importing them would flip the source column from the
original attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG and
double-count in any source-segregated dashboard. An `echoSkipped`
wide-event annotation lets the operator confirm the round-trip is
closing at one hop.

Cross-repo:
The matching MoodLog v0.2.0 release adds the inbound POST handler
this push targets. Both apps must deploy together; MoodLog deploys
first so HealthLog never hits a 405 / 404 on the new endpoint.

Tests: 9 cases in src/lib/moodlog/__tests__/push.test.ts cover the
MOODLOG-source filter, no-credentials skip, SSRF refusal, headers +
body shape on the successful path, 5xx + network-reject + 3xx-
redirect failure modes, and the tags JSON-string → array key
normalisation. Pins the contract the call sites no longer await so
future helper refactors can't silently break the fire-and-forget
integration. HealthLog suite: 5279 pass, lint clean, typecheck clean.
This commit is contained in:
Marc-André Bombeck committed 2026-05-24 00:38:31 +02:00
1 parent 49c9b425bb
commit b6d90905ce
7 files changed
+496 -1

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push)
Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
### Added
- `src/lib/moodlog/push.ts` — `pushMoodEntriesToMoodLog(userId, entries)`. Best-effort reverse-sync push to MoodLog's new `POST /api/integrations/health-log/mood` endpoint. Same per-user `moodLogUrlEncrypted` + `moodLogApiKeyEncrypted` credentials the pull side uses, same `isPublicUrl` SSRF guard, same manual-redirect policy. Wraps every failure mode in a wide-event warning so a network blip or a stale MoodLog deploy can never bubble back to the user's mood-create request.
- `POST /api/mood-entries` (single) and `POST /api/mood-entries/bulk` now fire the push as a fire-and-forget side-effect after a successful create. Entries with `source === "MOODLOG"` skip inside the helper to avoid an echo loop. Bulk push only includes rows that landed as `inserted` — duplicates and skips don't retry a known failure.
### Changed
- `src/lib/moodlog/sync.ts` (pull side) filters out entries where MoodLog reports `loggedVia: "HEALTHLOG"`. Those are echoes of rows we just pushed; re-importing them would flip the `source` column from the original attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG and double-count in any source-segregated dashboard. An `echoSkipped` wide-event annotation lets the operator confirm the round-trip is closing at one hop.
### Tests
- `src/lib/moodlog/__tests__/push.test.ts` — 9 cases covering the MOODLOG-source filter, no-credentials skip, SSRF refusal, headers + body shape on the successful path, 5xx + network-reject + 3xx-redirect failure modes, and the `tags` JSON-string → array key normalisation. Pins the contract the call sites no longer await so future helper refactors can't silently break the fire-and-forget integration.
- HealthLog suite: 5279 unit + integration pass, lint clean, typecheck clean.
### Cross-repo coordination
The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/mood` handler this push targets. Both apps must deploy together for the round-trip to close — MoodLog first so HealthLog never hits a 405 / 404 on the new endpoint.
## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift
Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.49.4",
"version": "1.4.50",
"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",
+35
View File
@@ -46,6 +46,7 @@ import {
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -282,6 +283,40 @@ async function postBulk(request: NextRequest): Promise<Response> {
}
}
// v1.4.50 — reverse-sync the freshly-inserted batch to MoodLog so
// an iOS one-shot backfill surfaces in both apps. Duplicates and
// skips do NOT push (MoodLog already has them — or we'd just be
// retrying a known failure). The helper filters MOODLOG-sourced
// rows internally so a re-ingest of a MoodLog pull doesn't echo.
if (inserted > 0) {
const pushBatch: Array<{
date: string;
moodLoggedAt: Date;
mood: string;
note: string | null;
tags: string | null;
source: string;
}> = [];
for (let i = 0; i < entries.length; i++) {
if (results[i]?.status !== "inserted") continue;
const e = entries[i];
const tz = user.timezone ?? DEFAULT_TIMEZONE;
pushBatch.push({
date: moodDateKey(e.moodLoggedAt, tz),
moodLoggedAt: e.moodLoggedAt,
mood: e.mood,
note: e.note ?? null,
tags: e.tags ? JSON.stringify(e.tags) : null,
source: e.source,
});
}
if (pushBatch.length > 0) {
void pushMoodEntriesToMoodLog(user.id, pushBatch).catch(() => {
/* helper wraps errors in wide-event warnings; defence-in-depth */
});
}
}
return apiSuccess({
processed: entries.length,
inserted,
+25
View File
@@ -21,6 +21,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -198,6 +199,30 @@ async function postMoodEntry(request: NextRequest) {
});
}
// v1.4.50 — reverse-sync push to MoodLog. Fire-and-forget; the
// helper itself is best-effort and never throws, so a 502 from
// MoodLog or a transient network blip can never bubble back to
// the user's create. The pull side (15-min cron) backfills any
// entry that fails the push window. Entries with `source ===
// "MOODLOG"` skip inside the helper to avoid an echo loop.
void pushMoodEntriesToMoodLog(user.id, [
{
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
mood: entry.mood,
note: entry.note ?? null,
tags: entry.tags,
source: entry.source,
},
]).catch(() => {
// The helper already wraps its own errors in wide-event
// warnings. The void + .catch is defence-in-depth so a
// synchronous throw inside the helper's promise chain (e.g. a
// future refactor that adds an await on a rejected promise
// before the first try/catch) can't surface as an unhandled
// rejection in the Next.js runtime.
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
} catch (err) {
if (
+173
View File
@@ -0,0 +1,173 @@
/**
* v1.4.50 — reverse-sync push helper.
*
* The push side mirrors the pull (`sync.ts`) tier by tier: stored
* credentials → SSRF guard → bounded fetch → typed response folding.
* The tests pin the contract the iOS + web mood-entry create handlers
* rely on, so a future refactor of the helper can't silently break
* the fire-and-forget call sites that no longer await the result.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (v: string) => v.replace(/^enc:/, ""),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: (u: string) => /^https?:\/\/(?!127\.|localhost|10\.)/.test(u),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
}),
}));
import { pushMoodEntriesToMoodLog } from "../push";
import { prisma } from "@/lib/db";
const USER_OK = {
moodLogEnabled: true,
moodLogUrlEncrypted: "enc:https://moodlog.example.com",
moodLogApiKeyEncrypted: "enc:secret-key-123",
};
const ENTRY = {
date: "2026-05-24",
moodLoggedAt: new Date("2026-05-24T08:30:00Z"),
mood: "GUT",
note: "Felt productive after my run.",
tags: JSON.stringify(["exercise", "morning"]),
source: "MANUAL",
};
describe("pushMoodEntriesToMoodLog", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ imported: 1, updated: 0, failed: 0 }),
{ status: 200 },
),
);
globalThis.fetch = fetchMock as never;
vi.mocked(prisma.user.findUnique).mockResolvedValue(USER_OK as never);
});
it("skips entirely when no entries pass the MOODLOG filter", async () => {
const result = await pushMoodEntriesToMoodLog("user-1", [
{ ...ENTRY, source: "MOODLOG" },
]);
expect(result).toEqual({ pushed: 0, skipped: 1, status: "skipped" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips when the user has no moodLog credentials stored", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodLogEnabled: false,
moodLogUrlEncrypted: null,
moodLogApiKeyEncrypted: null,
} as never);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("skipped");
expect(fetchMock).not.toHaveBeenCalled();
});
it("refuses to push when the stored URL points at a non-public host", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
...USER_OK,
moodLogUrlEncrypted: "enc:http://127.0.0.1:3000",
} as never);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
expect(fetchMock).not.toHaveBeenCalled();
});
it("posts the entry to /api/integrations/health-log/mood with HEALTHLOG source", async () => {
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("ok");
expect(result.pushed).toBe(1);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/api/integrations/health-log/mood");
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer secret-key-123",
);
const body = JSON.parse(init.body as string) as {
entries: Array<{
source: string;
time: string;
date: string;
mood: string;
tags?: string[];
}>;
};
expect(body.entries).toHaveLength(1);
expect(body.entries[0].source).toBe("HEALTHLOG");
expect(body.entries[0].date).toBe("2026-05-24");
expect(body.entries[0].time).toBe("2026-05-24T08:30:00.000Z");
expect(body.entries[0].mood).toBe("GUT");
// Tags are parsed from the JSON-string column into an array of
// key strings the MoodLog endpoint accepts directly.
expect(body.entries[0].tags).toEqual(["exercise", "morning"]);
});
it("returns ok with pushed=0 when MoodLog responds 200 but no rows were accepted", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ imported: 0, updated: 0, failed: 1 }),
{ status: 200 },
),
);
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("ok");
expect(result.pushed).toBe(0);
expect(result.skipped).toBe(1);
});
it("reports failed on a 5xx without throwing", async () => {
fetchMock.mockResolvedValueOnce(new Response("upstream down", { status: 502 }));
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
expect(result.pushed).toBe(0);
});
it("reports failed on a network blip (fetch reject) without throwing", async () => {
fetchMock.mockRejectedValueOnce(new Error("ECONNRESET"));
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
expect(result.pushed).toBe(0);
});
it("refuses to follow a 3xx redirect (SSRF defence)", async () => {
fetchMock.mockResolvedValueOnce(new Response("", { status: 302 }));
const result = await pushMoodEntriesToMoodLog("user-1", [ENTRY]);
expect(result.status).toBe("failed");
});
it("omits the tags field when the column is null or malformed JSON", async () => {
await pushMoodEntriesToMoodLog("user-1", [
{ ...ENTRY, tags: null },
]);
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as { entries: Array<{ tags?: unknown }> };
expect("tags" in body.entries[0]).toBe(false);
fetchMock.mockClear();
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ imported: 1 }), { status: 200 }),
);
await pushMoodEntriesToMoodLog("user-1", [
{ ...ENTRY, tags: "not-valid-json{{" },
]);
const body2 = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
) as { entries: Array<{ tags?: unknown }> };
expect("tags" in body2.entries[0]).toBe(false);
});
});
+213
View File
@@ -0,0 +1,213 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* v1.4.50 — reverse-sync push: HealthLog → MoodLog.
*
* The historic sync direction (`syncMoodLogEntries` in `sync.ts`)
* polls MoodLog every 15 minutes and imports new rows. That's a
* one-way bridge: anything the user logs INSIDE HealthLog (the iOS
* app's mood reminder, the web form, a Telegram nudge handler) never
* reaches MoodLog. Marc's report: he taps a mood in iOS, the entry
* lands in HealthLog, but never appears in MoodLog's timeline.
*
* This helper closes that gap. Every successful create on
* `POST /api/mood-entries` and `POST /api/mood-entries/bulk` triggers
* a fire-and-forget push to MoodLog's `POST /api/integrations/health-
* log/mood` (added in MoodLog's matching release). The push:
*
* - Skips rows whose `source` is already `MOODLOG` — those came
* FROM MoodLog via the pull side, pushing them back would echo.
* - Uses the same per-user `moodLogUrlEncrypted` + `moodLogApiKey
* Encrypted` credentials the pull side reads, plus the same
* `isPublicUrl` SSRF guard.
* - Carries `mood`, `note`, `tags` (as key strings), and the
* original `moodLoggedAt` in the body's `time` field so MoodLog's
* upsert dedup key `(userId, date, createdAt)` stays consistent
* across both directions.
* - Tags the inbound entries with `source: "HEALTHLOG"` on the
* MoodLog side so the next pull cycle's `syncMoodLogEntries` can
* filter them out (avoiding an echo-loop where the same row
* bounces back as a MOODLOG-tagged entry).
*
* Failure mode: this is a best-effort push, intentionally NOT awaited
* by the create handler. Network errors, MoodLog being down, or
* MoodLog rejecting the payload all emit a wide-event warning but
* never bubble back to the user's create request. The next entry the
* user logs retries the push; if MoodLog has been down for hours, the
* user can hit "Force sync" in settings to backfill (TBD — for v1.5).
*/
export interface MoodEntryForPush {
/** YYYY-MM-DD, anchored to the user's display timezone. */
date: string;
/** ISO timestamp — `moodLoggedAt` from the HealthLog row. */
moodLoggedAt: Date;
mood: string;
note: string | null;
/** JSON-encoded string or null — pull side normalises to key array. */
tags: string | null;
/** v1.4.50 — only `MANUAL` / `WEB` / `TELEGRAM` / `iOS` push to MoodLog. */
source: string;
}
export async function pushMoodEntriesToMoodLog(
userId: string,
entries: ReadonlyArray<MoodEntryForPush>,
): Promise<{ pushed: number; skipped: number; status: "ok" | "skipped" | "failed" }> {
// Entries originating in MoodLog mustn't loop back. The pull side
// also filters incoming `HEALTHLOG`-tagged rows; this is the
// outbound mirror of that gate.
const candidates = entries.filter((e) => e.source !== "MOODLOG");
if (candidates.length === 0) {
return { pushed: 0, skipped: entries.length, status: "skipped" };
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
},
});
if (
!user?.moodLogEnabled ||
!user.moodLogUrlEncrypted ||
!user.moodLogApiKeyEncrypted
) {
return {
pushed: 0,
skipped: candidates.length,
status: "skipped",
};
}
const baseUrl = decrypt(user.moodLogUrlEncrypted);
const apiKey = decrypt(user.moodLogApiKeyEncrypted);
if (!isPublicUrl(baseUrl)) {
// Same SSRF guard pull side uses — a stored row pointing at an
// internal target must never receive the user's apiKey.
getEvent()?.addWarning(
`moodlog push refused for ${userId}: stored URL points at non-public host`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
const url = new URL("/api/integrations/health-log/mood", baseUrl);
const body = {
entries: candidates.map((e) => ({
date: e.date,
time: e.moodLoggedAt.toISOString(),
mood: e.mood,
note: e.note,
tags: parseTagKeys(e.tags),
source: "HEALTHLOG" as const,
})),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const start = performance.now();
let response: Response;
try {
response = await fetch(url.toString(), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
redirect: "manual",
});
} catch (err) {
clearTimeout(timeout);
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addExternalCall({
service: "moodlog",
method: "pushMoodEntriesToMoodLog",
duration_ms: Math.round(performance.now() - start),
});
getEvent()?.addWarning(
`moodlog push failed for ${userId} (fetch): ${message}`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
} finally {
clearTimeout(timeout);
}
getEvent()?.addExternalCall({
service: "moodlog",
method: "pushMoodEntriesToMoodLog",
duration_ms: Math.round(performance.now() - start),
status: response.status,
});
// 3xx — manual redirect mode surfaces them. Match the pull-side
// behaviour: a redirect from a public host that ended up pointing
// at an RFC1918 target would otherwise leak the apiKey.
if (response.status >= 300 && response.status < 400) {
getEvent()?.addWarning(
`moodlog push refused redirect for ${userId}: HTTP ${response.status}`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
if (!response.ok) {
getEvent()?.addWarning(
`moodlog push HTTP ${response.status} for ${userId}`,
);
return { pushed: 0, skipped: candidates.length, status: "failed" };
}
// Surface MoodLog's per-entry receipt counts so the dashboard
// admin diagnostic + Wide Event timeline can attribute partial
// failures correctly (e.g. an unknown tag key dropped on the
// MoodLog side reports `failed > 0` while imported/updated > 0).
type PushResult = {
imported?: number;
updated?: number;
failed?: number;
};
let result: PushResult | null = null;
try {
result = (await response.json()) as PushResult;
} catch {
// MoodLog should always return JSON; tolerate the parse failure
// so a stale upstream doesn't undo the successful HTTP write.
}
const imported = result?.imported ?? 0;
const updated = result?.updated ?? 0;
const failed = result?.failed ?? 0;
return {
pushed: imported + updated,
skipped: entries.length - candidates.length + failed,
status: "ok",
};
}
/**
* Parse the JSON-encoded `tags` column into an array of key strings.
* Returns an empty array on null / parse failure so the push body
* always carries a well-formed `tags: string[]` (or omitted entirely).
*/
function parseTagKeys(raw: string | null | undefined): string[] | undefined {
if (!raw) return undefined;
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
const keys = parsed
.filter((v): v is string => typeof v === "string" && v.length > 0)
.slice(0, 50);
return keys.length > 0 ? keys : undefined;
} catch {
return undefined;
}
}
+27
View File
@@ -195,7 +195,16 @@ export async function syncMoodLogEntries(
const entries = (data as { entries: unknown[] }).entries;
// 4. Upsert entries (note field is intentionally NOT imported)
//
// v1.4.50 — entries MoodLog re-exports with `loggedVia: "HEALTHLOG"`
// are echoes of rows HealthLog pushed via the reverse-sync POST.
// Re-importing them would flip the `source` column from the original
// attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG, losing the
// provenance the user already established and double-counting the
// entry in any source-segregated dashboard. Skip them on the pull
// side so the loop closes at one round-trip.
let imported = 0;
let echoSkipped = 0;
for (const e of entries) {
const entry = e as {
time: string;
@@ -203,7 +212,12 @@ export async function syncMoodLogEntries(
mood: string;
score: number;
tags?: string[];
loggedVia?: string;
};
if (entry.loggedVia === "HEALTHLOG") {
echoSkipped += 1;
continue;
}
try {
const moodLoggedAt = new Date(entry.time);
const date = entry.date;
@@ -245,6 +259,19 @@ export async function syncMoodLogEntries(
});
await recordSyncSuccess(userId, "moodlog");
// v1.4.50 — annotate the echo-skip count so an operator can see how
// many reverse-sync entries the pull side filtered out per cycle.
// Should match the user's recent `pushMoodEntriesToMoodLog` count;
// a divergent number signals either a stale MoodLog deploy
// (pre-HEALTHLOG-source) or a configuration drift.
if (echoSkipped > 0) {
getEvent()?.addExternalCall({
service: "moodlog",
method: "syncMoodLogEntries.echoSkipped",
duration_ms: 0,
});
}
// v1.4.39 W-MOOD — re-fold the rollup tier after a sync. The
// sync upserts a batch spanning many days; one bounded
// recompute is cheaper than firing N per-row hooks. Best-effort: