fix(workouts): keep a deleted workout's insight slot spent

The insight claim row is now the daily-cap ledger. Its workout_id is
nullable with ON DELETE SET NULL (migration 0367), so deleting a workout
no longer frees the day's slot. The cap counts claims that reached the
provider plus live leases, and drops the separate count of insight rows.
A daily workout-insight-claim-cleanup queue removes claim rows untouched
for seven days.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 17:53:36 +02:00
1 parent 556bd6d6cd
commit ab081d2a85
11 files changed
+324 -135

No files matched your search

@@ -0,0 +1,15 @@
-- The per-workout insight claim is the ledger behind the daily insight cap.
-- It used to cascade with its workout, so deleting a workout freed the day's
-- slot and a delete-and-re-add loop could buy more paragraphs than the cap
-- allows. The claim now outlives the workout with a null `workout_id`; a
-- daily retention job drops rows older than a week. Idempotent: a re-run
-- finds the column already nullable and the constraint already re-created.
ALTER TABLE "workout_insight_generation_claims"
ALTER COLUMN "workout_id" DROP NOT NULL;
ALTER TABLE "workout_insight_generation_claims"
DROP CONSTRAINT IF EXISTS "workout_insight_generation_claims_workout_id_fkey";
ALTER TABLE "workout_insight_generation_claims"
ADD CONSTRAINT "workout_insight_generation_claims_workout_id_fkey"
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+6 -3
View File
@@ -2409,7 +2409,10 @@ model WorkoutInsight {
model WorkoutInsightGenerationClaim {
id String @id @default(cuid())
userId String @map("user_id")
workoutId String @unique @map("workout_id")
/// Null once the workout is deleted. The row stays as the day's ledger so
/// the delete cannot free an insight slot; the retention job drops it after
/// a week.
workoutId String? @unique @map("workout_id")
localDate String @map("local_date")
claimId String? @unique @map("claim_id")
claimedAt DateTime? @map("claimed_at")
@@ -2418,8 +2421,8 @@ model WorkoutInsightGenerationClaim {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workout Workout? @relation(fields: [workoutId], references: [id], onDelete: SetNull)
@@index([userId, localDate])
@@map("workout_insight_generation_claims")
+1 -1
View File
@@ -579,7 +579,7 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
WorkoutInsight:
"Generated narrative over `Workout`; regenerates on demand. Carrying it would restore an interpretation of data rather than the data.",
WorkoutInsightGenerationClaim:
"A concurrency claim guarding the generator above. Meaningless outside the run that took it.",
"A concurrency claim and the daily-cap ledger for the generator above. It outlives a deleted workout for at most a week and means nothing on another instance or day.",
InsightNarrative:
"Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.",
InsightStatusCache:
@@ -54,7 +54,10 @@ vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({
pickCanonicalWorkoutRows: vi.fn((rows) => rows),
}));
import { runWorkoutInsightGenerate } from "../workout-insight-generate";
import {
runWorkoutInsightGenerate,
workoutInsightCapWhere,
} from "../workout-insight-generate";
import { prisma } from "@/lib/db";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -224,8 +227,10 @@ describe("Activity Insight — the gate stack", () => {
expect(outcome.status).toBe("generated");
});
it("gate 3 — refuses once four paragraphs exist for the local day", async () => {
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(4 as never);
it("gate 3 — refuses once four slots are spent for the local day", async () => {
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
4 as never,
);
const outcome = await runWorkoutInsightGenerate(
{ userId: USER, workoutId: WORKOUT },
@@ -237,36 +242,33 @@ describe("Activity Insight — the gate stack", () => {
});
it("gate 3 — counts in the USER's timezone, not UTC", async () => {
// 00:30 Berlin on the 19th is 22:30 UTC on the 18th. A UTC-keyed window
// would still be counting the 18th's paragraphs against the new local day.
// 00:30 Berlin on the 19th is 22:30 UTC on the 18th. A UTC-keyed day
// would still be counting the 18th's claims against the new local day.
vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin");
await runWorkoutInsightGenerate(
{ userId: USER, workoutId: WORKOUT },
new Date("2026-07-18T22:30:00.000Z"),
);
const where = vi.mocked(prisma.workoutInsight.count).mock.calls[0][0]
?.where as { generatedAt: { gte: Date } };
// Midnight Berlin on the 19th == 22:00 UTC on the 18th.
expect(where.generatedAt.gte.toISOString()).toBe(
"2026-07-18T22:00:00.000Z",
);
const where = vi.mocked(prisma.workoutInsightGenerationClaim.count).mock
.calls[0][0]?.where as { localDate: string };
expect(where.localDate).toBe("2026-07-19");
});
it("gate 3 — starts a midday cap window at exact local midnight", async () => {
vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin");
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(4 as never);
it("gate 3 — the cap reads the claim ledger, never the insight rows", async () => {
// A paragraph whose workout was deleted is gone from `workout_insights`,
// so counting those rows would hand the slot back. Only the claims count.
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
4 as never,
);
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(0 as never);
const outcome = await runWorkoutInsightGenerate(
{ userId: USER, workoutId: WORKOUT },
new Date("2026-07-18T15:00:45.678Z"),
);
const where = vi.mocked(prisma.workoutInsight.count).mock.calls[0][0]
?.where as { generatedAt: { gte: Date } };
expect(where.generatedAt.gte.toISOString()).toBe(
"2026-07-17T22:00:00.000Z",
);
expect(prisma.workoutInsight.count).not.toHaveBeenCalled();
expect(outcome).toEqual({ status: "skipped", reason: "daily_cap" });
expect(runStatusCompletion).not.toHaveBeenCalled();
});
@@ -304,7 +306,9 @@ describe("Activity Insight — the gate stack", () => {
vi.clearAllMocks();
arrangeHappyPath();
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(0 as never);
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
0 as never,
);
vi.mocked(prisma.workoutInsight.findFirst).mockResolvedValue({
id: "wi-1",
inputHash: stored.inputHash,
@@ -321,7 +325,9 @@ describe("Activity Insight — the gate stack", () => {
// The mirror of the test above: a fresh workout (no stored row, so the hash
// gate cannot fire) is still refused once the day's count is spent.
vi.mocked(prisma.workoutInsight.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.workoutInsight.count).mockResolvedValue(4 as never);
vi.mocked(prisma.workoutInsightGenerationClaim.count).mockResolvedValue(
4 as never,
);
const outcome = await runWorkoutInsightGenerate(
{ userId: USER, workoutId: WORKOUT },
@@ -660,3 +666,37 @@ describe("Activity Insight — own-history comparison", () => {
expect(where.id).toEqual({ not: WORKOUT });
});
});
describe("Activity Insight — the cap predicate", () => {
const staleBefore = new Date("2026-07-18T14:50:00.000Z");
it("counts every provider attempt and every live lease, nothing narrower", () => {
const where = workoutInsightCapWhere({
userId: USER,
localDate: "2026-07-18",
staleBefore,
});
expect(where).toEqual({
userId: USER,
localDate: "2026-07-18",
OR: [
{ providerInvokedAt: { not: null } },
{ claimId: { not: null }, claimedAt: { gte: staleBefore } },
],
});
// A completed claim whose workout is gone is still a spent slot: the
// predicate must not narrow on the workout or on completion.
expect(JSON.stringify(where)).not.toContain("workoutId");
expect(JSON.stringify(where)).not.toContain("completedAt");
});
it("leaves the reclaiming row out of its own count", () => {
const where = workoutInsightCapWhere({
userId: USER,
localDate: "2026-07-18",
staleBefore,
excludeClaimRowId: "claim-row-1",
});
expect(where).toMatchObject({ id: { not: "claim-row-1" } });
});
});
+1
View File
@@ -291,6 +291,7 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
"idempotency-cleanup": short("One retention DELETE."),
"step-up-elevation-cleanup": short("One retention DELETE."),
"coach-message-cleanup": short("One retention DELETE."),
"workout-insight-claim-cleanup": short("One retention DELETE."),
"mcp-token-cleanup": short("One retention DELETE."),
"audit-log-cleanup": short("At most 40 batches of 5 000 rows per leg."),
"measurement-tombstone-cleanup": short(
+25
View File
@@ -31,6 +31,7 @@ import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup";
import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup";
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup";
import { cleanupOldWorkoutInsightClaims } from "@/lib/jobs/workout-insight-claim-cleanup";
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
import { cleanupExpiredOidcNativeHandoffs } from "@/lib/jobs/oidc-handoff-cleanup";
import {
@@ -395,6 +396,30 @@ export async function handleCoachMessageCleanup(
});
}
export interface WorkoutInsightClaimCleanupPayload {
triggeredAt: string;
}
export async function handleWorkoutInsightClaimCleanup(
jobs: Job<WorkoutInsightClaimCleanupPayload>[],
): Promise<JobOutcome> {
void jobs;
return withBackgroundEvent(
"job.workout_insight_claim_cleanup",
async (evt) => {
const p = getWorkerPrisma();
try {
const deleted = await cleanupOldWorkoutInsightClaims(p);
evt.addMeta("workout_insight_claim_cleanup_deleted", deleted);
return jobDone({ deleted });
} catch (err) {
evt.addWarning(`workout-insight-claim-cleanup failed: ${err}`);
return jobFailed("workout-insight-claim cleanup failed", err);
}
},
);
}
export interface McpTokenCleanupPayload {
triggeredAt: string;
}
@@ -189,6 +189,8 @@ import {
handlePushAttemptCleanup,
ArrivalReactionCleanupPayload,
handleArrivalReactionCleanup,
WorkoutInsightClaimCleanupPayload,
handleWorkoutInsightClaimCleanup,
MeasurementTombstoneCleanupPayload,
handleMeasurementTombstoneCleanup,
handleRateLimitCleanup,
@@ -330,6 +332,13 @@ const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
const COACH_MESSAGE_CLEANUP_QUEUE = "coach-message-cleanup";
const COACH_MESSAGE_CLEANUP_CRON = "50 3 * * *";
// v1.40 — daily prune of the workout-insight claim ledger. Claims outlive a
// deleted workout so the delete cannot free a daily slot; rows older than a
// week are never read by the cap again.
const WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE = "workout-insight-claim-cleanup";
const WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON = "52 3 * * *";
// v1.23 — converging backfill that migrates the free-text health-note columns
// (mood + measurement) from plaintext to AES-256-GCM at rest. Boot discovery
// enqueues one per-user job; a daily 03:55 Europe/Berlin discovery tick (empty
@@ -445,6 +454,10 @@ const allQueues = [
// the daily schedule silently no-ops and the encrypted coach_messages
// table grows unbounded.
COACH_MESSAGE_CLEANUP_QUEUE,
// v1.40 — workout-insight claim ledger retention prune. Without this entry
// the daily schedule silently no-ops and the claim table keeps every row
// whose workout was deleted.
WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE,
// v1.23 — free-text health-note encryption backfill. Boot discovery + a
// daily discovery cron enqueue one per-user job per account still holding a
// plaintext note; without this entry pg-boss never provisions the queue and
@@ -605,6 +618,12 @@ const schedules: ScheduleEntry[] = [
[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON],
// v1.18.7 — daily 03:50 Europe/Berlin prune for stale Coach history.
[COACH_MESSAGE_CLEANUP_QUEUE, COACH_MESSAGE_CLEANUP_CRON, cronIsTheRetry],
// v1.40 — daily 03:52 Europe/Berlin prune for the workout-insight claims.
[
WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE,
WORKOUT_INSIGHT_CLAIM_CLEANUP_CRON,
cronIsTheRetry,
],
// v1.23 — daily 03:55 Europe/Berlin note-encryption backfill discovery.
// The empty cron payload (no `userId`) is the handler's signal to fan out
// one per-user job per account still holding a plaintext note.
@@ -918,6 +937,14 @@ export async function registerMaintenanceQueues(
{ localConcurrency: 1 },
handleCoachMessageCleanup,
);
// v1.40 — daily prune of the workout-insight claim ledger. Single-flight
// like every other cleanup queue.
await createAndWork<WorkoutInsightClaimCleanupPayload>(
boss,
WORKOUT_INSIGHT_CLAIM_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleWorkoutInsightClaimCleanup,
);
// MCP Phase 3 (M2) — daily prune of dead MCP connector access tokens +
// long-revoked connection anchors. Single-flight like every other cleanup.
await createAndWork<McpTokenCleanupPayload>(
+1
View File
@@ -124,6 +124,7 @@ const subsystemSurface = {
"achievement-unlock-sweep": { audience: "system" },
"measurement-tombstone-cleanup": { audience: "system" },
"coach-message-cleanup": { audience: "system" },
"workout-insight-claim-cleanup": { audience: "system" },
"note-encryption-backfill": { audience: "system" },
"encryption-key-rotate": { audience: "system" },
"mcp-token-cleanup": { audience: "system" },
@@ -0,0 +1,30 @@
/**
* Daily retention for `workout_insight_generation_claims`.
*
* The claim row is the ledger behind the per-workout insight daily cap, and it
* outlives its workout on purpose: deleting a workout nulls the claim's
* `workout_id` instead of removing the row, so the delete cannot free the
* day's slot. The cap only ever reads today's rows, so anything older than the
* retention window is dead weight and is dropped here.
*
* Keyed on `updated_at`, which moves on every claim, reclaim, provider
* invocation and completion. A row written within the window is kept whatever
* its `local_date` says, so no row that the cap can still read is ever
* deleted.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export const WORKOUT_INSIGHT_CLAIM_RETENTION_DAYS = 7;
export async function cleanupOldWorkoutInsightClaims(
prisma: Pick<PrismaClient, "workoutInsightGenerationClaim">,
now: Date = new Date(),
): Promise<number> {
const cutoff = new Date(
now.getTime() - WORKOUT_INSIGHT_CLAIM_RETENTION_DAYS * 86_400_000,
);
const { count } = await prisma.workoutInsightGenerationClaim.deleteMany({
where: { updatedAt: { lt: cutoff } },
});
return count;
}
+41 -18
View File
@@ -55,7 +55,6 @@ import { withBackgroundEvent } from "@/lib/logging/background";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
import {
buildWorkoutInsightEvidence,
@@ -98,10 +97,42 @@ type ClaimWorkoutInsightGenerationArgs = {
userId: string;
workoutId: string;
localDate: string;
dayStart: Date;
now: Date;
};
/**
* Which claim rows spend one of the day's slots.
*
* The claim row is the ledger. A slot is spent by every attempt that reached
* the provider (completed or not, and whether or not its workout still exists:
* deleting a workout nulls the claim's `workout_id` instead of removing the
* row, so the delete cannot buy the slot back) plus every live lease that may
* still reach the provider. A lease past its expiry that never invoked the
* provider spent nothing and is reclaimable. Rows older than a week are
* dropped by `workout-insight-claim-cleanup`.
*/
export function workoutInsightCapWhere({
userId,
localDate,
staleBefore,
excludeClaimRowId,
}: {
userId: string;
localDate: string;
staleBefore: Date;
excludeClaimRowId?: string;
}) {
return {
userId,
localDate,
...(excludeClaimRowId ? { id: { not: excludeClaimRowId } } : {}),
OR: [
{ providerInvokedAt: { not: null } },
{ claimId: { not: null }, claimedAt: { gte: staleBefore } },
],
};
}
/**
* Serialize the local-day capacity check with the durable claim insert.
* PostgreSQL advisory transaction locks keep the count and write atomic across
@@ -112,7 +143,6 @@ export async function claimWorkoutInsightGeneration({
userId,
workoutId,
localDate,
dayStart,
now,
}: ClaimWorkoutInsightGenerationArgs): Promise<WorkoutInsightClaimResult> {
const claimId = randomUUID();
@@ -153,20 +183,15 @@ export async function claimWorkoutInsightGeneration({
return { status: "skipped", reason: "already_claimed" };
}
const [generatedToday, claimedToday] = await Promise.all([
tx.workoutInsight.count({
where: { userId, generatedAt: { gte: dayStart } },
const spentToday = await tx.workoutInsightGenerationClaim.count({
where: workoutInsightCapWhere({
userId,
localDate,
staleBefore,
excludeClaimRowId: existing?.id,
}),
tx.workoutInsightGenerationClaim.count({
where: {
userId,
localDate,
completedAt: null,
...(existing ? { workoutId: { not: workoutId } } : {}),
},
}),
]);
if (generatedToday + claimedToday >= MAX_INSIGHTS_PER_DAY) {
});
if (spentToday >= MAX_INSIGHTS_PER_DAY) {
return { status: "skipped", reason: "daily_cap" };
}
@@ -270,13 +295,11 @@ export async function runWorkoutInsightGenerate(
// The capacity check and durable claim write are one serialized PostgreSQL
// transaction. A count performed before the write would let concurrent
// workouts all observe the same free slot and exceed the cap.
const dayStart = startOfLocalDayInTz(now, tz);
const localDate = userDayKey(now, tz);
const claim = await claimWorkoutInsightGeneration({
userId,
workoutId,
localDate,
dayStart,
now,
});
if (claim.status === "skipped") {
+116 -92
View File
@@ -11,8 +11,9 @@
* a paragraph that outlived its workout would be an orphaned description of
* a session the user believes they erased. That is a data-retention claim,
* not a tidiness one, and only the database enforces it.
* - **The daily-cap count** reads an index over `(user_id, generated_at)`.
* Whether the window actually selects the right rows is a SQL fact.
* - **The daily-cap count** reads the claim ledger, which outlives a deleted
* workout (`ON DELETE SET NULL`). Whether a delete frees a slot is an FK
* fact, so only the database can prove it does not.
*
* Every test writes through Prisma the way the worker does, so a schema change
* that dropped a constraint fails here rather than in production.
@@ -21,6 +22,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { claimWorkoutInsightGeneration } from "@/lib/jobs/workout-insight-generate";
import { cleanupOldWorkoutInsightClaims } from "@/lib/jobs/workout-insight-claim-cleanup";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
@@ -189,67 +191,8 @@ describe("WorkoutInsight — cascade", () => {
});
});
describe("WorkoutInsight — the daily-cap count", () => {
it("counts only this user's rows inside the window", async () => {
const prisma = getPrismaClient();
const mine = await createUser("wi-cap-mine");
const other = await createUser("wi-cap-other");
// Three of mine today, one of mine yesterday, one belonging to someone else.
const today = new Date("2026-07-18T09:00:00Z");
for (let i = 0; i < 3; i++) {
const w = await createWorkout(
mine.id,
new Date(today.getTime() + i * 3600_000),
`ext-today-${i}`,
);
await prisma.workoutInsight.create({
data: insightData(
mine.id,
w.id,
new Date(today.getTime() + i * 3600_000),
),
});
}
const yesterdayWorkout = await createWorkout(
mine.id,
new Date("2026-07-17T09:00:00Z"),
"ext-yesterday",
);
await prisma.workoutInsight.create({
data: insightData(
mine.id,
yesterdayWorkout.id,
new Date("2026-07-17T09:30:00Z"),
),
});
const otherWorkout = await createWorkout(
other.id,
new Date("2026-07-18T09:00:00Z"),
"ext-other",
);
await prisma.workoutInsight.create({
data: insightData(
other.id,
otherWorkout.id,
new Date("2026-07-18T10:00:00Z"),
),
});
const dayStart = new Date("2026-07-18T00:00:00Z");
const count = await prisma.workoutInsight.count({
where: { userId: mine.id, generatedAt: { gte: dayStart } },
});
// Not 4 (yesterday's is outside the window) and not 5 (the other user's is
// outside the tenancy narrow).
expect(count).toBe(3);
});
});
describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () => {
const now = new Date("2026-07-18T15:00:00.000Z");
const dayStart = new Date("2026-07-17T22:00:00.000Z");
const localDate = "2026-07-18";
it("atomically grants at most four different workouts for one user-local day", async () => {
@@ -271,7 +214,6 @@ describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () =>
userId: user.id,
workoutId: workout.id,
localDate,
dayStart,
now,
}),
),
@@ -315,7 +257,6 @@ describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () =>
userId: user.id,
workoutId: workout.id,
localDate,
dayStart,
now,
});
@@ -329,49 +270,132 @@ describe("WorkoutInsightGenerationClaim — concurrent ownership and cap", () =>
expect(rows[0]?.claimId).toBe(outcome.claimId);
});
it("counts a pre-migration insight row against the local-day cap", async () => {
it("keeps a deleted workout's slot spent, so a re-add hits the cap", async () => {
// Generate four, delete them all, add a fifth: the deletes must not buy
// the day's slots back. The claim row outlives its workout with a null
// workout id and is still counted.
const prisma = getPrismaClient();
const user = await createUser("wi-legacy-cap");
const legacyWorkout = await createWorkout(
user.id,
new Date("2026-07-18T07:00:00.000Z"),
"ext-legacy",
);
await prisma.workoutInsight.create({
data: insightData(
user.id,
legacyWorkout.id,
new Date("2026-07-18T08:00:00.000Z"),
),
});
const candidates = await Promise.all(
const user = await createUser("wi-delete-cap");
const first = await Promise.all(
Array.from({ length: 4 }, (_, index) =>
createWorkout(
user.id,
new Date(`2026-07-18T${10 + index}:00:00.000Z`),
`ext-new-${index}`,
`ext-first-${index}`,
),
),
);
for (const workout of first) {
const outcome = await claimWorkoutInsightGeneration({
userId: user.id,
workoutId: workout.id,
localDate,
now,
});
expect(outcome.status).toBe("claimed");
// The worker marks the provider call and completes the claim.
await prisma.workoutInsightGenerationClaim.update({
where: { workoutId: workout.id },
data: {
providerInvokedAt: now,
completedAt: now,
claimId: null,
claimedAt: null,
},
});
await prisma.workoutInsight.create({
data: insightData(user.id, workout.id, now),
});
}
const outcomes = await Promise.all(
candidates.map((workout) =>
claimWorkoutInsightGeneration({
await prisma.workout.deleteMany({
where: { id: { in: first.map((w) => w.id) } },
});
expect(await prisma.workoutInsight.count()).toBe(0);
const orphans = await prisma.workoutInsightGenerationClaim.findMany({
where: { userId: user.id },
});
expect(orphans).toHaveLength(4);
expect(orphans.every((row) => row.workoutId === null)).toBe(true);
const readded = await createWorkout(
user.id,
new Date("2026-07-18T14:30:00.000Z"),
"ext-readded",
);
const outcome = await claimWorkoutInsightGeneration({
userId: user.id,
workoutId: readded.id,
localDate,
now,
});
expect(outcome).toEqual({ status: "skipped", reason: "daily_cap" });
});
it("does not count a lapsed lease that never reached the provider", async () => {
const prisma = getPrismaClient();
const user = await createUser("wi-lapsed-lease");
const lapsed = await Promise.all(
Array.from({ length: 4 }, (_, index) =>
createWorkout(
user.id,
new Date(Date.UTC(2026, 6, 18, 6 + index)),
`ext-lapsed-${index}`,
),
),
);
for (const workout of lapsed) {
await prisma.workoutInsightGenerationClaim.create({
data: {
userId: user.id,
workoutId: workout.id,
localDate,
dayStart,
now,
}),
),
claimId: `dead-${workout.id}`,
claimedAt: new Date("2026-07-18T12:00:00.000Z"),
},
});
}
const fresh = await createWorkout(
user.id,
new Date("2026-07-18T14:00:00.000Z"),
"ext-fresh",
);
expect(
outcomes.filter((outcome) => outcome.status === "claimed"),
).toHaveLength(3);
expect(outcomes).toContainEqual({
status: "skipped",
reason: "daily_cap",
const outcome = await claimWorkoutInsightGeneration({
userId: user.id,
workoutId: fresh.id,
localDate,
now,
});
expect(outcome.status).toBe("claimed");
});
});
describe("WorkoutInsightGenerationClaim — retention", () => {
it("drops rows untouched for a week and keeps the rest", async () => {
const prisma = getPrismaClient();
const user = await createUser("wi-claim-retention");
const now = new Date();
const old = await prisma.workoutInsightGenerationClaim.create({
data: { userId: user.id, workoutId: null, localDate: "2000-01-01" },
});
const recent = await prisma.workoutInsightGenerationClaim.create({
data: { userId: user.id, workoutId: null, localDate: "2000-01-02" },
});
// `updated_at` is Prisma-managed; age one row through raw SQL.
await prisma.$executeRaw`
UPDATE "workout_insight_generation_claims"
SET "updated_at" = ${new Date(now.getTime() - 8 * 86_400_000)}
WHERE "id" = ${old.id}
`;
const deleted = await cleanupOldWorkoutInsightClaims(prisma, now);
expect(deleted).toBe(1);
const left = await prisma.workoutInsightGenerationClaim.findMany({
select: { id: true },
});
expect(left).toEqual([{ id: recent.id }]);
});
});