fix(files): review round 1, the server — the tray's window, the GC's locks, a file's origin, the upgrade path

- The queue keeps a done job by when it became done (ready_at), not by its last write: renaming,
  keeping or moving an old file, or the GC's expiry warning, no longer brings it back as just finished.
- GC expiry and eviction re-read each file under the asset→job locks the user routes take, and commit
  one file at a time: a delete, a re-download or a keep made while the GC runs is honoured, never a
  second release that frees a file another user still holds.
- A file records the origin it was placed under (download_jobs.source_key, migration 0105): an edit
  whose parent expired still goes back to its folder, not to Uploads.
- A folder a file is being put in is held (FOR SHARE) until it lands; a delete of it waits and takes
  that file home too, instead of failing on the foreign key.
- The hash route reads at most two files at once (429 past that) and holds no connection meanwhile.
- 0104's backfill dates old files by their request date only: no notice prod wrote names its job.
- The old DOWNLOAD_ROOT still finds the files; a files root that is not a mounted volume stops the
  worker and is logged by the API; the volume-copy recipe looks the project-prefixed names up.
- Stale comments about the /downloads mount and the Downloads page.
This commit is contained in:
2026-10-13 00:14:09 +02:00
parent 96509fe356
commit 815083bcc4
29 changed files with 593 additions and 92 deletions
@@ -0,0 +1,55 @@
import { useState } from "react";
import { Input } from "./ui/Input";
// A name being typed in place of the name itself — a library file's in its row, a folder's in the rail
// or the page title, a new folder's. Enter or leaving the field saves, Escape cancels: unmounting the
// input fires no blur in React, so Escape really does cancel. The caller decides what an unchanged or
// empty name means.
//
// Ended by a KEY, the field leaves with the keyboard's focus and nothing to give it to: the caller
// puts it back on what stands there now (the row, the entry, the button that opened it) — or the
// keyboard user is dropped on <body> and loses their place (measured in Chrome, the playlist rail's
// rename). Ended by a blur, the user has put the focus somewhere on purpose, and it stays there.
export function RenameField({
initial,
label,
className = "w-full",
onDone,
}: {
initial: string;
/** The field's accessible name (it has no visible label: it stands where the name stood). */
label: string;
className?: string;
/** The typed name, or null when cancelled; `byKey`: Enter or Escape, so the focus is the caller's
* to put back. */
onDone: (name: string | null, byKey: boolean) => void;
}) {
const [draft, setDraft] = useState(initial);
return (
<Input
size="sm"
className={className}
autoFocus
value={draft}
aria-label={label}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => onDone(draft, false)}
onKeyDown={(e) => {
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
if (e.key === "Enter") {
e.preventDefault();
onDone(draft, true);
}
if (e.key === "Escape") {
e.preventDefault();
// Only the edit is cancelled: a drawer (the rail on a phone) or a dialog round the field
// must not close with it.
e.stopPropagation();
onDone(null, true);
}
}}
/>
);
}