From befbace4456139a8fb666cf08f163ac5a4fa7378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 10 Nov 2026 19:08:37 +0100 Subject: [PATCH] fix(cycle): keep a feed turned off during a rotation off Rotation revoked the feed it had read without checking it was still live, so a turn-off in between came back under the new link, and it copied options read before a concurrent change. The revoke is guarded and the options are read after it; PATCH writes only to the live feed and follows a rotation to its successor. --- CHANGELOG.md | 14 ++++ .../cycle/feed-subscription/rotate/route.ts | 31 ++++++--- src/app/api/cycle/feed-subscription/route.ts | 22 +++++- tests/integration/cycle-calendar-feed.test.ts | 69 +++++++++++++++++++ 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 970b17acf..ece51a626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,20 @@ counts as no unit, and a marker already saved with the note accepts the plain unit. Any other text in brackets still counts as part of the unit. (#1313) +- **The sync feed no longer loses a reading written during a long + import.** A Garmin or Withings import writes a batch in one transaction + and stamps its readings before it commits them. When a reading logged by + hand committed in between and the app synced right then, the feed's + position moved past the import's readings, and they never reached the + app or Apple Health. A caught-up page of `GET /api/sync/changes` now + hands back a position a minute behind the present, like `start=now` + already does, so the next pull reads that minute again and anything + that committed late comes with it. +- **Changing the calendar subscription while its link is being renewed.** + A subscription turned off at the same moment its link was renewed could + come back under the new link, and an option changed at that moment + could be lost to the new link. Turning it off now wins, and the change + lands on the link that stays live. ## [1.47.1] — 2026-11-07 diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index fd9449577..70916a245 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -7,10 +7,11 @@ * The forecast style is copied too: a feed minted before that option keeps * BAND rather than taking a new feed's default. * Calendars holding the old link stop updating on their next poll. The new - * URL is returned exactly once. Two rotations racing both revoke the same - * feed, and the second successor then meets the one-live-feed index: that - * one gets the mint path's 409 `cycle.feed.exists`, and the winner's link - * stands. + * URL is returned exactly once. Of two rotations racing, the second finds + * the feed already revoked (or its successor meets the one-live-feed index) + * and gets the mint path's 409 `cycle.feed.exists`; the winner's link + * stands. A subscription turned off during a rotation stays off, and an + * option changed during one lands on the link that stays live. * * Same gates as turning the subscription on: cookie session only, the owner or * the guardian of a managed profile (`requireGuardianAuth`), a fresh proof, @@ -72,8 +73,23 @@ export const POST = apiHandler(async (request: NextRequest) => { const minted = mintFeedToken(); const rotated = await prisma .$transaction(async (tx) => { - const live = await tx.cycleCalendarFeed.findFirst({ + const found = await tx.cycleCalendarFeed.findFirst({ where: { userId: user.id, revokedAt: null }, + select: { id: true }, + }); + if (!found) return null; + // Only while it is still live: a subscription turned off after the + // read above stays off instead of coming back under a new link. + const revoked = await tx.cycleCalendarFeed.updateMany({ + where: { id: found.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (revoked.count === 0) return "raced" as const; + // The options are read after the revoke holds the row, so a change + // that committed in between is the one the new link carries, and one + // that comes later waits and then finds the new link. + const live = await tx.cycleCalendarFeed.findUniqueOrThrow({ + where: { id: found.id }, select: { id: true, horizonCycles: true, @@ -83,11 +99,6 @@ export const POST = apiHandler(async (request: NextRequest) => { forecastStyle: true, }, }); - if (!live) return null; - await tx.cycleCalendarFeed.update({ - where: { id: live.id }, - data: { revokedAt: new Date() }, - }); const created = await tx.cycleCalendarFeed.create({ data: { userId: user.id, diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 6d8b94516..30dc3e1e3 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -170,7 +170,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ); } - const live = await findLiveFeed(user.id); + let live = await findLiveFeed(user.id); if (!live) return noLiveFeed(); const data = { @@ -188,9 +188,25 @@ export const PATCH = apiHandler(async (request: NextRequest) => { ? { forecastStyle: input.forecastStyle } : {}), }; - const updated = await prisma.cycleCalendarFeed.update({ - where: { id: live.id }, + // Only while the feed is still the live one. A rotation that committed + // after the read above revoked it and copied its old options onto the new + // link; the change then goes to that link, once. Updating the revoked row + // would answer 200 with the old options left in force. + let written = await prisma.cycleCalendarFeed.updateMany({ + where: { id: live.id, revokedAt: null }, data, + }); + if (written.count === 0) { + live = await findLiveFeed(user.id); + if (!live) return noLiveFeed(); + written = await prisma.cycleCalendarFeed.updateMany({ + where: { id: live.id, revokedAt: null }, + data, + }); + if (written.count === 0) return feedExists(); + } + const updated = await prisma.cycleCalendarFeed.findUniqueOrThrow({ + where: { id: live.id }, select: FEED_SUMMARY_SELECT, }); diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 7e271e72b..47c775d9d 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -11,6 +11,8 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Prisma } from "@/generated/prisma/client"; + import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; @@ -240,6 +242,73 @@ describe("turning the subscription on", () => { expect(statuses).toContain(409); }); + /** + * Run `rotate()` while `write` holds the live feed's row in an open + * transaction, and commit `write` only once the rotation is waiting on + * that row: the rotation has read the feed, and its revoke comes after + * the competing write whatever the scheduler does. + */ + async function rotateAround( + userId: string, + write: (tx: Prisma.TransactionClient, feedId: string) => Promise, + ): Promise { + const feed = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId, revokedAt: null }, + select: { id: true }, + }); + let pending: Promise | undefined; + await db().$transaction( + async (tx) => { + await write(tx, feed.id); + pending = rotate(); + for (let i = 0; i < 100; i++) { + const [{ n }] = await db().$queryRaw<{ n: number }[]>` + SELECT count(*)::int AS n FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'`; + if (n > 0) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error("the rotation never waited on the feed row"); + }, + { timeout: 10_000 }, + ); + return pending!; + } + + it("keeps a subscription that is turned off during a rotation off", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const res = await rotateAround(owner.id, async (tx, feedId) => { + await tx.cycleCalendarFeed.update({ + where: { id: feedId }, + data: { revokedAt: new Date() }, + }); + }); + expect(res.status).toBe(409); + expect( + await db().cycleCalendarFeed.count({ + where: { userId: owner.id, revokedAt: null }, + }), + ).toBe(0); + }); + + it("carries an option changed during a rotation onto the new link", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const res = await rotateAround(owner.id, async (tx, feedId) => { + await tx.cycleCalendarFeed.update({ + where: { id: feedId }, + data: { forecastStyle: "EXACT" }, + }); + }); + expect(res.status).toBe(201); + const live = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId: owner.id, revokedAt: null }, + select: { forecastStyle: true }, + }); + expect(live.forecastStyle).toBe("EXACT"); + }); + it("the partial unique index rejects a second live row written directly", async () => { const owner = await makeUser("owner"); const base = {