fix(moodlog): acknowledge durable source writes

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:20:35 +02:00
1 parent f648c1748a
commit a6d4bb414c
7 files changed
+494 -118

No files matched your search

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
upsert: vi.fn(),
updateMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
import { prisma } from "@/lib/db";
import { persistMoodLogSourceEntry } from "../persistence";
describe("persistMoodLogSourceEntry concurrent legacy adoption", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("applies the losing replay payload to the row adopted by the winner", async () => {
const moodLoggedAt = new Date("2026-07-19T08:30:00.000Z");
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(
Object.assign(new Error("natural-key collision"), { code: "P2002" }),
);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValueOnce({
count: 0,
});
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValueOnce({
date: "2026-07-19",
moodLoggedAt,
} as never);
vi.mocked(prisma.moodEntry.update).mockResolvedValueOnce({} as never);
await persistMoodLogSourceEntry("user-1", {
externalId: "provider-event-1",
date: "2026-07-19",
moodLoggedAt,
mood: "SUPER_GUT",
score: 5,
tags: ["updated"],
});
expect(prisma.moodEntry.update).toHaveBeenCalledWith({
where: {
userId_source_externalId: {
userId: "user-1",
source: "MOODLOG",
externalId: "provider-event-1",
},
},
data: {
mood: "SUPER_GUT",
score: 5,
tags: JSON.stringify(["updated"]),
source: "MOODLOG",
deletedAt: null,
date: "2026-07-19",
moodLoggedAt,
},
});
});
});
+98
View File
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
moodEntry: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: vi.fn(() => true),
}));
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: vi.fn(),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeUserMoodRollups: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: vi.fn(() => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { prisma } from "@/lib/db";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { safeFetch } from "@/lib/safe-fetch";
import { syncMoodLogEntries } from "../sync";
const SOURCE_ENTRY = {
id: "provider-event-1",
date: "2026-07-19",
time: "2026-07-19T08:30:00.000Z",
mood: "GUT",
score: 4,
tags: ["walk"],
};
describe("syncMoodLogEntries persistence completion", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodLogEnabled: true,
moodLogUrlEncrypted: "https://moodlog.example.test",
moodLogApiKeyEncrypted: "api-key",
moodLogLastSyncedAt: new Date("2026-07-18T08:30:00.000Z"),
} as never);
vi.mocked(isReauthRequired).mockResolvedValue(false);
vi.mocked(safeFetch).mockResolvedValue(
new Response(JSON.stringify({ version: "1", entries: [SOURCE_ENTRY] }), {
status: 200,
}),
);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
it("holds the watermark and success marker when a source upsert fails", async () => {
const persistenceError = new Error("database unavailable");
vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(persistenceError);
await expect(syncMoodLogEntries("user-1")).rejects.toThrow(
"database unavailable",
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).toHaveBeenCalledWith({
userId: "user-1",
integration: "moodlog",
kind: "transient",
message: "database unavailable",
errorCode: "persistence_failed",
});
});
});
+132
View File
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
export interface MoodLogSourceEntry {
externalId: string | null;
date: string;
moodLoggedAt: Date;
mood: string;
score: number;
tags?: readonly string[];
}
/**
* Persist one moodLog-owned source row using the provider id when available
* and the legacy wall-clock key otherwise.
*
* A provider may add an id after an older delivery already created the row by
* wall-clock key. In that case the external-id upsert collides with the legacy
* unique constraint; adopt that durable row and attach the provider id rather
* than creating a duplicate or retrying forever.
*/
export async function persistMoodLogSourceEntry(
userId: string,
entry: MoodLogSourceEntry,
): Promise<void> {
const tags = entry.tags ? JSON.stringify(entry.tags) : null;
const update = {
mood: entry.mood,
score: entry.score,
tags,
source: "MOODLOG",
deletedAt: null,
} as const;
if (!entry.externalId) {
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
},
update,
create: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
...update,
},
});
return;
}
let collisionError: unknown;
try {
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
update: {
...update,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
create: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
externalId: entry.externalId,
...update,
},
});
return;
} catch (err) {
if (!isP2002(err)) throw err;
collisionError = err;
}
const adopted = await prisma.moodEntry.updateMany({
where: {
userId,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
source: "MOODLOG",
externalId: null,
},
data: {
...update,
externalId: entry.externalId,
},
});
if (adopted.count === 1) return;
// A concurrent replay may have adopted the same legacy row first.
const winner = await prisma.moodEntry.findUnique({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
select: { date: true, moodLoggedAt: true },
});
if (
winner?.date === entry.date &&
winner.moodLoggedAt.getTime() === entry.moodLoggedAt.getTime()
) {
await prisma.moodEntry.update({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId: entry.externalId,
},
},
data: {
...update,
date: entry.date,
moodLoggedAt: entry.moodLoggedAt,
},
});
return;
}
throw collisionError;
}
+19 -55
View File
@@ -8,6 +8,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
/**
@@ -235,63 +236,26 @@ export async function syncMoodLogEntries(
? entry.id.slice(0, 120)
: null;
if (externalId) {
await prisma.moodEntry.upsert({
where: {
userId_source_externalId: {
userId,
source: "MOODLOG",
externalId,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
date,
moodLoggedAt,
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
externalId,
moodLoggedAt,
},
});
} else {
await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId,
date,
moodLoggedAt,
},
},
update: {
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
},
create: {
userId,
date,
mood: entry.mood,
score: entry.score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
source: "MOODLOG",
moodLoggedAt,
},
});
}
await persistMoodLogSourceEntry(userId, {
externalId,
date,
moodLoggedAt,
mood: entry.mood,
score: entry.score,
tags: entry.tags,
});
imported++;
} catch (err) {
getEvent()?.addWarning(`Failed to upsert entry: ${err}`);
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(`Failed to upsert moodLog entry: ${message}`);
await recordSyncFailure({
userId,
integration: "moodlog",
kind: "transient",
message,
errorCode: "persistence_failed",
});
throw err;
}
}