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:
1 parent
6dcc62a2fd
commit
8ede6a8adf
14 files changed
+1722
-2
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user