release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

91
lib/immichCache.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* When a cached answer from Immich may still be served — the policy on its own,
* separated from the client that applies it (#610).
*
* This governs how quickly the public site reflects a change made in Immich,
* and what visitors see while Immich is down. Inside the client it was only
* reachable through HTTP mocks; here it is a plain function over a cache and an
* error.
*/
import { cache } from './cache';
import { ImmichUnavailableError } from './immichTransport';
/**
* Cache sentinel for "Immich answered, and the resource does not exist".
*
* The cache cannot tell a miss from a stored null — both read back as null — so
* absence is recorded as a distinct object identity instead.
*
* Only definitive 404/410 answers are stored. An outage throws
* `ImmichUnavailableError` and is never cached: pinning one would keep the
* gallery broken long after Immich recovered.
*
* Stored under the normal `cacheTtl`, and — unlike what the 2026-08-05
* resilience plan says — **inside** the stale window, deliberately. A 404 is as
* authoritative an answer as a 200, so during an outage "still missing" is the
* last known good answer, exactly like a stale album is. The alternative is
* worse than it sounds: the homepage looks up every hero ID in one
* `Promise.all` with no catch, so a single deleted hero photo whose "missing"
* had expired would take the whole homepage down for the length of the outage,
* while every other image could have been served stale (#626).
*
* Both invalidation paths (the Immich webhook and the admin panel's
* save/reload) clear it immediately, so a corrected ID takes effect at once.
*/
export const MISSING = Object.freeze({ __immichMissing: true });
export type Missing = typeof MISSING;
/** Cache write carrying the configured stale window. */
export function cacheSet<T>(key: string, data: T, ttlMs: number, staleMaxAgeMs: number): void {
cache.set(key, data, ttlMs, staleMaxAgeMs);
}
/**
* Last resort when Immich is unavailable, for keys that only ever hold real
* data: hand back the most recent known good answer rather than failing the
* page.
*
* It answers only ImmichUnavailableError. Any other error means we asked the
* wrong question, not that the answer is unavailable, and serving something
* stale would hide a real bug behind yesterday's data.
*
* It will not cast the MISSING sentinel into a `T`. That cast is what #626
* was: a sentinel handed back as an album or an asset is a truthy object with
* no fields, so every `if (!asset)` guard passes and the page answers 500.
* Keys that can hold the sentinel go through staleOrMissing instead; here,
* meeting one means there is nothing usable, and the outage propagates.
*/
export function staleOrThrow<T>(cacheKey: string, error: unknown, label: string): T {
if (error instanceof ImmichUnavailableError) {
const stale = cache.getStale<T | Missing>(cacheKey);
if (stale !== null && stale !== MISSING) {
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
return stale as T;
}
}
throw error;
}
/**
* The same fallback for keys that may hold the MISSING sentinel — a single
* album or a single asset.
*
* A stale "missing" comes back as `null`, which is what the caller would have
* got from Immich itself: the last authoritative answer was that the resource
* does not exist, and every caller already handles `null` that way.
*/
export function staleOrMissing<T>(cacheKey: string, error: unknown, label: string): T | null {
if (error instanceof ImmichUnavailableError) {
const stale = cache.getStale<T | Missing>(cacheKey);
if (stale === MISSING) {
console.warn(`[Immich] ⚠️ Upstream unavailable — ${label} was last known missing`);
return null;
}
if (stale !== null) {
console.warn(`[Immich] ⚠️ Upstream unavailable — serving stale ${label}`);
return stale as T;
}
}
throw error;
}