feat(broadcast): controller + destination-store + types updates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 14:35:17 -04:00
parent 31cbe4d9e8
commit d77a63d7ff
3 changed files with 43 additions and 6 deletions

View file

@ -13,15 +13,15 @@
* consistent state after every mutation.
*/
import type { Destination, RelayStatus, ObsScene } from "../shared/types";
import type { Destination, RelayStatus, ObsScene, IDestinationStore } from "../shared/types";
import { ObsClient, ObsError } from "./obs-client";
import { FanoutManager } from "./fanout-manager";
import { DestinationStore } from "./destination-store";
export interface BroadcastControllerOptions {
obs: ObsClient;
fanout: FanoutManager;
store: DestinationStore;
/** Any IDestinationStore impl (file-backed in controller, Postgres in backend-api). */
store: IDestinationStore;
}
export class BroadcastController {
@ -35,7 +35,7 @@ export class BroadcastController {
return this.opts.fanout;
}
get store(): DestinationStore {
get store(): IDestinationStore {
return this.opts.store;
}

View file

@ -10,11 +10,11 @@
* - All mutations await save.
*/
import type { Destination } from "../shared/types";
import type { Destination, IDestinationStore } from "../shared/types";
const DEFAULT_FILE = "/data/destinations.json";
export class DestinationStore {
export class DestinationStore implements IDestinationStore {
private destinations: Destination[] = [];
private filePath: string;
private readonly intendedFilePath: string;

View file

@ -15,6 +15,43 @@ export interface Destination {
url: string;
}
/**
* Default tenant id for the Quinn-first phase. Every persisted row carries a
* tenant_id so flipping to multi-performer later is a data activation, not a
* schema rewrite. Until SSO/`my` performer identity is wired as the tenant key,
* all broadcast rows belong to this seeded tenant.
*/
export const DEFAULT_TENANT_ID = 1;
/**
* Persistence contract for RTMP destinations.
*
* Two implementations satisfy this (DIP BroadcastController depends only on
* the interface, never the concrete class):
* - file-backed `DestinationStore` (controller/, zero-dep, bare-droplet path)
* - `PgDestinationStore` (backend-api/, Postgres, always-on control plane)
*
* `list()`/`find()` are synchronous: both impls keep an authoritative in-memory
* snapshot, hydrated by `load()` and kept in sync on every mutation.
*/
export interface IDestinationStore {
/** Hydrate in-memory state. `initialFromEnv` seeds when the backing store is empty. */
load(initialFromEnv?: Destination[]): Promise<void>;
/** Snapshot copy (safe to mutate by caller). */
list(): Destination[];
find(name: string): Destination | undefined;
/** Idempotent upsert by name. Returns the new full list. */
add(dest: Destination): Promise<Destination[]>;
/** Remove by name (no-op if absent). Returns the new full list. */
remove(name: string): Promise<Destination[]>;
/** Replace the whole set. Returns the new full list. */
replaceAll(newList: Destination[]): Promise<Destination[]>;
/** Diagnostic: where state persists (file path, "postgres", or "" for memory-only). */
readonly path: string;
/** True when persistence degraded to a fallback/memory-only location. */
readonly isUsingFallback: boolean;
}
export interface FanoutStatus {
name: string;
url: string; // may be redacted in some contexts