mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
6fb0d184f1
commit
befbace445
4 files changed
+123
-13
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user