Files
SnapOtter/apps/api/src/lib/feature-install-queue.ts
T

88 lines
3.0 KiB
TypeScript
Raw Normal View History

/**
* Server-side feature-install queue (in-memory, FIFO).
*
* The queue lives on the server so a POST is always durable: the client POSTs
* immediately and the server serializes installs behind the existing file lock.
* A queued-but-not-yet-started install therefore has a real server footprint
* (this module), instead of only living in a browser tab that can close and
* silently drop it.
*
* This is a LEAF module: it imports NOTHING from feature-status.ts. The
* dependency runs the other way (feature-status.ts imports getQueuedBundleIds
* from here to surface "queued" status), so importing back would form a cycle.
*
* In-memory is deliberate, not a shortcut. The active install is a child of the
* API process and does not survive a server restart (recoverInterruptedInstalls
* clears the lock on boot because any previous install is dead). This queue
* matches that exact contract: it survives a browser tab close (the bug it
* fixes) but not a server restart.
*/
export interface QueuedInstall {
bundleId: string;
jobId: string;
}
let activeInstall: QueuedInstall | null = null;
const queue: QueuedInstall[] = [];
/** The bundle currently installing (holding the file lock), or null. */
export function getActiveBundleId(): string | null {
return activeInstall?.bundleId ?? null;
}
/** Bundle ids waiting in the queue, in FIFO order (excludes the active one). */
export function getQueuedBundleIds(): string[] {
return queue.map((entry) => entry.bundleId);
}
/** True when the bundle is currently installing or already waiting in the queue. */
export function isQueuedOrActive(bundleId: string): boolean {
return activeInstall?.bundleId === bundleId || queue.some((entry) => entry.bundleId === bundleId);
}
/**
* Add a bundle to the FIFO queue and return the effective jobId to track.
*
* Server-side dedup: if the bundle is already the active install or already in
* the queue, no second entry is added and the existing job's id is returned so
* the caller can attach to the in-flight progress stream.
*/
export function enqueue(entry: QueuedInstall): string {
if (activeInstall?.bundleId === entry.bundleId) {
return activeInstall.jobId;
}
const existing = queue.find((q) => q.bundleId === entry.bundleId);
if (existing) {
return existing.jobId;
}
queue.push(entry);
return entry.jobId;
}
/** Peek the head of the queue without removing it. */
export function peekQueue(): QueuedInstall | null {
return queue[0] ?? null;
}
/** Remove and return the head of the queue. */
export function dequeue(): QueuedInstall | null {
return queue.shift() ?? null;
}
/** Mark a bundle as the active install (called by the pump when it starts one). */
export function setActive(entry: QueuedInstall | null): void {
activeInstall = entry;
}
/** Clear the active install (called when the installer child exits). */
export function clearActive(): void {
activeInstall = null;
}
/** Test helper: wipe all queue state. */
export function resetQueueState(): void {
activeInstall = null;
queue.length = 0;
}