Files
SoulStream/src/lib/preview/session.js
T
KeelrLeek 8ede6a8adf Phase 1: preview contracts and pure session core, 61 tests
src/lib/preview/ — framework-free ESM modules, per design §12–14:
- contracts.js: versioned manifest/item/source-ref factories; signed
  URLs structurally excluded from manifests; Apple identity first-class.
- lease.js: discriminated MediaLease union; computeSafeForMs derives
  lifetimes provider-clock-first with fallback/contradiction handling
  (margins are ADRs backed by Phase 0 soak evidence).
- redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact
  host allowlist gate applied to initial URLs and every redirect.
- manifest.js: artist (rank order, exact-id dedupe), album (disc/track
  sort, context inheritance) and fixture builders — pure, no fetching.
- session.js: reducer with session/generation guards on every async
  action; keyed active item with derived playable indices/cursor;
  auto-advance under fresh generations; close is terminal.
- resolver.js: PreviewResolver contract + scripted fixture resolver
  with classified failures (stale_lease/not_found/network/…).
- transport.js: transport seam + deterministic fake (manual ticks).
- focus.js: revision gateway (all main-player entry points; focus-
  internal tag) and the coordinator — acquisition only via a
  generation-bound silence ack, restore only with revisions unchanged.

tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing,
covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the
exit gate: stale lease/play events are inert, no restoration after any
simulated main command, and no component renders anywhere.
2026-07-22 18:29:03 -05:00

278 lines
12 KiB
JavaScript

// Pure preview-session reducer — design §12 runtime state, §9 navigation
// invariants. No timers, no I/O, no Date.now(): every action carries what it
// needs, so the whole machine replays deterministically under node:test.
//
// THE one rule everything else hangs off: any action produced by async work
// (resolver, transport) carries {sessionId, generation}, and the reducer
// drops it unless BOTH match the current state. A late lease from generation
// 3 can never start audio under the cover generation 4 is showing.
import { AVAILABILITY } from "./contracts.js";
export const PHASE = Object.freeze({
RESOLVING_MANIFEST: "resolving_manifest",
READY: "ready",
RESOLVING_MEDIA: "resolving_media",
BUFFERING: "buffering",
PLAYING: "playing",
PAUSED: "paused",
ENDED: "ended",
ITEM_FAILED: "item_failed",
EMPTY: "empty",
CLOSING: "closing",
});
export function createInitialState(sessionId) {
if (!sessionId) throw new Error("sessionId required");
return Object.freeze({
sessionId,
phase: PHASE.RESOLVING_MANIFEST,
manifest: null,
itemRuntimeByKey: Object.freeze({}),
activeItemKey: null,
activeManifestIndex: null,
requestGeneration: 0,
desiredPlaying: false,
muted: false,
positionMs: 0,
clipDurationMs: undefined,
focusToken: null,
errorClass: undefined,
});
}
// ------------------------------------------------------------ derivations --
/** Effective availability: runtime overrides the manifest's initial value. */
export function effectiveAvailability(state, item) {
return state.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
}
/** Manifest indices whose items are currently playable, in manifest order.
* Derived every time — never stored, so it can't drift (design §9). */
export function playableManifestIndices(state) {
if (!state.manifest) return [];
const out = [];
state.manifest.items.forEach((item, i) => {
if (effectiveAvailability(state, item) === AVAILABILITY.AVAILABLE) out.push(i);
});
return out;
}
/** Position of the active item inside the playable list, or null when the
* active item is not currently eligible (e.g. it failed and is being shown
* in its error state). The active item is identified by sessionKey + index —
* never by this cursor (design §9: availability changes recompute the cursor
* AROUND the stable item; they never move the item). */
export function playableCursor(state) {
if (state.activeManifestIndex == null) return null;
const idx = playableManifestIndices(state).indexOf(state.activeManifestIndex);
return idx === -1 ? null : idx;
}
function itemAt(state, manifestIndex) {
return state.manifest?.items[manifestIndex] ?? null;
}
// -------------------------------------------------------------- reducer ----
function guard(state, action, { needGeneration }) {
if (action.sessionId !== state.sessionId) return false;
if (state.phase === PHASE.CLOSING) return false; // terminal: nothing lands
if (needGeneration && action.generation !== state.requestGeneration) return false;
return true;
}
function withRuntime(state, sessionKey, patch) {
const prev = state.itemRuntimeByKey[sessionKey] ?? {
availability: undefined,
mediaPhase: "idle",
errorClass: undefined,
};
return Object.freeze({
...state.itemRuntimeByKey,
[sessionKey]: Object.freeze({ ...prev, ...patch }),
});
}
/** Activate the item at `manifestIndex`, bumping the generation — the
* common core of first-pick, step, click and auto-advance. */
function activate(state, manifestIndex, { keepDesired }) {
const item = itemAt(state, manifestIndex);
if (!item) return state;
return Object.freeze({
...state,
phase: PHASE.RESOLVING_MEDIA,
activeItemKey: item.sessionKey,
activeManifestIndex: manifestIndex,
requestGeneration: state.requestGeneration + 1,
desiredPlaying: keepDesired ? state.desiredPlaying : state.desiredPlaying,
positionMs: 0,
clipDurationMs: undefined,
errorClass: undefined,
});
}
export function reduce(state, action) {
switch (action.type) {
// ---- session lifecycle ------------------------------------------
case "MANIFEST_READY": {
if (!guard(state, action, { needGeneration: false })) return state;
const next = Object.freeze({ ...state, manifest: action.manifest });
const playable = playableManifestIndices(next);
if (playable.length === 0) {
return Object.freeze({ ...next, phase: PHASE.EMPTY });
}
// Opening takes audio (decision 2026-07-22): the first playable
// item becomes active with listen intent already set; the focus
// coordinator upstream decides when sound may actually start.
return activate(
Object.freeze({ ...next, desiredPlaying: true }),
action.startAtManifestIndex != null && playable.includes(action.startAtManifestIndex)
? action.startAtManifestIndex
: playable[0],
{ keepDesired: true }
);
}
case "CLOSE": {
if (action.sessionId !== state.sessionId) return state;
return Object.freeze({ ...state, phase: PHASE.CLOSING, desiredPlaying: false, focusToken: null });
}
// ---- navigation (user intent, current-generation by definition) --
case "STEP": {
if (!guard(state, action, { needGeneration: false })) return state;
const playable = playableManifestIndices(state);
if (playable.length === 0) return state;
const cur = playableCursor(state);
let target;
if (cur != null) {
target = cur + action.dir;
} else {
// Active item isn't eligible (failed): move to the next
// eligible MANIFEST ordinal in the step direction (design §9).
const from = state.activeManifestIndex ?? -1;
target =
action.dir > 0
? playable.findIndex((i) => i > from)
: (() => {
let last = -1;
playable.forEach((i, k) => {
if (i < from) last = k;
});
return last;
})();
}
if (target < 0 || target >= playable.length) return state; // no wrap
return activate(state, playable[target], { keepDesired: true });
}
case "ACTIVATE_KEY": {
if (!guard(state, action, { needGeneration: false })) return state;
const idx = state.manifest?.items.findIndex((it) => it.sessionKey === action.sessionKey) ?? -1;
if (idx === -1) return state;
const item = state.manifest.items[idx];
if (effectiveAvailability(state, item) !== AVAILABILITY.AVAILABLE) return state;
if (idx === state.activeManifestIndex) return state;
return activate(state, idx, { keepDesired: true });
}
case "REPLAY": {
if (!guard(state, action, { needGeneration: false })) return state;
if (state.activeManifestIndex == null) return state;
return Object.freeze({
...activate(state, state.activeManifestIndex, { keepDesired: true }),
desiredPlaying: true,
});
}
case "SET_DESIRED_PLAYING": {
if (!guard(state, action, { needGeneration: false })) return state;
return Object.freeze({ ...state, desiredPlaying: !!action.value });
}
case "SET_MUTED": {
if (!guard(state, action, { needGeneration: false })) return state;
return Object.freeze({ ...state, muted: !!action.value });
}
case "FOCUS_SET": {
if (!guard(state, action, { needGeneration: false })) return state;
return Object.freeze({ ...state, focusToken: action.token ?? null });
}
// ---- async completions (generation-gated) ------------------------
case "RESOLVE_STARTED": {
if (!guard(state, action, { needGeneration: true })) return state;
if (action.sessionKey !== state.activeItemKey) return state;
return Object.freeze({
...state,
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "resolving" }),
});
}
case "LEASE_READY": {
if (!guard(state, action, { needGeneration: true })) return state;
if (action.sessionKey !== state.activeItemKey) return state;
return Object.freeze({
...state,
phase: state.desiredPlaying ? PHASE.BUFFERING : PHASE.READY,
clipDurationMs: action.clipDurationMs ?? state.clipDurationMs,
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "ready" }),
});
}
case "MEDIA_PLAYING": {
if (!guard(state, action, { needGeneration: true })) return state;
return Object.freeze({ ...state, phase: PHASE.PLAYING });
}
case "MEDIA_PAUSED": {
if (!guard(state, action, { needGeneration: true })) return state;
return Object.freeze({ ...state, phase: PHASE.PAUSED });
}
case "MEDIA_POSITION": {
if (!guard(state, action, { needGeneration: true })) return state;
return Object.freeze({ ...state, positionMs: action.positionMs });
}
case "MEDIA_ENDED": {
if (!guard(state, action, { needGeneration: true })) return state;
// Auto-advance (decision 2026-07-22): roll into the next playable
// item under the SAME generation rules — activate() bumps, so any
// straggler event from the finished clip is stale by construction.
const playable = playableManifestIndices(state);
const cur = playableCursor(state);
if (cur != null && cur + 1 < playable.length) {
return activate(state, playable[cur + 1], { keepDesired: true });
}
return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false });
}
case "ITEM_FAILED": {
if (!guard(state, action, { needGeneration: true })) return state;
const runtime = withRuntime(state, action.sessionKey, {
availability: action.availability ?? AVAILABILITY.TEMP_FAILED,
mediaPhase: "failed",
errorClass: action.errorClass,
});
const isActive = action.sessionKey === state.activeItemKey;
return Object.freeze({
...state,
itemRuntimeByKey: runtime,
// A failed ACTIVE item stays visible in its error state
// (design §9) — no auto-skip, the user retries or steps.
phase: isActive ? PHASE.ITEM_FAILED : state.phase,
errorClass: isActive ? action.errorClass : state.errorClass,
});
}
// ---- runtime availability (metadata, NOT generation-gated) -------
case "AVAILABILITY_CHANGED": {
if (!guard(state, action, { needGeneration: false })) return state;
// Only itemRuntimeByKey changes: a newly available EARLIER item
// must never move the current card (design §9) — and it can't,
// because the active item is keyed, not cursor-positioned.
return Object.freeze({
...state,
itemRuntimeByKey: withRuntime(state, action.sessionKey, {
availability: action.availability,
}),
});
}
default:
return state;
}
}