From ab081d2a8590f262c78b9cfec7814cca251f6b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 17:53:36 +0200 Subject: [PATCH] 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. --- .../migration.sql | 15 ++ prisma/schema.prisma | 9 +- src/lib/export/backup-plan.ts | 2 +- .../workout-insight-generate.test.ts | 82 +++++-- src/lib/jobs/queue-runtime.ts | 1 + src/lib/jobs/reminder/cleanup-handlers.ts | 25 +++ src/lib/jobs/reminder/register-maintenance.ts | 27 +++ src/lib/jobs/subsystem-surface.ts | 1 + src/lib/jobs/workout-insight-claim-cleanup.ts | 30 +++ src/lib/jobs/workout-insight-generate.ts | 59 +++-- .../integration/workout-insight-claim.test.ts | 208 ++++++++++-------- 11 files changed, 324 insertions(+), 135 deletions(-) create mode 100644 prisma/migrations/0367_workout_insight_claim_set_null/migration.sql create mode 100644 src/lib/jobs/workout-insight-claim-cleanup.ts diff --git a/prisma/migrations/0367_workout_insight_claim_set_null/migration.sql b/prisma/migrations/0367_workout_insight_claim_set_null/migration.sql new file mode 100644 index 000000000..d561de0cf --- /dev/null +++ b/prisma/migrations/0367_workout_insight_claim_set_null/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11b59cb98..bde9dbecc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index a6e83e2d2..8287dd748 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -579,7 +579,7 @@ export const DERIVED_MODELS: Readonly> = { 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: diff --git a/src/lib/jobs/__tests__/workout-insight-generate.test.ts b/src/lib/jobs/__tests__/workout-insight-generate.test.ts index bd3798933..f1443b204 100644 --- a/src/lib/jobs/__tests__/workout-insight-generate.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-generate.test.ts @@ -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" } }); + }); +}); diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index caf376c3d..82e919086 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -291,6 +291,7 @@ export const QUEUE_RUNTIME: Readonly> = { "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( diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 14c86bb90..0c6927965 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -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[], +): Promise { + 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; } diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index e9fdc896d..e157839a4 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -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( + 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( diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index a67847565..42703adc5 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -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" }, diff --git a/src/lib/jobs/workout-insight-claim-cleanup.ts b/src/lib/jobs/workout-insight-claim-cleanup.ts new file mode 100644 index 000000000..b51bd799b --- /dev/null +++ b/src/lib/jobs/workout-insight-claim-cleanup.ts @@ -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, + now: Date = new Date(), +): Promise { + 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; +} diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index cff606fe0..083f77cd2 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -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 { 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") { diff --git a/tests/integration/workout-insight-claim.test.ts b/tests/integration/workout-insight-claim.test.ts index 1fe49840f..534af38f6 100644 --- a/tests/integration/workout-insight-claim.test.ts +++ b/tests/integration/workout-insight-claim.test.ts @@ -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 }]); }); });