fix: announce each version once, and don't flash old notes on reopen

- A downgrade leaves the recorded version alone, so going back up to it
  isn't announced again.
- The recorded version is claimed by moving it on from the value read, so
  of two processes starting at once only one notifies.
- The dialog clears the last notes before it paints.
- Clearer wording for fresh installs in the docs and for the picture move
  in the 1.2.0 post.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-21 21:48:58 +02:00
1 parent aab83df886
commit 58eb011793
5 files changed
+65 -31

No files matched your search

+45 -27
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "../db";
import { RELEASE_NOTES_URL } from "../config";
@@ -63,13 +64,8 @@ export async function isQuietRelease(version: string, baseUrl = RELEASE_NOTES_UR
}
}
async function readVersionSetting(key: string): Promise<string | null> {
const row = await prisma.setting.findUnique({ where: { key } });
return typeof row?.value === "string" && SEMVER.test(row.value) ? row.value : null;
}
function writeVersionSetting(key: string, value: string) {
return prisma.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
function asVersion(value: Prisma.JsonValue | undefined): string | null {
return typeof value === "string" && SEMVER.test(value) ? value : null;
}
/** At startup: when this version is newer than the one that ran last, every user gets a notification that opens
@@ -81,24 +77,46 @@ export async function announceVersionUpdate(
isQuiet: (version: string) => Promise<boolean> = isQuietRelease,
): Promise<boolean> {
if (!version || !SEMVER.test(version)) return false;
const previous = await readVersionSetting(LAST_RUN_VERSION_KEY);
if (previous === version) return false;
const updated = previous ? compareVersions(version, previous) > 0 : (await prisma.user.count()) > 0;
const since = (await readVersionSetting(LAST_ANNOUNCED_VERSION_KEY)) ?? previous;
const [lastRun, lastAnnounced] = await Promise.all([
prisma.setting.findUnique({ where: { key: LAST_RUN_VERSION_KEY } }),
prisma.setting.findUnique({ where: { key: LAST_ANNOUNCED_VERSION_KEY } }),
]);
const previous = asVersion(lastRun?.value);
// Equal is a restart. Lower is a downgrade: the higher version stays recorded, so going back up to it isn't
// announced a second time.
if (previous && compareVersions(version, previous) <= 0) return false;
const updated = previous !== null || (await prisma.user.count()) > 0;
const since = asVersion(lastAnnounced?.value) ?? previous;
const quiet = updated && (await isQuiet(version));
const users = updated && !quiet ? await prisma.user.findMany({ select: { id: true } }) : [];
if (updated && (await isQuiet(version))) {
await prisma.$transaction([
writeVersionSetting(LAST_RUN_VERSION_KEY, version),
...(since ? [writeVersionSetting(LAST_ANNOUNCED_VERSION_KEY, since)] : []),
]);
return false;
}
const users = updated ? await prisma.user.findMany({ select: { id: true } }) : [];
await prisma.$transaction([
writeVersionSetting(LAST_RUN_VERSION_KEY, version),
writeVersionSetting(LAST_ANNOUNCED_VERSION_KEY, version),
prisma.notification.createMany({
return prisma.$transaction(async (tx) => {
// The version is claimed by moving it on from the one read above, so of two processes starting at once only
// one notifies.
const claimed = lastRun
? (
await tx.setting.updateMany({
where: { key: LAST_RUN_VERSION_KEY, value: { equals: lastRun.value as Prisma.InputJsonValue } },
data: { value: version },
})
).count === 1
: (
await tx.setting.createMany({
data: [{ key: LAST_RUN_VERSION_KEY, value: version }],
skipDuplicates: true,
})
).count === 1;
if (!claimed) return false;
// A quiet release leaves the next announcement's notes starting where the last one's ended.
const announced = quiet ? since : version;
if (announced) {
await tx.setting.upsert({
where: { key: LAST_ANNOUNCED_VERSION_KEY },
create: { key: LAST_ANNOUNCED_VERSION_KEY, value: announced },
update: { value: announced },
});
}
await tx.notification.createMany({
data: users.map((user) => ({
userId: user.id,
type: "system",
@@ -107,9 +125,9 @@ export async function announceVersionUpdate(
internalPath: whatsNewPath(version, since),
createdAt: now,
})),
}),
]);
return updated;
});
return users.length > 0;
});
}
const curatedSchema = z.object({
+15
View File
@@ -240,6 +240,21 @@ describe("version update notification", () => {
expect((await updates())[0].internalPath).toBe("?whats-new=1.2.0");
});
it("doesn't announce a version again after going down from it and back up", async () => {
await ranBefore("1.1.0");
expect(await announce("1.2.0")).toBe(true);
expect(await announce("1.1.0")).toBe(false);
expect(await announce("1.2.0")).toBe(false);
expect(await updates()).toHaveLength(1);
});
it("notifies once when two processes start on the new version together", async () => {
await ranBefore("1.1.0");
const results = await Promise.all([announce("1.2.0"), announce("1.2.0")]);
expect(results.filter(Boolean)).toHaveLength(1);
expect(await updates()).toHaveLength(1);
});
it("skips a quiet release, and covers it in the next announcement's notes", async () => {
await ranBefore("1.2.0");
expect(await announce("1.2.1", ["1.2.1"])).toBe(false);
+1 -1
View File
@@ -259,7 +259,7 @@ ordinary notification (the System tab). To post one, add an entry to `entries` a
## Release notes (What's new)
The first time a backend starts on a newer version than it last ran, every user gets a notification that opens
the What's new dialog (admins can reopen it from Update Checker in Administration). A fresh install isn't told. There's no need to add a "Welcome to
the What's new dialog (admins can reopen it from Update Checker in Administration). A fresh installation doesn't get this notification. There's no need to add a "Welcome to
X.Y.Z" entry to `system-notifications.json` for this any more.
The dialog fetches its content from `main`, so it can be written after the release is out:
@@ -397,7 +397,8 @@ export default function WhatsNewDialog({ request, onClose }: Props) {
const [failed, setFailed] = React.useState(false);
const [zoomed, setZoomed] = React.useState<ReleaseNoteItem["images"][0] | null>(null);
React.useEffect(() => {
// Before paint, so reopening shows the loading state rather than the last notes for a frame.
React.useLayoutEffect(() => {
if (!request) return;
let active = true;
setNotes(null);
+2 -2
View File
@@ -116,6 +116,6 @@ Pull the new images and restart:
docker compose pull && docker compose up -d
```
The first start after the update moves existing pictures next to their models. On a big library that can take a few
minutes, and Administration shows how far it has got. Everyone on your instance then gets a notification that opens the
The first start after the update moves existing pictures next to their models. For a large library, the move can take
a few minutes, and Administration shows its progress. Everyone on your instance then gets a notification that opens the
release notes in the app.