The file <input> had name="img", so new FormData(form) already included
the original uncompressed file under "img". handleSubmit then appended
the compressed version with the same key, and FormData.append doesn't
overwrite — every save uploaded two files to S3 (the second clobbering
the first under the same generated filename).
The submit button also only checked the full-size img state, not
smallImg. Both Compressor instances run in parallel, so Save could fire
before the thumbnail finished compressing — saving art with no
thumbnail, and (under slow MinIO on CI) making the e2e test flaky as
two serial S3 uploads pushed past the 5s toHaveURL timeout.
Non-participants (e.g. tournament organizers) viewing a tournament
match chat could see the chat auto-open correctly, but clicking the
back arrow to the room list left the chat invisible — the only way
back was to leave the route and return.
The match chat appears in chatContext.rooms via the SUBSCRIBE
response, which (unlike the initial-payload path that participants
go through) does not check room expiry on the skalop side. So an
organizer viewing an old match ends up with an expired room in
their rooms list. ChatView already handles this gracefully with a
read-only banner, but RoomList was filtering on
expiresAt > Date.now() and dropping it.
Cleanup in useChatRouteSync removes the room from chatContext.rooms
on navigation, so exempting the current route's chatCode from the
expiry filter only affects the page that subscribed to it.
upsertWidgets ran an unconditional insertInto("UserWidget").values([])
when the submitted widgets array was empty. Kysely emits
`insert into "UserWidget" () values ()` for an empty values array,
which SQLite rejects with `near ")": syntax error`, rolling back the
preceding delete in the same transaction. Saving an empty widget list
was therefore impossible. Skip the insert when the array is empty.
The /q (front page), /q/preparing, and /q/match/:id action handlers
were not wrapping their switch in try/catch the way /q/looking does.
When SQGroupRepository.createGroup, addMember, or createGroupFromPrevious
tripped the integrity check (member already in another non-INACTIVE
group, group too large, etc.) the SendouQError bubbled up unhandled
and React Router responded with 500 instead of letting the loaders
re-run with fresh state.
These errors are expected race conditions, e.g. a double-submit
where the second JOIN_QUEUE arrives after the first already created
the group. Returning null lets the client see the actual current
state instead of an error page, matching the behavior already in
q.looking.server.ts.
The SPECIAL branch of validatedAnyWeaponFromSearchParams only checked
nonDamagingSpecialWeaponIds membership, not whether the parsed id was
actually a valid SpecialWeaponId. A malformed URL like
?weapon=SPECIAL_9ap=0... made Number() return NaN, which fell through
to exampleMainWeaponIdWithSpecialWeaponId and hit assertUnreachable.
Error: Invariant failed: Empty placements not supported
I don't think it's possible for this to happen normally
but this fix provides more defense just in case
Regression introduced in b26f4ab2 (Migrate setHistoryByTeamId to
Kysely): the old raw SQL inner-joined through
TournamentMatchGameResultParticipant, which implicitly dropped
completed matches that had no game results (forfeits/walkovers).
The Kysely version fetches players via a jsonArrayFrom subquery,
so those matches leaked through and rendered as 0-0 on the team
page.
In round robin, all matches are set to have both participants at the start.
If the bracket is larger e.g. 5 rounds then later rounds might have chat
disappearing before teams get a chance to play it.
Closes#3005
GroupMatch has unique constraints on both alphaGroupId and bravoGroupId
(a group can only be in one match). If two managers click MATCH_UP at
nearly the same moment against overlapping groups, the second INSERT
trips SQLITE_CONSTRAINT_UNIQUE and bubbles up as a 500.
Translate that error into a SendouQError inside SQMatchRepository.create
so the q/looking action's existing SendouQError catch treats it like
any other stale-state error and returns null, which causes the loader
to re-run and the user sees the fresh state instead of an error page.
A crawler hitting /builds/:slug?limit=48%27 (URL-encoded single quote,
likely an SQL injection probe) was triggering SQLITE_MISMATCH errors
server-side. The loader was calling Number() on the raw string, which
returned NaN, and then forwarding NaN as the LIMIT bind parameter on
the underlying Kysely query. No injection was possible (params are
bound), but the bad value only failed at the DB boundary.
Parse the param through a zod schema that coerces to a positive int,
falls back to the default batch size on any invalid input, and clamps
to the page max.
Originally this was removed to "fix" the img download being wonky.
But the underlying library got a lot of updates since so maybe it's good now?
Couldn't at least reproduce the bug anymore.