mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user