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

+14
View File
@@ -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
@@ -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,
});
@@ -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<void>,
): Promise<Response> {
const feed = await db().cycleCalendarFeed.findFirstOrThrow({
where: { userId, revokedAt: null },
select: { id: true },
});
let pending: Promise<Response> | 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 = {