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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:08:37 +01:00
1 parent 6fb0d184f1
commit befbace445
4 files changed
+123 -13

No files matched your search

@@ -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,
+19 -3
View File
@@ -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,
});