fix: guard tag rewrites against concurrent edits, pluralise merge count, fix lt wording

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 20:12:31 +02:00
1 parent a0dbba8058
commit ef14f54221
7 files changed
+55 -19

No files matched your search

+41 -11
View File
@@ -26,13 +26,32 @@ function rewrite(tags: string[], from: Set<string>, to: string | null): string[]
return result;
}
/** Swaps `from` for `to` (or removes them) on every model and collection of the user's, in one transaction so two
* rewrites can't each save a model's tags over the other's. Answers how many models had each of `from`, the models
* changed, and how many of those hadn't had `to`. */
/** Thrown inside a rewrite when a row's tags changed after they were read, to roll the rewrite back and retry it. */
class TagsChanged extends Error {}
/** Swaps `from` for `to` (or removes them) on every model and collection of the user's, in one transaction. Each
* write only lands if the row still has the tags that were read, so a concurrent edit is never saved over: the
* rewrite is rolled back and retried instead. Answers how many models had each of `from`, the models changed, and
* how many of those hadn't had `to`. */
async function rewriteTags(
userId: string,
from: Set<string>,
to: string | null,
): Promise<{ counts: Map<string, number>; printIds: string[]; gained: number }> {
for (let attempt = 1; ; attempt++) {
try {
return await rewriteTagsOnce(userId, from, to);
} catch (err) {
if (!(err instanceof TagsChanged)) throw err;
if (attempt >= 3) throw new HttpError(409, "Tags changed while saving, try again");
}
}
}
async function rewriteTagsOnce(
userId: string,
from: Set<string>,
to: string | null,
): Promise<{ counts: Map<string, number>; printIds: string[]; gained: number }> {
const [prints, collections] = await Promise.all([
prisma.print.findMany({ where: { userId }, select: { id: true, tags: true } }),
@@ -48,14 +67,25 @@ async function rewriteTags(
return hit;
});
const taggedCollections = collections.filter((c) => c.tags.some((tag) => from.has(normalizeTag(tag))));
await prisma.$transaction([
...tagged.map((print) =>
prisma.print.update({ where: { id: print.id }, data: { tags: rewrite(print.tags, from, to) } }),
),
...taggedCollections.map((c) =>
prisma.collection.update({ where: { id: c.id }, data: { tags: rewrite(c.tags, from, to) } }),
),
]);
await prisma.$transaction(
async (tx) => {
for (const print of tagged) {
const { count } = await tx.print.updateMany({
where: { id: print.id, tags: { equals: print.tags } },
data: { tags: rewrite(print.tags, from, to) },
});
if (!count) throw new TagsChanged();
}
for (const c of taggedCollections) {
const { count } = await tx.collection.updateMany({
where: { id: c.id, tags: { equals: c.tags } },
data: { tags: rewrite(c.tags, from, to) },
});
if (!count) throw new TagsChanged();
}
},
{ timeout: 60_000 },
);
return { counts, printIds: tagged.map((print) => print.id), gained };
}
+1 -1
View File
@@ -221,7 +221,7 @@ export default function MergeDialog({ open, onClose, kind, items, loadPreview, o
{added !== null && added > 0 && (
<Box component="span" sx={{ color: "success.main", fontWeight: 600 }}>
{" · +"}
<RollingNumber value={added} /> {t("models:merge.new")}
<RollingNumber value={added} /> {t("models:merge.new", { count: added })}
</Box>
)}
</Typography>
+2 -2
View File
@@ -57,9 +57,9 @@ export function useSelection<T>(
const from = anchor ? pickable.findIndex((item) => keyOf(item) === anchor) : -1;
const to = pickable.findIndex((item) => keyOf(item) === key);
if (to < 0) return prev;
if (range && from >= 0) {
if (range && anchor !== null && from >= 0) {
// The range takes the state the anchor was left in, as a file manager does.
const select = prev.has(anchor!);
const select = prev.has(anchor);
for (const item of pickable.slice(Math.min(from, to), Math.max(from, to) + 1)) {
if (select) next.add(keyOf(item));
else next.delete(keyOf(item));
+2 -1
View File
@@ -3106,7 +3106,8 @@
"after": "After the merge",
"modelUnit_one": "model",
"modelUnit_other": "models",
"new": "new",
"new_one": "new",
"new_other": "new",
"quoted": "“{{name}}”",
"andMore_one": "{{count}} more",
"andMore_other": "{{count}} more",
+2 -1
View File
@@ -3107,7 +3107,8 @@
"after": "Tras la fusión",
"modelUnit_one": "modelo",
"modelUnit_other": "modelos",
"new": "nuevos",
"new_one": "nuevo",
"new_other": "nuevos",
"quoted": "«{{name}}»",
"andMore_one": "{{count}} más",
"andMore_other": "{{count}} más",
+2 -1
View File
@@ -3120,7 +3120,8 @@
"after": "Après la fusion",
"modelUnit_one": "modèle",
"modelUnit_other": "modèles",
"new": "nouveaux",
"new_one": "nouveau",
"new_other": "nouveaux",
"quoted": "« {{name}} »",
"andMore_one": "{{count}} autre",
"andMore_other": "{{count}} autres",
+5 -2
View File
@@ -3234,14 +3234,17 @@
"modelUnit_few": "modeliai",
"modelUnit_many": "modelio",
"modelUnit_other": "modelių",
"new": "naujų",
"new_one": "naujas",
"new_few": "nauji",
"new_many": "naujo",
"new_other": "naujų",
"quoted": "„{{name}}“",
"andMore_one": "dar {{count}}",
"andMore_few": "dar {{count}}",
"andMore_many": "dar {{count}}",
"andMore_other": "dar {{count}}",
"syncsWith": "sinchronizuojama su {{provider}}",
"bookmarkMoves": "Įsiminimas pereina „{{name}}“.",
"bookmarkMoves": "Įsiminimas pereina į „{{name}}“.",
"cantUndo": "Sujungimo atšaukti negalima.",
"mergeInto": "Sujungti į „{{name}}“",
"collections": {