Files
SoulStream/src/lib/preview/contracts.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

126 lines
4.6 KiB
JavaScript

// 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;
}