mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(measurements): reconcile dual identities atomically
This commit is contained in:
1 parent
f8b0a64d69
commit
a8ac376cf7
20 files changed
+1549
-797
No files matched your search
@@ -31,6 +31,62 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: async (
|
||||
tx: {
|
||||
measurement: {
|
||||
findMany: (args: unknown) => Promise<
|
||||
Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
source?: string;
|
||||
externalId?: string | null;
|
||||
}>
|
||||
>;
|
||||
createManyAndReturn: (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
updateMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown> & {
|
||||
userId: string;
|
||||
type: string;
|
||||
source: string;
|
||||
externalId: string;
|
||||
},
|
||||
options: { exactExternalMatch?: "update" | "duplicate" } = {},
|
||||
) => {
|
||||
const existing = await tx.measurement.findMany({});
|
||||
const exact = existing.find(
|
||||
(row: { type?: string; source?: string; externalId?: string | null }) =>
|
||||
row.type === input.type &&
|
||||
row.source === input.source &&
|
||||
row.externalId === input.externalId,
|
||||
);
|
||||
if (exact && options.exactExternalMatch !== "update") {
|
||||
return { status: "duplicate", row: exact };
|
||||
}
|
||||
if (exact) {
|
||||
await tx.measurement.updateMany({
|
||||
where: { id: exact.id },
|
||||
data: { ...input, deletedAt: null },
|
||||
});
|
||||
return { status: "updated", row: { ...exact, ...input } };
|
||||
}
|
||||
const [inserted] = await tx.measurement.createManyAndReturn({ data: input });
|
||||
if (inserted) return { status: "inserted", row: inserted };
|
||||
if (options.exactExternalMatch === "update") {
|
||||
await tx.measurement.updateMany({ where: {}, data: input });
|
||||
return { status: "updated", row: input };
|
||||
}
|
||||
return { status: "duplicate" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -159,12 +215,10 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
|
||||
// Stored as a PULSE row carrying the 10-min average value.
|
||||
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0][0];
|
||||
const rows = (createArg as { data: { type: string; value: number }[] })
|
||||
.data;
|
||||
expect(rows[0].type).toBe("PULSE");
|
||||
expect(rows[0].value).toBe(72);
|
||||
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0]![0]!.data as { type: string; value: number };
|
||||
expect(row.type).toBe("PULSE");
|
||||
expect(row.value).toBe(72);
|
||||
});
|
||||
|
||||
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
|
||||
@@ -176,16 +230,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: { value: number; valueMin: number; valueMax: number }[];
|
||||
}
|
||||
).data;
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBe(58);
|
||||
expect(rows[0].valueMax).toBe(96);
|
||||
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0]![0]!.data as {
|
||||
value: number;
|
||||
valueMin: number;
|
||||
valueMax: number;
|
||||
};
|
||||
expect(row.value).toBe(72);
|
||||
expect(row.valueMin).toBe(58);
|
||||
expect(row.valueMax).toBe(96);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -202,21 +255,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: {
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}[];
|
||||
}
|
||||
).data;
|
||||
// The trustworthy average survives; the invalid spread is dropped.
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0]![0]!.data as {
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
};
|
||||
expect(row.value).toBe(72);
|
||||
expect(row.valueMin).toBeNull();
|
||||
expect(row.valueMax).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -251,15 +298,13 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: { valueMin: number | null; valueMax: number | null }[];
|
||||
}
|
||||
).data;
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0]![0]!.data as {
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
};
|
||||
expect(row.valueMin).toBeNull();
|
||||
expect(row.valueMax).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
|
||||
@@ -445,12 +490,10 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await readJson(res);
|
||||
|
||||
// Exactly ONE row reaches the insert, carrying the NEWER value.
|
||||
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0][0];
|
||||
const rows = (createArg as { data: { value: number }[] }).data;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].value).toBe(78);
|
||||
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const row = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0]![0]!.data as { value: number };
|
||||
expect(row.value).toBe(78);
|
||||
|
||||
// The superseded entry is reported honestly, not as a phantom insert.
|
||||
expect(data.entries[0].status).toBe("duplicate");
|
||||
@@ -500,9 +543,7 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", (
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await readJson(res);
|
||||
|
||||
const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock
|
||||
.calls[0][0];
|
||||
expect((createArg as { data: unknown[] }).data).toHaveLength(2);
|
||||
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(2);
|
||||
expect(data.inserted).toBe(2);
|
||||
expect(data.duplicates).toBe(0);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,58 @@ vi.mock("@/lib/db", () => ({
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: async (
|
||||
tx: {
|
||||
measurement: {
|
||||
findMany: (args: unknown) => Promise<
|
||||
Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
source?: string;
|
||||
externalId?: string | null;
|
||||
}>
|
||||
>;
|
||||
createManyAndReturn: (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
updateMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown> & {
|
||||
userId: string;
|
||||
type: string;
|
||||
source: string;
|
||||
externalId: string;
|
||||
},
|
||||
options: { exactExternalMatch?: "update" | "duplicate" } = {},
|
||||
) => {
|
||||
const existing = await tx.measurement.findMany({});
|
||||
const exact = existing.find(
|
||||
(row: { type?: string; source?: string; externalId?: string | null }) =>
|
||||
row.type === input.type &&
|
||||
row.source === input.source &&
|
||||
row.externalId === input.externalId,
|
||||
);
|
||||
if (exact && options.exactExternalMatch !== "update") {
|
||||
return { status: "duplicate", row: exact };
|
||||
}
|
||||
if (exact) {
|
||||
await tx.measurement.updateMany({ where: { id: exact.id }, data: input });
|
||||
return { status: "updated", row: { ...exact, ...input } };
|
||||
}
|
||||
const [inserted] = await tx.measurement.createManyAndReturn({ data: input });
|
||||
if (inserted) return { status: "inserted", row: inserted };
|
||||
if (options.exactExternalMatch === "update") {
|
||||
await tx.measurement.updateMany({ where: {}, data: input });
|
||||
return { status: "updated", row: input };
|
||||
}
|
||||
return { status: "duplicate" };
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -190,12 +242,7 @@ describe("POST /api/measurements/batch — exact INSERT RETURNING identity", ()
|
||||
"duplicate",
|
||||
"duplicate",
|
||||
]);
|
||||
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skipDuplicates: true,
|
||||
select: { type: true, source: true, externalId: true },
|
||||
}),
|
||||
);
|
||||
expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(4);
|
||||
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [
|
||||
new Date(insertedSleepAt),
|
||||
]);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const reconcileOverrideMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
@@ -23,6 +25,26 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: async (
|
||||
tx: {
|
||||
measurement: {
|
||||
createManyAndReturn: (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown>,
|
||||
) => {
|
||||
const override = await reconcileOverrideMock(tx, input);
|
||||
if (override !== undefined) return override;
|
||||
const [inserted] = await tx.measurement.createManyAndReturn({ data: input });
|
||||
return inserted
|
||||
? { status: "inserted", row: inserted }
|
||||
: { status: "duplicate" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -118,6 +140,29 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a hard reconciliation failure without checkpointing it", async () => {
|
||||
reconcileOverrideMock.mockResolvedValueOnce({
|
||||
status: "failed",
|
||||
error: { message: "write rejected", code: "P2002" },
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await res.json()).toMatchObject({
|
||||
data: {
|
||||
processed: 1,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
duplicates: 0,
|
||||
failed: 1,
|
||||
entries: [{ index: 0, status: "failed", reason: "P2002" }],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
expect(enqueuePrDetection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets silent=true once the batch crosses the 50-entry threshold", async () => {
|
||||
const big = Array.from({ length: 51 }, (_, i) =>
|
||||
validStepEntry(`ext-${i}`),
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
oppositeMergeSource,
|
||||
type MergeCandidate,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -173,11 +174,15 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
|
||||
* we already have, the server overwrites the row's `value` rather than
|
||||
* dropping the new payload as a duplicate. Sample-class rows (every
|
||||
* other HK metric) keep the strict `duplicate` semantics because each
|
||||
* sample is a canonical, immutable reading. `skipped` is the only case
|
||||
* where the client may want to surface a diagnostic — typically because
|
||||
* Apple introduced a new identifier the server doesn't know about yet.
|
||||
* sample is a canonical, immutable reading. `skipped` is a validation no-op;
|
||||
* `failed` is a hard database verdict and must not be checkpointed as landed.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
type EntryStatus =
|
||||
| "inserted"
|
||||
| "updated"
|
||||
| "duplicate"
|
||||
| "skipped"
|
||||
| "failed";
|
||||
|
||||
// v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative
|
||||
// totals that the iOS HealthKit observer re-posts as the day progresses.
|
||||
@@ -372,47 +377,20 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
let failedCount = 0;
|
||||
// v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the
|
||||
// MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain
|
||||
// composite-key duplicate). Surfaced as a dedicated wide-event count so
|
||||
// an operator can see how often the standalone-pair mirror collapses.
|
||||
let crossSourceMergedCount = 0;
|
||||
// Exact prepared rows returned by INSERT ... RETURNING. This is the only
|
||||
// source for post-write arrival and sleep side effects: preflight checks can
|
||||
// lose a unique-key race after they run.
|
||||
// Only rows the reconciler actually inserted drive arrival side effects.
|
||||
const insertedPrepared: Prepared[] = [];
|
||||
const writtenIdentities: Array<{
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
|
||||
if (prepared.length > 0) {
|
||||
// Pre-flight duplicate detection classifies rows already present before
|
||||
// this request. The later INSERT ... RETURNING remains authoritative when
|
||||
// a competing request wins the unique-key race after this probe.
|
||||
// v1.8.6 W6 — `source` is now part of the dedup key, so the lookup
|
||||
// matches on (type, source, externalId) per incoming row rather than
|
||||
// a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an
|
||||
// `APPLE_HEALTH` row sharing an externalId are distinct, so each must
|
||||
// carry its source through the existence probe and the composite key.
|
||||
const incomingKeys = prepared.map((p) => ({
|
||||
type: p.row.type as Prisma.MeasurementCreateManyInput["type"],
|
||||
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
|
||||
externalId: p.row.externalId as string,
|
||||
}));
|
||||
|
||||
const existing = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
OR: incomingKeys.map((k) => ({
|
||||
type: k.type,
|
||||
source: k.source,
|
||||
externalId: k.externalId,
|
||||
})),
|
||||
},
|
||||
select: { type: true, source: true, externalId: true },
|
||||
});
|
||||
|
||||
const existingSet = new Set(
|
||||
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
|
||||
);
|
||||
|
||||
// v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH
|
||||
// mirror duplicate. When the iOS app logs a manual reading offline it
|
||||
// mirrors that reading into HealthKit; on pairing, adopt-on-pair
|
||||
@@ -485,20 +463,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intra-batch `stats:` supersession. Two entries carrying the same
|
||||
// (type, source, externalId) `stats:` key inside ONE batch both miss
|
||||
// `existingSet` — neither is in the DB yet — so the overwrite branch
|
||||
// never runs, both land in `toInsert`, and
|
||||
// `createMany({ skipDuplicates: true })` keeps whichever row the unique
|
||||
// index saw first. The OLDER snapshot of a per-day cumulative total (or
|
||||
// of a still-filling ten-minute heart-rate bucket) therefore won and the
|
||||
// newer, larger figure was dropped silently: both entries reported
|
||||
// `inserted`, the counters still summed, and the client checkpointed
|
||||
// past both. `stats:` rows carry overwrite semantics by contract — a
|
||||
// re-post replaces value / unit / measuredAt — so within one batch the
|
||||
// LAST entry for a key is the authoritative one. Resolve it here and
|
||||
// mark the superseded earlier entries `duplicate`, the same status the
|
||||
// client already checkpoints past for a composite-key duplicate.
|
||||
// `stats:` rows carry overwrite semantics and the last entry in one batch
|
||||
// is authoritative. Earlier snapshots are safe duplicates.
|
||||
const lastStatsIndexByKey = new Map<string, number>();
|
||||
for (const p of prepared) {
|
||||
if (!isStatsExternalId(p.row.externalId as string)) continue;
|
||||
@@ -508,57 +474,96 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
const toInsert: Prepared[] = [];
|
||||
const insertKeys = new Set<string>();
|
||||
const toOverwrite: Prepared[] = [];
|
||||
for (const p of prepared) {
|
||||
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
|
||||
if (
|
||||
isStatsExternalId(p.row.externalId as string) &&
|
||||
lastStatsIndexByKey.get(key) !== p.index
|
||||
) {
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (existingSet.has(key) || insertKeys.has(key)) {
|
||||
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
|
||||
// intentionally overwritten on a re-post so today's tile reflects
|
||||
// the latest HealthKit total instead of freezing at the
|
||||
// first-sync value. Sample-class rows keep the immutable
|
||||
// duplicate contract.
|
||||
if (isStatsExternalId(p.row.externalId as string)) {
|
||||
toOverwrite.push(p);
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
const externalId = p.row.externalId as string;
|
||||
const statsRow = isStatsExternalId(externalId);
|
||||
const key = `${p.row.type}::${p.row.source}::${externalId}`;
|
||||
if (statsRow && lastStatsIndexByKey.get(key) !== p.index) {
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "duplicate",
|
||||
reason: "superseded_in_batch",
|
||||
};
|
||||
duplicateCount++;
|
||||
continue;
|
||||
}
|
||||
} else if (
|
||||
!isStatsExternalId(p.row.externalId as string) &&
|
||||
hasSameReadingSibling(p)
|
||||
) {
|
||||
// v1.11.4 (iOS #2) — cross-source same-reading collapse. The
|
||||
// physical reading already exists under the opposite client
|
||||
// source; drop this mirror copy. Status stays `duplicate` so the
|
||||
// iOS sync cursor checkpoints past it exactly as it does for a
|
||||
// composite-key duplicate; the `reason` distinguishes it for ops.
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "duplicate",
|
||||
reason: "cross_source_merge",
|
||||
};
|
||||
duplicateCount += 1;
|
||||
crossSourceMergedCount += 1;
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
toInsert.push(p);
|
||||
insertKeys.add(key);
|
||||
// Track this row so a same-reading sibling later in the SAME
|
||||
// batch collapses against it.
|
||||
|
||||
if (!statsRow && hasSameReadingSibling(p)) {
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "duplicate",
|
||||
reason: "cross_source_merge",
|
||||
};
|
||||
duplicateCount++;
|
||||
crossSourceMergedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const verdict = await prisma.$transaction((tx) =>
|
||||
reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId: user.id,
|
||||
type: p.row.type as MeasurementType,
|
||||
value: p.row.value as number,
|
||||
valueMin: p.row.valueMin as number | null,
|
||||
valueMax: p.row.valueMax as number | null,
|
||||
unit: p.row.unit as string,
|
||||
source:
|
||||
p.row.source as Prisma.MeasurementUncheckedCreateInput["source"],
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
externalId,
|
||||
externalSourceVersion: p.row
|
||||
.externalSourceVersion as string | null,
|
||||
glucoseContext:
|
||||
p.row.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"],
|
||||
sleepStage:
|
||||
p.row.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"],
|
||||
rhythmClassification:
|
||||
p.row
|
||||
.rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"],
|
||||
deviceType: p.row.deviceType as string | null,
|
||||
},
|
||||
{ exactExternalMatch: statsRow ? "update" : "duplicate" },
|
||||
),
|
||||
);
|
||||
|
||||
switch (verdict.status) {
|
||||
case "inserted":
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
insertedCount++;
|
||||
insertedPrepared.push(p);
|
||||
writtenIdentities.push({
|
||||
type: p.row.type as MeasurementType,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
});
|
||||
break;
|
||||
case "updated":
|
||||
case "resurrected":
|
||||
results[p.index] = { index: p.index, status: "updated" };
|
||||
updatedCount++;
|
||||
writtenIdentities.push(
|
||||
{
|
||||
type: p.row.type as MeasurementType,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
},
|
||||
...(verdict.dirtyIdentities ?? []),
|
||||
);
|
||||
break;
|
||||
case "duplicate":
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount++;
|
||||
break;
|
||||
case "failed":
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "failed",
|
||||
reason: verdict.error.code ?? "write_failed",
|
||||
};
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMergeableSource(p.row.source as string)) {
|
||||
inBatchCandidates.push({
|
||||
type: p.row.type as MeasurementType,
|
||||
@@ -567,95 +572,6 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toOverwrite.length > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const p of toOverwrite) {
|
||||
await tx.measurement.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
// v1.8.6 W6 — scope the overwrite to the row's own source
|
||||
// so a `stats:*` re-post only touches the matching
|
||||
// (type, source, externalId) row.
|
||||
source: p.row
|
||||
.source as Prisma.MeasurementCreateManyInput["source"],
|
||||
type: p.row.type as MeasurementType,
|
||||
externalId: p.row.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: p.row.value as number,
|
||||
// v1.19.2 (iOS #34 extension) — overwrite the per-bucket
|
||||
// spread alongside `value` so a within-hour re-post (the
|
||||
// running mean + range shift as more samples land) replaces
|
||||
// both. Null for every non-HR-bucket `stats:` overwrite.
|
||||
valueMin: p.row.valueMin as number | null,
|
||||
valueMax: p.row.valueMax as number | null,
|
||||
unit: p.row.unit as string,
|
||||
measuredAt: p.row.measuredAt as Date,
|
||||
externalSourceVersion: p.row.externalSourceVersion as
|
||||
string | null,
|
||||
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
|
||||
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — the observer re-posts the day's canonical
|
||||
// total, so a `stats:` day-total row follows the
|
||||
// source-owned resurrect rule (mirrors Google / Fitbit).
|
||||
// SAMPLE-grain rows never reach this branch: their
|
||||
// tombstones stick (the iOS LWW reconciler propagates
|
||||
// HealthKit deletions and reports them as `duplicate`).
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
results[p.index] = { index: p.index, status: "updated" };
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
// PostgreSQL RETURNING identifies the exact winner when another request
|
||||
// races the same composite unique key. An aggregate insert count can say
|
||||
// how many rows landed but not which prepared rows won, which would make
|
||||
// arrival and morning-refresh side effects attach to arbitrary rows.
|
||||
const CHUNK = 200;
|
||||
const insertedIndexes = new Set<number>();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toInsert.length; i += CHUNK) {
|
||||
const chunk = toInsert.slice(i, i + CHUNK);
|
||||
const pendingByKey = new Map<string, Prepared>();
|
||||
for (const p of chunk) {
|
||||
pendingByKey.set(
|
||||
`${p.row.type}::${p.row.source}::${p.row.externalId}`,
|
||||
p,
|
||||
);
|
||||
}
|
||||
|
||||
const inserted = await tx.measurement.createManyAndReturn({
|
||||
data: chunk.map((p) => p.row),
|
||||
skipDuplicates: true,
|
||||
select: { type: true, source: true, externalId: true },
|
||||
});
|
||||
// Count only returned identities that map to an attempted row, so
|
||||
// aggregate counts and per-entry statuses share one source of truth.
|
||||
for (const row of inserted) {
|
||||
const preparedRow = pendingByKey.get(
|
||||
`${row.type}::${row.source}::${row.externalId}`,
|
||||
);
|
||||
if (!preparedRow) continue;
|
||||
insertedIndexes.add(preparedRow.index);
|
||||
insertedCount += 1;
|
||||
insertedPrepared.push(preparedRow);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const p of toInsert) {
|
||||
if (insertedIndexes.has(p.index)) continue;
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,6 +596,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
failed: failedCount,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +677,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
failed: failedCount,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -784,15 +702,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// by day so a 500-row Apple Health batch fires only one recompute per
|
||||
// type/day pair. Best-effort — a populator hiccup never fails ingest.
|
||||
try {
|
||||
const insertedKeys = collapseToTypeDayKeys(
|
||||
prepared.map((p) => ({
|
||||
type: p.row.type as MeasurementType,
|
||||
measuredAt:
|
||||
p.row.measuredAt instanceof Date
|
||||
? p.row.measuredAt
|
||||
: new Date(p.row.measuredAt as string),
|
||||
})),
|
||||
);
|
||||
const insertedKeys = collapseToTypeDayKeys(writtenIdentities);
|
||||
for (const k of insertedKeys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
@@ -851,12 +761,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
failed: failedCount,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
if (failedCount > 0) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
Reference in new issue
Block a user