fix(import): resolve legacy mood timestamp collisions

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 13:35:37 +02:00
1 parent cd6b5f0cc6
commit 7eb3b91925
2 files changed
+225 -82

No files matched your search

+101 -4
View File
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
moodEntry: { create: vi.fn(), upsert: vi.fn() },
moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() },
},
}));
@@ -77,6 +77,7 @@ import { auditLog } from "@/lib/auth/audit";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null);
vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined);
vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined);
encryptNoteMock.mockImplementation((note: string | null) =>
@@ -809,6 +810,20 @@ describe("POST /api/import — write failure classification", () => {
vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) =>
writeMood(data as LegacyMoodWrite, committedKeys),
);
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
async ({ where }) => {
const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`;
if (!committedKeys.has(key)) return null;
const isFirstDay = where.userId_date_moodLoggedAt.date === "2026-05-01";
return {
source: "IMPORT",
mood: isFirstDay ? "GUT" : "OKAY",
score: isFirstDay ? 4 : 3,
tags: null,
externalId: null,
} as never;
},
);
const payload = {
moodEntries: [
@@ -896,18 +911,100 @@ describe("POST /api/import — write failure classification", () => {
expect(response.status).toBe(200);
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
.data.moodLoggedAt;
expect(moodLoggedAt.toISOString().slice(0, 10)).toBe("2026-05-01");
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
Date.parse("2026-05-01T00:00:00.000Z"),
Date.parse("2026-04-30T22:00:00.000Z"),
);
expect(moodLoggedAt.getTime()).toBeLessThan(
Date.parse("2026-05-01T00:05:00.000Z"),
Date.parse("2026-04-30T22:05:00.000Z"),
);
} finally {
vi.useRealTimers();
}
});
it("accepts the current Berlin day shortly after local midnight", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-10T22:30:00.000Z"));
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
const response = await POST(
request({
moodEntries: [{ date: "2026-05-11", mood: "GUT", score: 4 }],
}),
);
expect(response.status).toBe(200);
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
.data.moodLoggedAt;
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
Date.parse("2026-05-10T22:00:00.000Z"),
);
expect(moodLoggedAt.getTime()).toBeLessThan(
Date.parse("2026-05-10T22:05:00.000Z"),
);
} finally {
vi.useRealTimers();
}
});
it("probes past a persisted fallback timestamp collision", async () => {
type StoredMood = {
date: string;
mood: string;
score: number;
tags: string | null;
externalId?: string | null;
moodLoggedAt: Date;
};
const persisted = new Map<string, StoredMood>();
vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => {
const key = `${data.date}:${data.moodLoggedAt.toISOString()}`;
if (persisted.has(key)) {
throw new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: "P2002",
clientVersion: "test",
meta: { target: ["userId", "date", "moodLoggedAt"] },
},
);
}
persisted.set(key, data as StoredMood);
return {} as never;
});
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(async ({ where }) => {
const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`;
return (persisted.get(key) ?? null) as never;
});
const firstPayload = {
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" },
],
};
const collidingPayload = {
moodEntries: [
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" },
],
};
await POST(request(firstPayload));
const collisionResponse = await POST(request(collidingPayload));
const retryResponse = await POST(request(collidingPayload));
expect(await collisionResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 1, skipped: 0 },
error: null,
});
expect(await retryResponse.json()).toEqual({
data: { measurements: 0, moodEntries: 0, skipped: 1 },
error: null,
});
expect(persisted.size).toBe(2);
});
it("keeps a generated timestamp stable when upserting an external ID", async () => {
vi.useFakeTimers();
try {
+124 -78
View File
@@ -33,6 +33,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
@@ -65,6 +67,16 @@ const measurementSchema = z
"Wert ausserhalb des plausiblen Bereichs",
);
function fallbackMoodDateStart(date: string): Date | null {
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day) return null;
const instant = zonedWallClockToUtc(
{ year, month, day, hour: 0, minute: 0 },
DEFAULT_TIMEZONE,
);
return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null;
}
const moodEntrySchema = z
.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -84,14 +96,8 @@ const moodEntrySchema = z
})
.superRefine((entry, ctx) => {
if (entry.loggedAt) return;
const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`);
const isCalendarDate =
!Number.isNaN(dateStart) &&
new Date(dateStart).toISOString().slice(0, 10) === entry.date;
if (
!isCalendarDate ||
!isPlausibleEntryInstant(new Date(dateStart))
) {
const dateStart = fallbackMoodDateStart(entry.date);
if (!dateStart || !isPlausibleEntryInstant(dateStart)) {
ctx.addIssue({
code: "custom",
path: ["date"],
@@ -115,20 +121,18 @@ function fallbackMoodIdentity(entry: MoodEntryInput): string {
function stableFallbackMoodInstant(
entry: MoodEntryInput,
occurrence: number,
reservedInstants: Set<number>,
probe: number,
): Date {
const digest = createHash("sha256")
.update(`${fallbackMoodIdentity(entry)}:${occurrence}`)
.digest();
let offset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`);
let instant = dateStart + offset;
while (reservedInstants.has(instant)) {
offset = (offset + 1) % ENTRY_INSTANT_CLOCK_SKEW_MS;
instant = dateStart + offset;
}
reservedInstants.add(instant);
return new Date(instant);
const initialOffset =
digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS;
const offset =
(initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS;
const dateStart = fallbackMoodDateStart(entry.date);
if (!dateStart) throw new Error("Invalid mood date");
return new Date(dateStart.getTime() + offset);
}
const importSchema = z.object({
@@ -295,74 +299,116 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Import mood entries
if (!writeFailed && data.moodEntries?.length) {
const fallbackOccurrences = new Map<string, number>();
const reservedFallbackInstants = new Set<number>();
for (const e of data.moodEntries) {
try {
let loggedAt = e.loggedAt;
if (!loggedAt) {
const identity = fallbackMoodIdentity(e);
const occurrence = fallbackOccurrences.get(identity) ?? 0;
fallbackOccurrences.set(identity, occurrence + 1);
loggedAt = stableFallbackMoodInstant(
e,
occurrence,
reservedFallbackInstants,
);
}
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
const occurrence =
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
if (identity !== null) {
fallbackOccurrences.set(identity, occurrence + 1);
}
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.
// A second import of the same export updates the row in place
// rather than skipping it as a duplicate, so an upstream edit
// (re-scored mood, added tag) is reflected.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
for (
let probe = 0;
probe < ENTRY_INSTANT_CLOCK_SKEW_MS;
probe++
) {
const loggedAt =
e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe);
try {
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.
// A second import of the same export updates the row in place
// rather than skipping it as a duplicate, so an upstream edit
// (re-scored mood, added tag) is reflected.
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "IMPORT",
externalId: e.externalId,
},
},
update: {
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
create: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
moodLoggedAt: loggedAt,
},
},
update: {
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
create: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
moodLoggedAt: loggedAt,
},
});
} else {
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
});
} else {
await prisma.moodEntry.create({
data: {
userId,
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
},
});
}
stats.moodEntries++;
break;
} catch (err) {
if (!isP2002(err)) {
writeFailed = true;
break;
}
if (e.loggedAt) {
stats.skipped++;
break;
}
if (!e.externalId) {
try {
const existing = await prisma.moodEntry.findUnique({
where: {
userId_date_moodLoggedAt: {
userId,
date: e.date,
moodLoggedAt: loggedAt,
},
},
select: {
source: true,
mood: true,
score: true,
tags: true,
externalId: true,
},
});
if (
existing?.source === "IMPORT" &&
existing.mood === e.mood &&
existing.score === e.score &&
existing.tags === (e.tags || null) &&
(existing.externalId ?? null) === null
) {
stats.skipped++;
break;
}
} catch {
writeFailed = true;
break;
}
}
if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) {
writeFailed = true;
}
}
stats.moodEntries++;
} catch (err) {
if (isP2002(err)) {
stats.skipped++;
continue;
}
writeFailed = true;
break;
}
if (writeFailed) break;
}
}