fix(cycle): turning the feed off wins against a rotation in either order

DELETE revoked the one feed it had read, so a rotation committing while it waited left the successor live behind a 200. It now revokes every live feed of the record in two passes. A rotation that loses to a turn-off answers 404 cycle.feed.none instead of 409; PATCH documents its 409. The in-repo feed guide describes the three forecast styles.
This commit is contained in:
Marc-André Bombeck committed 2026-11-10 19:53:47 +01:00
1 parent c62abad965
commit c14cc9ab47
5 files changed
+83 -22

No files matched your search

+13 -6
View File
@@ -32,16 +32,23 @@ managed on the web; the iOS app shows where to find it.
## What the feed shows
- The next period and, once three cycles are logged, a few after it (three
by default, up to six). Each event covers the whole window the period may
fall in, and the window widens the further out it is. The list stops itself
where a date would be more guess than estimate (beyond about ±10 days, or
half a cycle).
by default, up to six). The range a period may fall in widens the further
out it is, and the list stops itself where a date would be more guess
than estimate (beyond about ±10 days, or half a cycle). How that range is
drawn is up to you, under "How forecasts look":
- **Expected days with margin** (the default for a new subscription): the
expected days as one event, and the days the period may start earlier
or run on later as separate "Period possible" events.
- **Expected days**: only the expected days, as one event.
- **One range**: one event covering the whole range. A subscription
turned on before v1.47.2 keeps this style until you change it, and
renewing the link keeps whichever style it has.
- Optionally, your past cycles (up to twelve).
- Optionally, when your goal is trying to conceive, the estimated fertile
window of the next cycle. It is an estimate, not a contraceptive method.
Titles are discreet by default: each event is a dot and the calendar is called
"HealthLog". Descriptive titles name the period instead. Events never carry
Titles are discreet by default: a period is a dot (●), a possible day an open
dot (○), and the calendar is called "HealthLog". Descriptive titles name the period instead. Events never carry
symptoms, notes or any other detail of your log.
When a period is later than its window, the feed shows no forecast until you
@@ -10,7 +10,8 @@
* 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
* stands. A subscription turned off during a rotation stays off, whichever
* commits first, and the rotation then answers 404 `cycle.feed.none`; 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
@@ -36,7 +37,11 @@ import {
feedLinks,
noLiveFeed,
} from "@/lib/cycle/feed/manage";
import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status";
import {
FEED_SUMMARY_SELECT,
findLiveFeed,
toFeedSummary,
} from "@/lib/cycle/feed/status";
import { mintFeedToken } from "@/lib/cycle/feed/token";
import { prisma } from "@/lib/db";
import { resolveOrigin } from "@/lib/http/origin";
@@ -128,7 +133,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (isP2002(err)) return "raced" as const;
throw err;
});
if (rotated === "raced") return feedExists();
// Lost to another rotation, the link that won stands; lost to the
// subscription being turned off, there is none.
if (rotated === "raced") {
return (await findLiveFeed(user.id)) ? feedExists() : noLiveFeed();
}
if (!rotated) return noLiveFeed();
return apiSuccess(
+11 -4
View File
@@ -245,10 +245,17 @@ export const DELETE = apiHandler(async () => {
const live = await findLiveFeed(user.id);
if (!live) return noLiveFeed();
await prisma.cycleCalendarFeed.update({
where: { id: live.id },
data: { revokedAt: new Date() },
});
// Every live feed of the record, not only the one read above, and twice:
// a rotation that held that row while this waited commits its successor
// after the first statement looked, and the second finds it. Off stays
// off even when the link was being renewed at the same moment.
const revokedAt = new Date();
for (let pass = 0; pass < 2; pass++) {
await prisma.cycleCalendarFeed.updateMany({
where: { userId: user.id, revokedAt: null },
data: { revokedAt },
});
}
// The link stops working on the provider's next poll. What the provider
// already copied is beyond reach, which the settings card says before the
// link is ever made.
+7 -2
View File
@@ -219,7 +219,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Change the calendar subscription's options (#1350)",
description:
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.",
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. A change saved while the link is being replaced applies to the link that stays live.",
requestBody: {
required: true,
content: { "application/json": { schema: feedOptionsRequest } },
@@ -237,6 +237,11 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "No subscription is on (`cycle.feed.none`).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The link was replaced twice while the change was being saved (`cycle.feed.exists`); nothing changed, and sending it again applies it to the link that is live now.",
content: { "application/json": { schema: errorEnvelope } },
},
...MANAGEMENT_REFUSALS,
...malformedJsonResponse,
...stdResponses,
@@ -275,7 +280,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Replace the calendar subscription's link (#1350)",
description:
"One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.",
"One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands; a rotation racing the subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off.",
responses: {
...mintResponses,
"404": {
+40 -7
View File
@@ -243,14 +243,15 @@ describe("turning the subscription on", () => {
});
/**
* 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.
* Run `act` (a rotation unless named) while `write` holds the live feed's
* row in an open transaction, and commit `write` only once `act` is
* waiting on that row: `act` has read the feed, and its own write comes
* after the competing one whatever the scheduler does.
*/
async function rotateAround(
userId: string,
write: (tx: Prisma.TransactionClient, feedId: string) => Promise<void>,
act: () => Promise<Response> = rotate,
): Promise<Response> {
const feed = await db().cycleCalendarFeed.findFirstOrThrow({
where: { userId, revokedAt: null },
@@ -260,7 +261,7 @@ describe("turning the subscription on", () => {
await db().$transaction(
async (tx) => {
await write(tx, feed.id);
pending = rotate();
pending = act();
for (let i = 0; i < 100; i++) {
const [{ n }] = await db().$queryRaw<{ n: number }[]>`
SELECT count(*)::int AS n FROM pg_stat_activity
@@ -268,7 +269,7 @@ describe("turning the subscription on", () => {
if (n > 0) return;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error("the rotation never waited on the feed row");
throw new Error("the request never waited on the feed row");
},
{ timeout: 10_000 },
);
@@ -284,7 +285,39 @@ describe("turning the subscription on", () => {
data: { revokedAt: new Date() },
});
});
expect(res.status).toBe(409);
expect(res.status).toBe(404);
expect((await res.json()).meta.errorCode).toBe("cycle.feed.none");
expect(
await db().cycleCalendarFeed.count({
where: { userId: owner.id, revokedAt: null },
}),
).toBe(0);
});
it("keeps a subscription off when it is turned off while a rotation commits", async () => {
const owner = await makeUser("owner");
await mint(owner.id);
// The rotation holds the live row (revoked, successor written, not yet
// committed) while the turn-off waits on it.
const res = await rotateAround(
owner.id,
async (tx, feedId) => {
await tx.cycleCalendarFeed.update({
where: { id: feedId },
data: { revokedAt: new Date() },
});
await tx.cycleCalendarFeed.create({
data: {
userId: owner.id,
createdById: owner.id,
tokenHash: "successor-hash",
tokenPrefix: "hlf_successor",
},
});
},
() => manage("DELETE"),
);
expect(res.status).toBe(200);
expect(
await db().cycleCalendarFeed.count({
where: { userId: owner.id, revokedAt: null },