mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Closed analytics blocks survive a write that says it reached no further back, so a writer that never invalidated left them on their pre-write values. The Apple Health and Health Connect imports, the CSV and JSON imports, the score engines, the WHOOP webhook, the Withings activity, sleep and ECG legs, the Oura history backfill and the sleep-segment sweep wrote measurements without touching the read caches; a long-history import showed the old all-time figures until the entries aged out. The shared post-mutation tail now marks the caches stale back to the oldest row it is handed, and the Withings and Oura legs that hand-rolled the same tail run through it. The two export imports share one tail that refolds the rollups, marks the caches stale back to the oldest sample written (all of them when none was) and re-warms the status assessments. A sweep that tombstones a sleep segment drops every cached block.
177 lines
6.0 KiB
TypeScript
177 lines
6.0 KiB
TypeScript
/**
|
|
* Every bulk writer of measurements marks the cached reads stale back to the
|
|
* oldest reading it wrote.
|
|
*
|
|
* A closed analytics block (a value over readings before a fixed instant)
|
|
* survives every write that says it reached no further back, so a writer that
|
|
* did not invalidate at all left the block on its pre-write value: an Apple
|
|
* Health export carrying years of history showed the old all-time figures
|
|
* until the entry aged out, and the next sync, reaching back only a day,
|
|
* spared it. Pinned here for the writers that had no invalidation: the Apple
|
|
* Health import worker, the shared post-mutation tail the CSV and JSON
|
|
* imports and the score engines rely on, the Oura upsert a history backfill
|
|
* writes through, and the sleep-segment sweep.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { strToU8, zipSync } from "fflate";
|
|
import type { Job } from "pg-boss";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
|
|
import { caches, type ServerCache } from "@/lib/cache/server-cache";
|
|
import {
|
|
APPLE_HEALTH_IMPORT_PARSER_REVISION,
|
|
handleAppleHealthImport,
|
|
_setWorkerPrismaForTests,
|
|
type AppleHealthImportPayload,
|
|
} from "@/lib/jobs/apple-health-import-worker";
|
|
import { upsertOuraMeasurements } from "@/lib/oura/sync";
|
|
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
|
import { sweepStaleSleepSegments } from "@/lib/sleep/sweep-stale-segments";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: vi.fn(() => null),
|
|
}));
|
|
|
|
const prisma = getPrismaClient();
|
|
const analytics = caches.analytics as ServerCache<{ v: number }>;
|
|
const USER = "user-bulk-writer-cache";
|
|
const DAY_MS = 86_400_000;
|
|
const NOW = Date.now();
|
|
const yearsAgo = (years: number) => new Date(NOW - years * 365 * DAY_MS);
|
|
|
|
/** A closed block over the readings before `before`, and an open entry. */
|
|
function seedCache(before: Date) {
|
|
const closed = `${USER}|pre-fold-remainder|${closedBlockKeyPart(before)}|x`;
|
|
const open = `${USER}|dashboard-snapshot|en`;
|
|
analytics.deleteByPrefix(`${USER}|`);
|
|
analytics.set(closed, { v: 1 });
|
|
analytics.set(open, { v: 2 });
|
|
return { closed, open };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
_setWorkerPrismaForTests(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: USER,
|
|
email: `${USER}@example.test`,
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
});
|
|
|
|
function record(at: Date, value: number): string {
|
|
const stamp = at.toISOString().replace("T", " ").slice(0, 19) + " +0000";
|
|
return `<Record type="HKQuantityTypeIdentifierBodyMass" unit="kg" startDate="${stamp}" endDate="${stamp}" value="${value}" sourceName="Scale"/>`;
|
|
}
|
|
|
|
async function runImport(xmlRecords: string[]) {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<HealthData locale="en_US">
|
|
${xmlRecords.join("\n ")}
|
|
</HealthData>`;
|
|
const dir = mkdtempSync(join(tmpdir(), "hl-import-cache-"));
|
|
const path = join(dir, "export.zip");
|
|
const bytes = Buffer.from(
|
|
zipSync({ "apple_health_export/export.xml": strToU8(xml) }),
|
|
);
|
|
writeFileSync(path, bytes);
|
|
await prisma.importJob.create({
|
|
data: {
|
|
userId: USER,
|
|
pgBossJobId: "cache-job",
|
|
status: "queued",
|
|
uploadBytes: bytes.length,
|
|
uploadSha256: createHash("sha256").update(bytes).digest("hex"),
|
|
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
|
|
},
|
|
});
|
|
const outcome = await handleAppleHealthImport({
|
|
id: "cache-job",
|
|
data: {
|
|
userId: USER,
|
|
uploadPath: path,
|
|
uploadBytes: bytes.length,
|
|
enqueuedAt: new Date().toISOString(),
|
|
},
|
|
} as unknown as Job<AppleHealthImportPayload>);
|
|
expect(outcome.ok).toBe(true);
|
|
}
|
|
|
|
describe("bulk writers invalidate the measurement read caches", () => {
|
|
it("the Apple Health import marks stale every block its oldest sample reaches", async () => {
|
|
const reached = seedCache(yearsAgo(1));
|
|
const older = `${USER}|pre-fold-remainder|${closedBlockKeyPart(yearsAgo(9))}|x`;
|
|
analytics.set(older, { v: 3 });
|
|
|
|
await runImport([record(yearsAgo(7), 88), record(yearsAgo(0.1), 84)]);
|
|
|
|
expect(analytics.get(reached.closed)).toBeNull();
|
|
expect(analytics.get(reached.open)).toBeNull();
|
|
// A block of readings older than anything imported stays.
|
|
expect(analytics.get(older)).toEqual({ v: 3 });
|
|
});
|
|
|
|
it("the shared post-mutation tail invalidates back to its oldest row", async () => {
|
|
const cache = seedCache(yearsAgo(1));
|
|
await afterMeasurementMutation(
|
|
USER,
|
|
[{ type: "WEIGHT", measuredAt: yearsAgo(3) }],
|
|
{ source: "manual" },
|
|
);
|
|
expect(analytics.get(cache.closed)).toBeNull();
|
|
expect(analytics.get(cache.open)).toBeNull();
|
|
});
|
|
|
|
it("the Oura upsert a history backfill writes through invalidates", async () => {
|
|
const cache = seedCache(yearsAgo(1));
|
|
const written = await upsertOuraMeasurements(
|
|
USER,
|
|
[
|
|
{
|
|
type: "RESTING_HEART_RATE",
|
|
value: 52,
|
|
unit: "bpm",
|
|
measuredAt: yearsAgo(2),
|
|
externalId: "oura:test:resting:1",
|
|
},
|
|
],
|
|
{ emitArrivals: false },
|
|
);
|
|
expect(written).toBe(1);
|
|
expect(analytics.get(cache.closed)).toBeNull();
|
|
});
|
|
|
|
it("a sleep-segment sweep that tombstones a row invalidates", async () => {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: USER,
|
|
type: "SLEEP_DURATION",
|
|
value: 60,
|
|
unit: "min",
|
|
source: "OURA",
|
|
measuredAt: yearsAgo(2),
|
|
externalId: "night-1:stale",
|
|
},
|
|
});
|
|
const cache = seedCache(yearsAgo(1));
|
|
const removed = await sweepStaleSleepSegments(USER, "OURA", [
|
|
{ prefix: "night-1:", keepIds: ["night-1:fresh"] },
|
|
]);
|
|
expect(removed).toBe(1);
|
|
expect(analytics.get(cache.closed)).toBeNull();
|
|
});
|
|
});
|