mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading.
This commit is contained in:
1 parent
1085a1e056
commit
9c63d3ce20
79 files changed
+3221
-564
No files matched your search
@@ -103,7 +103,11 @@ beforeEach(() => {
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
measurement.findMany.mockResolvedValue([]);
|
||||
measurement.createMany.mockResolvedValue({ count: 0 });
|
||||
// Echo the attempted chunk size, matching Postgres semantics when no row
|
||||
// collides — the route now reconciles `inserted` against this count.
|
||||
measurement.createMany.mockImplementation(
|
||||
async (arg: { data: unknown[] }) => ({ count: arg.data.length }),
|
||||
);
|
||||
measurement.updateMany.mockResolvedValue({ count: 0 });
|
||||
(
|
||||
prisma.$transaction as unknown as {
|
||||
@@ -226,6 +230,29 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resurrects a tombstoned externalId row on re-import (deletedAt: null in update)", async () => {
|
||||
// The ext-probe is deliberately deletedAt-less, so a tombstoned IMPORT
|
||||
// row matches like a live one; the update must carry the resurrection
|
||||
// (IMPORT rows are re-importable by design).
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", externalId: "ext-tomb" },
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,81.0,kg,2026-05-01T08:00:00Z,,,ext-tomb"].join("\n"),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.updated).toBe(1);
|
||||
expect(body.data?.rows[0].status).toBe("updated");
|
||||
const updateArg = mMeasurement().updateMany.mock.calls[0][0] as {
|
||||
data: { value: number; deletedAt: Date | null };
|
||||
};
|
||||
expect(updateArg.data.value).toBe(81.0);
|
||||
expect(updateArg.data.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("inserts an externalId row when it does not exist yet", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([]);
|
||||
const res = await POST(
|
||||
@@ -274,4 +301,33 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
// Only the first survivor reaches the bulk insert.
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconciles `inserted` against the createMany count when skipDuplicates absorbs a race", async () => {
|
||||
// Two fresh rows attempted, but a concurrent double-submit already
|
||||
// landed one of them — `skipDuplicates` absorbs the conflict and the
|
||||
// count comes back short. The envelope must sum to the DB truth: one
|
||||
// inserted, one downgraded to skipped/duplicate.
|
||||
mMeasurement().createMany.mockImplementation(
|
||||
async (arg: { data: unknown[] }) => ({ count: arg.data.length - 1 }),
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[
|
||||
HEADER,
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
|
||||
"WEIGHT,81.5,kg,2026-05-01T09:00:00Z,,,",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.skipped).toBe(1);
|
||||
const statuses = body.data?.rows.map((r) => r.status).sort();
|
||||
expect(statuses).toEqual(["inserted", "skipped"]);
|
||||
expect(body.data?.rows.find((r) => r.status === "skipped")?.reason).toBe(
|
||||
"duplicate",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -239,12 +239,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
const CREATE_CHUNK = 200;
|
||||
let createdCount = 0;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
|
||||
await tx.measurement.createMany({
|
||||
const res = await tx.measurement.createMany({
|
||||
data: toCreate.slice(i, i + CREATE_CHUNK),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
createdCount += res.count;
|
||||
}
|
||||
for (const m of extUpdates.values()) {
|
||||
await tx.measurement.updateMany({
|
||||
@@ -262,11 +264,37 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reconcile `inserted` against what `createMany` actually wrote
|
||||
// (mirrors the batch route's raced-duplicate downgrade). Under
|
||||
// `skipDuplicates` a conflicting row is silently absorbed — the key is
|
||||
// present in the table either way, so we cannot identify the SPECIFIC
|
||||
// raced lines; we only need the counters and per-line statuses to sum
|
||||
// to the truth. Downgrade enough `inserted` lines to `duplicate` so
|
||||
// the envelope matches the DB write count.
|
||||
const racedDuplicates = toCreate.length - createdCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const [line, outcome] of writeOutcome) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (outcome === "inserted") {
|
||||
writeOutcome.set(line, "duplicate");
|
||||
inserted--;
|
||||
skipped++;
|
||||
downgraded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
|
||||
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
|
||||
// Best-effort: a populator hiccup never fails the importer.
|
||||
|
||||
Reference in new issue
Block a user