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.
This commit is contained in:
KeelrLeek committed 2026-07-22 18:29:03 -05:00
1 parent 6dcc62a2fd
commit 8ede6a8adf
14 files changed
+1722 -2

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
// Versioned data contracts for the preview feature — design §12, schema v1.
//
// Plain frozen objects, framework-free, so the whole session core is
// unit-testable under node:test with no renderer. The reducer and manifest
// builders depend on THIS file's shapes, never on raw CatalogTrack rows.
export const MANIFEST_SCHEMA_VERSION = 1;
export const AVAILABILITY = Object.freeze({
UNRESOLVED: "unresolved",
AVAILABLE: "available",
NOT_PROVIDED: "not_provided",
UNRELEASED: "unreleased",
REGION_UNAVAILABLE: "region_unavailable",
TEMP_FAILED: "temporarily_failed",
});
const CONTEXT_KINDS = new Set(["artist", "album", "playlist", "search", "collection"]);
const PROVIDERS = new Set(["deezer", "apple", "local"]);
const CONFIDENCE = new Set(["native", "isrc", "strong_metadata"]);
/**
* Stable provider identity for one source of a preview. Signed URLs are NOT
* identity (Phase 0: every fetch mints a different URL); the provider track
* id is. Apple rows keep their REAL provider id here — the backend's
* synthetic-id flattening is a Phase 2 extension, and this contract is the
* reason it can't silently regress.
*/
export function makeSourceRef({ provider, providerTrackId, isrc, matchConfidence = "native" }) {
if (!PROVIDERS.has(provider)) throw new Error(`unknown provider ${provider}`);
if (!CONFIDENCE.has(matchConfidence)) throw new Error(`unknown confidence ${matchConfidence}`);
const id = String(providerTrackId ?? "");
if (!id) throw new Error("providerTrackId required");
return Object.freeze({ provider, providerTrackId: id, isrc, matchConfidence });
}
export function makeContext({ kind, contextKey, title, subtitle, artwork, sourceTrackCount }) {
if (!CONTEXT_KINDS.has(kind)) throw new Error(`unknown context kind ${kind}`);
if (!contextKey) throw new Error("contextKey required");
return Object.freeze({
kind,
contextKey: String(contextKey),
title: title ?? "",
subtitle,
artwork,
sourceTrackCount: sourceTrackCount | 0,
});
}
/**
* One occurrence in a manifest. `sessionKey` is unique per occurrence (so
* playlist duplicates stay distinct); `recordingKey` ties repeated
* occurrences of the same recording together for shared action state.
*/
export function makeItem({
sessionKey,
sourceOrdinal,
discNumber,
trackNumber,
recordingKey,
title,
artists,
album,
albumId,
artwork,
fullDurationMs,
explicit,
sources,
initialAvailability,
actions,
}) {
if (!sessionKey) throw new Error("sessionKey required");
if (!Number.isInteger(sourceOrdinal) || sourceOrdinal < 0) {
throw new Error("sourceOrdinal must be a non-negative integer");
}
const avail = initialAvailability ?? AVAILABILITY.UNRESOLVED;
if (!Object.values(AVAILABILITY).includes(avail)) {
throw new Error(`unknown availability ${avail}`);
}
return Object.freeze({
sessionKey: String(sessionKey),
sourceOrdinal,
discNumber,
trackNumber,
recordingKey,
title: title ?? "",
artists: Object.freeze(Array.isArray(artists) ? [...artists] : []),
album,
albumId: albumId != null ? String(albumId) : undefined,
artwork,
fullDurationMs,
explicit,
sources: Object.freeze((sources ?? []).map((s) => (Object.isFrozen(s) ? s : makeSourceRef(s)))),
initialAvailability: avail,
actions: Object.freeze(actions ?? { playFull: true, save: true, addToPlaylist: true, openAlbum: true }),
});
}
/** Immutable, versioned manifest. `createdAtMs` is supplied by the caller so
* everything here stays pure and replayable in tests. */
export function makeManifest({ context, items, createdAtMs }) {
return Object.freeze({
schemaVersion: MANIFEST_SCHEMA_VERSION,
context,
items: Object.freeze([...items]),
createdAtMs,
});
}
/** Sanity check used by tests and by the session boundary in dev builds. */
export function isValidManifest(m) {
if (!m || m.schemaVersion !== MANIFEST_SCHEMA_VERSION) return false;
if (!m.context || !CONTEXT_KINDS.has(m.context.kind)) return false;
if (!Array.isArray(m.items)) return false;
const keys = new Set();
for (const it of m.items) {
if (!it.sessionKey || keys.has(it.sessionKey)) return false; // dupes forbidden
keys.add(it.sessionKey);
// No signed URL may ride in a manifest — identity is provider ids.
for (const s of it.sources ?? []) {
if (typeof s.url === "string") return false;
}
}
return true;
}
+150
View File
@@ -0,0 +1,150 @@
// Audio-focus coordinator and main-transport revision gateway — design §10,
// pure logic over injected transports so every rule is testable without
// audio hardware. The native primitive underneath (Player::pause_acked with
// the desired-paused latch) was proven in Phase 0.4.
//
// Invariants enforced here, verbatim from the design:
// - Focus acquisition can NEVER complete from renderer status alone: it
// requires a silence acknowledgement bound to the captured playback
// generation. A stale/wrong-generation ack leaves preview parked.
// - Main resumes on release only if preview caused the pause AND every
// captured revision (user-command, queue, playback) is unchanged.
// - A boolean resumeMainOnClose does not exist, by construction.
// - Coordinator-issued commands are tagged focus-internal so they do not
// invalidate their own token.
/**
* Revision gateway: the single object every main-player entry point calls
* through. Each user-visible command family bumps its revision; the
* coordinator's own pause/resume pass `{ focusInternal: true }` and bump
* nothing — that is what keeps the token valid across the pause the
* coordinator itself requested.
*/
export function createRevisionGateway() {
const rev = { userCommand: 0, queue: 0, playback: 0 };
const bump = (k, opts) => {
if (opts?.focusInternal) return;
rev[k] += 1;
};
return {
// every ordinary entry point maps to exactly one of these:
userPlay: (o) => (bump("userCommand", o), bump("playback", o)),
userPauseResume: (o) => bump("userCommand", o),
userStop: (o) => (bump("userCommand", o), bump("playback", o)),
userSeekOrScratch: (o) => bump("userCommand", o),
queueJumpOrNextPrev: (o) => (bump("userCommand", o), bump("queue", o), bump("playback", o)),
queueMutation: (o) => bump("queue", o),
autoAdvance: (o) => (bump("queue", o), bump("playback", o)),
nativeSourceChange: (o) => bump("playback", o),
snapshot: () => ({ ...rev }),
changedSince: (snap) =>
snap.userCommand !== rev.userCommand || snap.queue !== rev.queue || snap.playback !== rev.playback,
};
}
/**
* @param {object} deps
* @param {() => {status: string, trackKey: string|null, positionMs: number,
* audible: boolean, playbackGeneration: number}} deps.mainStatus
* Renderer-side view of the main player. Used for the SNAPSHOT only —
* never as proof of silence.
* @param {(generation: number) => Promise<{generation: number, applied: boolean}>}
* deps.requestSilence
* Asks the native transport for an acknowledged pause (pause_acked).
* The returned generation is the one the ack is bound to.
* @param {(snapshot: object, opts: {focusInternal: true}) => void} deps.resumeMain
* Resumes main playback at the snapshot; tagged focus-internal.
* @param {ReturnType<createRevisionGateway>} deps.gateway
* @param {number} [deps.ackTimeoutMs]
*/
export function createFocusCoordinator({ mainStatus, requestSilence, resumeMain, gateway, ackTimeoutMs = 2_000 }) {
let owner = "none"; // none | main | preview
let token = null;
async function acquirePreview() {
const status = mainStatus();
const revisions = gateway.snapshot();
// Main idle or already paused by the user: nothing to silence, and
// nothing to restore later — preview simply takes focus.
if (!status.audible) {
owner = "preview";
token = Object.freeze({
id: `focus-${revisions.userCommand}-${revisions.queue}-${revisions.playback}-${status.playbackGeneration}`,
pausedByPreview: false,
snapshot: null,
revisions,
});
return { granted: true, token };
}
// Main is audible: capture the snapshot, then demand the
// generation-bound acknowledgement. No ack, no focus — a renderer
// status flag or a timeout NEVER substitutes for silence.
const snapshot = Object.freeze({
trackKey: status.trackKey,
positionMs: status.positionMs,
playbackGeneration: status.playbackGeneration,
});
let ack = null;
try {
ack = await withTimeout(requestSilence(status.playbackGeneration), ackTimeoutMs);
} catch {
ack = null;
}
const ackValid =
!!ack && ack.applied === true && ack.generation === snapshot.playbackGeneration;
if (!ackValid || gateway.changedSince(revisions)) {
// Stale generation, timeout, or the user acted mid-negotiation:
// preview stays parked; the caller shows the transient wait state.
return { granted: false, token: null };
}
owner = "preview";
token = Object.freeze({
id: `focus-${revisions.userCommand}-${revisions.queue}-${revisions.playback}-${snapshot.playbackGeneration}`,
pausedByPreview: true,
snapshot,
revisions,
});
return { granted: true, token };
}
/** Release focus. Main resumes ONLY when preview caused the pause and no
* revision moved since the snapshot — otherwise audio stays idle. */
function release(t) {
if (!t || t !== token) return { restored: false, reason: "stale_token" };
owner = "none";
token = null;
if (!t.pausedByPreview) return { restored: false, reason: "main_was_not_audible" };
if (gateway.changedSince(t.revisions)) return { restored: false, reason: "revisions_changed" };
resumeMain(t.snapshot, { focusInternal: true });
owner = "main";
return { restored: true };
}
/** Play-full-track handoff: ownership moves to ordinary playback and the
* old snapshot is intentionally discarded — never restored. */
function handoffToMain() {
owner = "main";
token = null;
return { restored: false, reason: "handoff" };
}
return {
acquirePreview,
release,
handoffToMain,
owner: () => owner,
currentToken: () => token,
};
}
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error("ack timeout")), ms);
promise.then(
(v) => (clearTimeout(t), resolve(v)),
(e) => (clearTimeout(t), reject(e))
);
});
}
+116
View File
@@ -0,0 +1,116 @@
// Ephemeral media leases — the discriminated union from design §12.
//
// A lease is the resolver's answer to "how do I play this item RIGHT NOW":
// either a transport-private URL (webview) or an opaque native handle, plus
// a conservative `safeForMs` after which the transport must re-resolve.
// Leases are never persisted, never used as cache identity, and never logged
// in full (see redact.js).
/**
* Safety margin subtracted from every derived lifetime. ADR (Phase 0): leases
* hard-403 after their signed exp with NO grace observed (206 at +14 min
* against a +14.97 exp, 403 by +17.5). 20 s covers request latency, decoder
* spin-up and one settle-window retry without meaningfully shortening a
* 13-minute lease.
*/
export const LEASE_SAFETY_MARGIN_MS = 20_000;
/**
* Fallback lifetime when provider time data is missing, malformed or
* contradictory. Deliberately short: Phase 0 never observed a lease younger
* than ~13 min, so 60 s is far inside any real lifetime while still letting
* a clip play out (clips are 30 s) before a forced refresh.
*/
export const LEASE_FALLBACK_SAFE_MS = 60_000;
/** Longest lifetime we will ever trust, however healthy the inputs look —
* a parse artifact claiming hours must not disable refresh. */
export const LEASE_MAX_SAFE_MS = 30 * 60_000;
/**
* Derive `safeForMs` from provider time data, per design §12:
* conservative, provider-clock based, never trusting any single input.
*
* @param {object} t
* @param {number|null} t.expEpochMs parsed signed expiry (provider clock)
* @param {number|null} t.dateHeaderMs provider `Date` header (provider clock)
* @param {number} t.localNowMs local wall clock at receipt
* @returns {number} milliseconds the lease may be used, >= 0
*/
export function computeSafeForMs({ expEpochMs, dateHeaderMs, localNowMs }) {
const bad =
!Number.isFinite(expEpochMs) ||
!Number.isFinite(localNowMs) ||
expEpochMs <= 0;
if (bad) return LEASE_FALLBACK_SAFE_MS;
// Remaining lifetime on the provider's own clock when both ends of it
// are known — immune to local clock skew entirely.
let remaining = null;
if (Number.isFinite(dateHeaderMs) && dateHeaderMs > 0) {
remaining = expEpochMs - dateHeaderMs;
// Contradictory provider data (exp at/before Date, or a lifetime far
// beyond anything the provider actually issues) → fallback.
if (remaining <= 0 || remaining > 2 * LEASE_MAX_SAFE_MS) {
return LEASE_FALLBACK_SAFE_MS;
}
}
// Local-clock view. If the local clock is skewed AHEAD of the provider,
// this is smaller than `remaining` and correctly wins (we refresh early).
// If skewed BEHIND, `remaining` wins and the local view can't extend it.
const localView = expEpochMs - localNowMs;
if (localView <= 0 && remaining === null) return LEASE_FALLBACK_SAFE_MS;
const usable =
remaining === null ? localView : Math.min(remaining, localView > 0 ? localView : remaining);
const safe = Math.min(usable, LEASE_MAX_SAFE_MS) - LEASE_SAFETY_MARGIN_MS;
return Math.max(0, safe);
}
/** Build a native-transport lease (the ratified transport). */
export function makeNativeLease({ source, opaqueLeaseId, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) {
if (typeof opaqueLeaseId !== "string" || !opaqueLeaseId) {
throw new Error("native lease requires opaqueLeaseId");
}
return Object.freeze({
transport: "native",
source,
opaqueLeaseId,
safeForMs,
mimeType,
clipDurationMs,
byteLength,
supportsRanges,
});
}
/** Build a webview-transport lease (documented fallback transport). */
export function makeWebviewLease({ source, url, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) {
if (typeof url !== "string" || !url) {
throw new Error("webview lease requires url");
}
return Object.freeze({
transport: "webview",
source,
url,
safeForMs,
mimeType,
clipDurationMs,
byteLength,
supportsRanges,
});
}
/** Union validity: exactly one locator, matching the transport tag. */
export function isValidLease(lease) {
if (!lease || typeof lease !== "object") return false;
if (!Number.isFinite(lease.safeForMs) || lease.safeForMs < 0) return false;
if (lease.transport === "native") {
return typeof lease.opaqueLeaseId === "string" && lease.opaqueLeaseId !== "" && lease.url === undefined;
}
if (lease.transport === "webview") {
return typeof lease.url === "string" && lease.url !== "" && lease.opaqueLeaseId === undefined;
}
return false;
}
+130
View File
@@ -0,0 +1,130 @@
// Manifest builders — design §13. Inputs are the shapes the app ALREADY
// holds (CatalogTrack rows from the artist page / album page); output is the
// immutable PreviewManifest. No fetching happens here, ever: builders are
// pure so the artist adapter can never become a discography crawler.
import { AVAILABILITY, makeContext, makeItem, makeManifest, makeSourceRef } from "./contracts.js";
/** Availability a catalog row can declare up front. The `preview` URL itself
* is treated as one-bit evidence ("a preview exists") and then DISCARDED —
* it expires in minutes and identity is the provider track id. */
function availabilityFromCatalogTrack(t) {
if (t.unreleased) return AVAILABILITY.UNRELEASED;
if (typeof t.preview === "string" && t.preview.length > 0) return AVAILABILITY.AVAILABLE;
return AVAILABILITY.NOT_PROVIDED;
}
function itemFromCatalogTrack(t, ordinal, inherit = {}) {
return makeItem({
sessionKey: `s${ordinal}-dz${t.id}`,
sourceOrdinal: ordinal,
discNumber: t.disk_number ?? undefined,
trackNumber: t.track_no ?? undefined,
recordingKey: `dz:${t.id}`,
title: t.title ?? "",
artists: [t.artist || inherit.artist || ""].filter(Boolean),
album: t.album || inherit.album,
albumId: t.album_id ?? inherit.albumId,
artwork: t.cover ?? inherit.artwork,
fullDurationMs: Number.isFinite(t.duration_secs) ? t.duration_secs * 1000 : undefined,
sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })],
initialAvailability: availabilityFromCatalogTrack(t),
actions: { playFull: !t.unreleased, save: !t.unreleased, addToPlaylist: !t.unreleased, openAlbum: t.album_id != null || inherit.albumId != null },
});
}
/**
* Artist adapter: the COMPLETE ranked top-track payload (never the five
* rendered rows), rank order preserved, collapsing only exact repeated
* provider ids — repetition adds no ordering meaning in a top list.
*/
export function buildArtistManifest({ artist, popularTracks, createdAtMs }) {
const seen = new Set();
const items = [];
for (const t of popularTracks ?? []) {
const key = `dz:${t.id}`;
if (seen.has(key)) continue; // exact provider-id dupe only
seen.add(key);
items.push(itemFromCatalogTrack(t, items.length, { artist: artist?.name, artwork: artist?.picture }));
}
return makeManifest({
context: makeContext({
kind: "artist",
contextKey: `artist:${artist?.id ?? "unknown"}`,
title: artist?.name ?? "",
subtitle: "Top tracks",
artwork: artist?.picture,
sourceTrackCount: (popularTracks ?? []).length,
}),
items,
createdAtMs,
});
}
/**
* Catalog album adapter: disc/track order (then stable response order),
* context metadata inherited by rows whose track endpoint omitted nested
* album data, unavailable tracks preserved in source counts.
*/
export function buildAlbumManifest({ album, tracks, createdAtMs }) {
const rows = (tracks ?? [])
.map((t, i) => ({ t, i }))
.sort((a, b) =>
(a.t.disk_number ?? 1) - (b.t.disk_number ?? 1) ||
(a.t.track_no ?? 0) - (b.t.track_no ?? 0) ||
a.i - b.i
);
const inherit = {
artist: album?.artist,
album: album?.title,
albumId: album?.id,
artwork: album?.cover,
};
return makeManifest({
context: makeContext({
kind: "album",
contextKey: `album:${album?.id ?? "unknown"}`,
title: album?.title ?? "",
subtitle: album?.artist,
artwork: album?.cover,
sourceTrackCount: (tracks ?? []).length,
}),
items: rows.map(({ t }, ordinal) => itemFromCatalogTrack(t, ordinal, inherit)),
createdAtMs,
});
}
/**
* Generic fixture adapter for tests and future surfaces (playlists keep
* duplicates: occurrences get distinct sessionKeys, shared recordingKey).
*/
export function buildFixtureManifest({ kind = "collection", contextKey = "fixture", title = "Fixture", rows, createdAtMs }) {
const items = (rows ?? []).map((r, ordinal) =>
makeItem({
sessionKey: `s${ordinal}-${r.provider ?? "deezer"}${r.providerTrackId}`,
sourceOrdinal: ordinal,
recordingKey: `${r.provider ?? "deezer"}:${r.providerTrackId}`,
title: r.title ?? "",
artists: r.artists ?? [],
album: r.album,
albumId: r.albumId,
artwork: r.artwork,
fullDurationMs: r.fullDurationMs,
explicit: r.explicit,
sources: [
makeSourceRef({
provider: r.provider ?? "deezer",
providerTrackId: r.providerTrackId,
isrc: r.isrc,
matchConfidence: r.matchConfidence ?? "native",
}),
],
initialAvailability: r.initialAvailability ?? AVAILABILITY.AVAILABLE,
})
);
return makeManifest({
context: makeContext({ kind, contextKey, title, sourceTrackCount: items.length }),
items,
createdAtMs,
});
}
+64
View File
@@ -0,0 +1,64 @@
// URL redaction and provider host policy for the preview feature.
//
// Signed preview URLs are ephemeral secrets: they carry an hmac and expire in
// minutes (Phase 0: 13–15 min, then 403). They must never appear in logs,
// session persistence, or diagnostics. Everything that wants to talk about a
// URL goes through redactUrl() and gets host + a stable hash + the parsed
// expiry — enough to correlate, nothing to replay.
/** Providers the preview resolver may fetch media from. HTTPS only; exact
* host match — no subdomain wildcards, because the CDN host is a single
* stable name (Phase 0: 71/71 samples on cdnt-preview.dzcdn.net). */
export const MEDIA_HOST_ALLOWLIST = Object.freeze([
"cdnt-preview.dzcdn.net",
// Apple preview hosts land here in Phase 2 when Apple identity is
// plumbed through the backend; adding a host is a reviewed change.
]);
/** Parse the signed expiry out of a preview URL. Deezer uses an Akamai
* `hdnea=exp=<epoch-seconds>~…` token; a bare `exp=` query also matches.
* Returns epoch MILLISECONDS or null — never throws. */
export function parseSignedExpiryMs(url) {
if (typeof url !== "string") return null;
const m = /(?:^|[?&~])(?:hdnea=)?exp=(\d{9,11})(?:[~&]|$)/.exec(url);
if (!m) return null;
return Number(m[1]) * 1000;
}
/** FNV-1a — tiny, stable, dependency-free. Not cryptographic and does not
* need to be: the hash only correlates log lines about the same URL. */
function fnv1a(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
/** The only representation of a media URL allowed outside the transport:
* `{ host, hash, expMs }`. */
export function redactUrl(url) {
if (typeof url !== "string" || url.length === 0) {
return { host: null, hash: null, expMs: null };
}
let host = null;
try {
host = new URL(url).host;
} catch {
/* malformed: host stays null, hash still correlates */
}
return { host, hash: fnv1a(url), expMs: parseSignedExpiryMs(url) };
}
/** True only for an https URL whose host is on the media allowlist. Used for
* the initial URL and re-checked for every redirect target — a redirect off
* the allowlist is a hard failure, not a follow. */
export function isAllowedMediaUrl(url) {
try {
const u = new URL(url);
return u.protocol === "https:" && MEDIA_HOST_ALLOWLIST.includes(u.host);
} catch {
return false;
}
}
+63
View File
@@ -0,0 +1,63 @@
// PreviewResolver contract (design §14) plus the fixture implementation the
// modal is built against in Phase 2 before the production resolver exists.
//
// Contract: resolve(sourceRef, {generation, bypassCache}) → Promise of a
// discriminated result:
// { ok: true, lease: MediaLease, generation }
// { ok: false, failure: <class>, generation, retryable }
//
// Failure classes mirror what Phase 0 actually observed:
// "stale_lease" — 403 from the CDN (signed exp passed). Retryable once
// with bypassCache: the API mints a fresh URL per fetch.
// "not_provided" — provider answered but has no preview (empty field).
// "not_found" — HTTP-200 DataException body (Deezer's error shape).
// "network" — timeout / transport error. Retryable with backoff.
// "invalid_media" — response wasn't audio/mpeg (e.g. the Akamai HTML
// error page) or failed the decoder sniff.
// The resolver NEVER loops providers and never retries without a cap —
// callers get one bypassCache retry per user intent, then a failure state.
import { isValidLease, makeNativeLease } from "./lease.js";
/**
* Fixture resolver for tests and Phase 2 UI bring-up. `plan` maps
* providerTrackId → either a lease-ish object or {failure, retryable} or a
* function(callCount, opts) returning one of those, so tests can script
* "fail once, then succeed" or artificial latency deterministically.
*/
export function createFixtureResolver(plan, { defaultSafeForMs = 300_000 } = {}) {
const calls = new Map(); // providerTrackId -> count
return {
async resolve(sourceRef, { generation, bypassCache = false } = {}) {
const id = sourceRef.providerTrackId;
const n = (calls.get(id) ?? 0) + 1;
calls.set(id, n);
let spec = plan[id];
if (typeof spec === "function") spec = spec(n, { bypassCache });
if (spec && typeof spec.then === "function") spec = await spec;
if (!spec) {
return { ok: false, failure: "not_found", generation, retryable: false };
}
if (spec.failure) {
return { ok: false, failure: spec.failure, generation, retryable: !!spec.retryable };
}
const lease = makeNativeLease({
source: sourceRef,
opaqueLeaseId: spec.opaqueLeaseId ?? `fixture-${id}-${n}`,
safeForMs: spec.safeForMs ?? defaultSafeForMs,
mimeType: "audio/mpeg",
clipDurationMs: spec.clipDurationMs ?? 30_000,
byteLength: spec.byteLength ?? 480_000,
supportsRanges: true,
});
if (!isValidLease(lease)) throw new Error("fixture produced invalid lease");
return { ok: true, lease, generation };
},
callCount(id) {
return calls.get(id) ?? 0;
},
};
}
+277
View File
@@ -0,0 +1,277 @@
// 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;
}
}
+82
View File
@@ -0,0 +1,82 @@
// Preview transport interface — the seam between the pure session core and
// whatever actually makes sound. Phase 0 ratified the NATIVE transport; this
// file pins the contract and provides the deterministic fake that Phase 2
// builds the modal against before the native backend lands.
//
// Contract (all methods take {sessionId, generation} and the transport
// stamps them onto every event it emits — the reducer's guard does the rest):
//
// load(lease, ctx) accept a MediaLease; emit LEASE-side effects only
// play(ctx) request audibility (focus must already be held)
// pause(ctx) park; position is retained
// stop(ctx) discard media; a later play needs a fresh load
// dispose() teardown at session close — must be idempotent
//
// Events are dispatched as reducer actions: MEDIA_PLAYING, MEDIA_PAUSED,
// MEDIA_POSITION, MEDIA_ENDED, ITEM_FAILED. A transport NEVER dispatches
// with a generation other than the one the command carried — stale
// suppression is the reducer's job, honest stamping is the transport's.
import { isValidLease } from "./lease.js";
/**
* Deterministic fake: no timers — the test (or storybook harness) calls
* `tick(ms)` to advance playback. Emits exactly what the native transport
* will emit, so swapping it out changes no session code.
*/
export function createFixtureTransport(dispatch) {
let current = null; // { lease, sessionId, generation, sessionKey, positionMs, playing }
let disposed = false;
const emit = (type, extra = {}) => {
if (!current) return;
dispatch({
type,
sessionId: current.sessionId,
generation: current.generation,
...extra,
});
};
return {
load(lease, { sessionId, generation, sessionKey }) {
if (disposed) throw new Error("transport disposed");
if (!isValidLease(lease)) {
current = { sessionId, generation, sessionKey, positionMs: 0, playing: false };
emit("ITEM_FAILED", { sessionKey, errorClass: "invalid_media" });
current = null;
return;
}
current = { lease, sessionId, generation, sessionKey, positionMs: 0, playing: false };
},
play() {
if (!current) return;
current.playing = true;
emit("MEDIA_PLAYING");
},
pause() {
if (!current) return;
current.playing = false;
emit("MEDIA_PAUSED");
},
stop() {
current = null;
},
dispose() {
disposed = true;
current = null;
},
/** Advance fake playback; emits POSITION then ENDED at the clip end. */
tick(ms) {
if (!current || !current.playing) return;
const clip = current.lease?.clipDurationMs ?? 30_000;
current.positionMs = Math.min(current.positionMs + ms, clip);
emit("MEDIA_POSITION", { positionMs: current.positionMs });
if (current.positionMs >= clip) {
current.playing = false;
emit("MEDIA_ENDED");
}
},
_debugState: () => (current ? { ...current } : null),
};
}