diff --git a/docs/using/cycle-calendar-feed.md b/docs/using/cycle-calendar-feed.md index b05c53d66..3ddb6b29a 100644 --- a/docs/using/cycle-calendar-feed.md +++ b/docs/using/cycle-calendar-feed.md @@ -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 diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 70916a245..755f171ea 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -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( diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 30dc3e1e3..a91f2d627 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -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. diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index 504bb005e..4648b1c1a 100644 --- a/src/lib/openapi/routes/cycle-feed.ts +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -219,7 +219,7 @@ export const cycleFeedPaths: NonNullable = { 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 = { 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 = { 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": { diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 47c775d9d..130569510 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -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, + act: () => Promise = rotate, ): Promise { 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 },