import path from "node:path"; import { readFile } from "node:fs/promises"; import type { Paths } from "./paths"; import type { RelocationMarker } from "./channelMedia"; // IS THE SAVED-VIDEO STORE SAFE TO WRITE INTO RIGHT NOW? // // The store moves to another drive the same way a channel's `data/` does, and // it inherits the same hazard: for the length of a multi-hour copy the bytes at // `transcripts/saved-videos` are being read, verified and then swapped, and // anything that writes into them in the meantime is one of three failures, all // of them silent until much later. // // 1. A container landing mid-copy makes `verifyCopy` refuse AT THE END of the // whole transfer — the omnimirror incident, exactly, one directory down. // 2. A container landing between the verify and the rename lands in the dir // the swap is about to PARK, and `reclaimParked` then `rm -rf`s it while // the video's `saved-video.json` still points at it. That is the only copy // of a source container, gone, with a pointer that outlives it. // 3. `moveFileCrossDevice` does an unconditional `mkdir -p` of the // destination's parent, so a persist firing between the `rename` and the // `symlink` RECREATES `saved-videos` as a real directory — the symlink is // then never made, and the move would have recorded the store as being on // a location it cannot be reached at. // // THIS MODULE IS lib/, AND IT HAS TO BE. `common/lib/savedVideo-server.ts` is // what persists a container, it is lib, and lib may not import controller // (architecture.test.ts) — so the marker's name and its reader live here, next // to `channelMedia.ts`, which is the same module for the same reason about a // channel. The controller that WRITES the marker imports these; it does not own // them. // // The marker's shape is deliberately a channel relocation marker // (`{target, direction, startedAt, phase}`) — one mover writes both, and a // second shape would be a second thing to keep in step. export const SAVED_VIDEOS_DIRNAME = "saved-videos"; export const SAVED_VIDEOS_MARKER_FILENAME = ".relocating-saved-videos.json"; export function savedVideosMarkerPath( paths: Pick, ): string { return path.join(paths.transcriptsDir, SAVED_VIDEOS_MARKER_FILENAME); } // The store's home on a location: `/saved-videos`. Flat, beside the // channels' `/media` dirs, and not configurable for the same reason // `relocatedMediaDir` is not — a mover recognises a target by its shape. export function relocatedSavedVideosDir(root: string): string { return path.join(root.trim(), SAVED_VIDEOS_DIRNAME); } export async function readSavedVideosMarker( paths: Pick, ): Promise { try { const raw = JSON.parse( await readFile(savedVideosMarkerPath(paths), "utf8"), ) as Partial; if (typeof raw.target !== "string" || raw.target.trim() === "") return null; return { target: raw.target, direction: raw.direction === "back" ? "back" : "out", startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "", phase: raw.phase === "swap" || raw.phase === "reclaim" ? raw.phase : "copy", }; } catch { return null; } } // Thrown by assertSavedVideosStoreWritable. A distinct class so a caller can // tell "the store is being moved" from any other I/O failure and decline the // persist rather than failing the whole download. export class SavedVideosStoreInTransitionError extends Error { readonly marker: RelocationMarker; constructor(marker: RelocationMarker) { super( `The saved-video store is being moved (${marker.direction} to ` + `${marker.target}, phase "${marker.phase}") — nothing may be written ` + `into it until that finishes or its marker is cleared on /storage.`, ); this.name = "SavedVideosStoreInTransitionError"; this.marker = marker; } } // Is `dir` the corpus store, or inside it? A channel may point its own store // somewhere else entirely (`ChannelConfig.savedVideosDir`), and that store is // not the one being moved — guarding a write to it would refuse a persist for a // move that has nothing to do with it. export function isInCorpusSavedVideoStore( paths: Pick, dir: string, ): boolean { const rel = path.relative(paths.savedVideosDir, dir); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } // THE GUARD, at the moment of the write. One `readFile` of a small JSON file // per persisted container — the same cost `inspectChannelMedia` pays per lane // tick, and a persist happens once per kept video, not once per tick. // // It is checked AT THE WRITE and not only at the start of the download because // the window is the whole copy: a download that began before the move started // is exactly the one that would land a container in the parked directory. export async function assertSavedVideosStoreWritable( paths: Pick, storeDir: string, ): Promise { if (!isInCorpusSavedVideoStore(paths, storeDir)) return; const marker = await readSavedVideosMarker(paths); if (marker) throw new SavedVideosStoreInTransitionError(marker); }