Skip to content

Commit 875649f

Browse files
committed
feat(addToRotation): library_identity lookup + LML resolve + allowlist (BS#1380 PR 4/5)
Fourth in the BS#1380 chain. Chains feature/bs-1380-3-rotation-etl-drift (which carries PR 1's schema). Depends on PR 2 (#1418) for the `resolveIdentity` wrapper; merged in here so this branch typechecks. apps/backend/services/library.service.ts:addToRotation: - Synchronously looks up `library_identity.discogs_release_id` for the requested album_id (library_identity.library_id is PRIMARY KEY per schema.ts:1391-1393). - If non-NULL, awaits `resolveIdentity({kind:'release', source:'discogs_release', external_id})` before INSERT to mint a stable lml_identity_id. - Triple-writes (discogs_release_id, discogs_release_id_source = 'library_identity', lml_identity_id) on the INSERT. - On resolve failure (timeout / 5xx / network), the first two still land; lml_identity_id falls back to NULL. The source-of-the-Discogs-id is library_identity regardless of whether the mint succeeded — kill_date forensics, BS#1029 backfill scoping, and the future discogs_release_id retirement migration all rely on this column being honest. - The daily backfill cron (PR 5) catches up within ~24h. apps/backend/services/library.service.ts:classifyLmlResolveError: - Pure classifier mapping `unknown` → `LmlResolveFallbackReason` (`timeout` | `5xx` | `4xx` | `network` | `other`). Each bucket signals a different operational response (timeout → LML latency, 5xx → LML deploy regression, 4xx → BS-side caller bug, network → infra noise). Sentry counter `lml.resolve.fallback_to_null` carries the result as a `reason` attribute alongside `caller=add_to_rotation`. - Disambiguates LmlClientError statusCode 502 between upstream-5xx (message starts with "LML responded with") and network/fetch-threw (message starts with "LML request failed"). apps/backend/controllers/library.controller.ts:pickAddRotationFields: - Signature-typed allowlist mirroring flowsheet.controller.ts pickUpdateEntryFields (BS#1099). Only {album_id, rotation_bin} pass through. - Server-derived columns (legacy_*, discogs_release_id*, lml_identity_id, tracklist_lookup_attempted_at, kill_date) and tubafrenzy-ETL-only snapshot columns (add_date, artist_name, album_title, record_label) must never be client-supplied through this endpoint — addToRotation derives the LML-handle columns from library_identity + the synchronous resolveIdentity hop, and tubafrenzy is the only legitimate source for the snapshot columns. - Phrased as an allowlist so future column additions to `rotation` are implicitly rejected by typecheck until explicitly added to the signature. Unit tests: - classifyLmlResolveError: every catch-block branch (AbortError → timeout, 504 → timeout, 502 + "LML responded" message → 5xx, 502 + "LML request failed" message → network, 422 → 4xx, 400 → 4xx, 500 → 5xx, ECONNRESET / ENOTFOUND / ECONNREFUSED → network, plain Error → other, non-Error throwables → other). - addToRotation: library_identity hit + resolve success → triple-write lands correctly; library_identity hit + resolve failure → first two fields land, lml_identity_id NULL, Sentry counter fires once with reason=timeout; library_identity miss → no LML call, no triple-write; reads library_identity by library_id PRIMARY KEY. Integration test (tests/integration/library.spec.js): - POST /library/rotation with non-allowlisted fields in the body (forbidden server-derived + ETL-only) verifies the persisted row has those fields server-set / NULL, not client-supplied. Refs #1380
1 parent f9e1641 commit 875649f

4 files changed

Lines changed: 495 additions & 2 deletions

File tree

apps/backend/controllers/library.controller.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,43 @@ export const getRotation: RequestHandler = async (req, res) => {
307307
};
308308

309309
export type RotationAddRequest = Omit<NewRotationRelease, 'id'>;
310+
311+
/**
312+
* Pick only the fields the client is allowed to write through the public
313+
* `POST /library/rotation` endpoint (BS#1380). Mirrors
314+
* `pickUpdateEntryFields()` in flowsheet.controller.ts (BS#1099).
315+
*
316+
* Server-derived columns (`legacy_rotation_id`, `legacy_library_release_id`,
317+
* `discogs_release_id`, `discogs_release_id_source`, `lml_identity_id`,
318+
* `tracklist_lookup_attempted_at`, `kill_date`) and tubafrenzy-ETL-only
319+
* snapshot columns (`add_date`, `artist_name`, `album_title`,
320+
* `record_label`) must never be client-supplied through this endpoint —
321+
* `addToRotation` derives the LML-handle columns from `library_identity`
322+
* and the synchronous `resolveIdentity` hop, and tubafrenzy is the only
323+
* legitimate source for the snapshot columns.
324+
*
325+
* Phrased as an allowlist (signature-typed accept list) so a future column
326+
* addition to `rotation` is implicitly rejected by typecheck until
327+
* explicitly added to the signature. Matches dj-site's `RotationParams`
328+
* (`{ album_id, rotation_bin }`); widen the signature here when a future
329+
* caller legitimately needs another field.
330+
*/
331+
type AddRotationAllowlist = Pick<NewRotationRelease, 'album_id' | 'rotation_bin'>;
332+
333+
export function pickAddRotationFields(body: Partial<NewRotationRelease>): AddRotationAllowlist {
334+
const picked = {} as AddRotationAllowlist;
335+
if (body.album_id !== undefined) picked.album_id = body.album_id;
336+
if (body.rotation_bin !== undefined) picked.rotation_bin = body.rotation_bin;
337+
return picked;
338+
}
339+
310340
export const addRotation: RequestHandler<object, unknown, NewRotationRelease> = async (req, res) => {
311341
if (req.body.album_id === undefined || req.body.rotation_bin === undefined) {
312342
throw new WxycError('Missing Parameters: album_id or rotation_bin', 400);
313343
}
314344

315-
const rotationRelease: RotationRelease = await libraryService.addToRotation(req.body);
345+
const picked = pickAddRotationFields(req.body);
346+
const rotationRelease: RotationRelease = await libraryService.addToRotation(picked);
316347
res.status(201).json(rotationRelease);
317348
};
318349

apps/backend/services/library.service.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
isLmlConfigured,
3737
envInt,
3838
LmlClientError,
39+
resolveIdentity,
3940
type LookupResponse,
4041
type DiscogsTrackItem,
4142
type DiscogsReleaseMetadata,
@@ -340,8 +341,136 @@ export const getRotationFromDB = async (): Promise<Rotation[]> => {
340341
return rows.map((row) => serializeReconciledIdentity(row));
341342
};
342343

344+
/**
345+
* BS#1380: Sentry counter `lml.resolve.fallback_to_null` reason buckets.
346+
* Each value signals a different operational response:
347+
* - `timeout`: LML latency/capacity (look at LML p95)
348+
* - `5xx`: LML deploy regression (look at LML release notes)
349+
* - `4xx`: BS-side caller bug (the library_identity row pointed at
350+
* a sentinel ID LML rejects, etc.)
351+
* - `network`: infra noise (transient TCP/DNS failures)
352+
* - `other`: uncategorised — investigate per-event
353+
*
354+
* Surfaced separately so a per-bucket regression doesn't get masked by the
355+
* rollup. Revisit (split the knob or tune the value) if Sentry shows p95
356+
* user-facing add-to-rotation > 2.5 s OR fallback rate > 5% in any bucket.
357+
*/
358+
export type LmlResolveFallbackReason = 'timeout' | '5xx' | '4xx' | 'network' | 'other';
359+
360+
/**
361+
* Classify the error caught from `resolveIdentity(...)` into one of the
362+
* five operational buckets above. Pulled out so the unit test can
363+
* exhaustively exercise every branch without spinning up an LML server.
364+
*
365+
* `LmlClientError` is the wrapper `lmlFetch` throws; its `statusCode` was
366+
* already translated upstream (5xx → 502, AbortError → 504). The catch
367+
* here untranslates so the Sentry attribute reads as the original
368+
* operational signal.
369+
*/
370+
export function classifyLmlResolveError(err: unknown): LmlResolveFallbackReason {
371+
if (!err || typeof err !== 'object') return 'other';
372+
const e = err as { name?: string; code?: string; statusCode?: number; message?: string };
373+
if (e.name === 'AbortError') return 'timeout';
374+
if (err instanceof LmlClientError) {
375+
// `lmlFetch` translates `AbortError` → 504 and upstream 5xx → 502; restore
376+
// the operational bucket from those translations + the raw status range.
377+
if (e.statusCode === 504) return 'timeout';
378+
if (e.statusCode === 502) {
379+
// 502 is overloaded: it's the wrapper for both upstream 5xx and a raw
380+
// fetch failure (the catch-all in `lmlFetch`). Disambiguate from the
381+
// message — "LML request failed" is the fetch-threw path.
382+
return typeof e.message === 'string' && e.message.startsWith('LML request failed') ? 'network' : '5xx';
383+
}
384+
if (typeof e.statusCode === 'number') {
385+
if (e.statusCode >= 500) return '5xx';
386+
if (e.statusCode >= 400) return '4xx';
387+
}
388+
return 'other';
389+
}
390+
if (e.code === 'ECONNRESET' || e.code === 'ENOTFOUND' || e.code === 'ECONNREFUSED') {
391+
return 'network';
392+
}
393+
return 'other';
394+
}
395+
396+
/**
397+
* Add an album to rotation (dj-site path, `POST /library/rotation`).
398+
*
399+
* Synchronously triple-writes `(discogs_release_id, discogs_release_id_source =
400+
* 'library_identity', lml_identity_id)` when the album's `library_identity`
401+
* row supplies a non-NULL `discogs_release_id` (BS#1380):
402+
*
403+
* 1. Read `library_identity.discogs_release_id` for the requested
404+
* `album_id`. `library_identity.library_id` is the PRIMARY KEY
405+
* (see schema.ts:1391-1393); `LIMIT 1` is defensive.
406+
* 2. If non-NULL, `await resolveIdentity({kind:'release',
407+
* source:'discogs_release', external_id})` to mint a stable
408+
* `lml_identity_id`. The hop runs inside this handler — the response
409+
* row carries the populated columns without needing a follow-up
410+
* patch.
411+
* 3. INSERT all three columns. On resolve failure (timeout / 5xx /
412+
* network), `lml_identity_id` falls back to NULL but
413+
* `discogs_release_id` + `discogs_release_id_source = 'library_identity'`
414+
* still land — the *source of the Discogs id* is library_identity
415+
* regardless of whether the mint succeeded. The daily backfill cron
416+
* catches up within ~24h.
417+
*
418+
* Rationale for synchronous-before-INSERT: dj-site's optimistic UI
419+
* (WXYC/dj-site#648) covers the latency tail at the client side — the row
420+
* appears in-rotation immediately on click. Blocking the music director on
421+
* an LML outage is worse than a temporarily-incomplete row, so the catch
422+
* arm proceeds rather than rethrowing.
423+
*/
343424
export const addToRotation = async (newRotation: RotationAddRequest) => {
344-
const insertedRotation: RotationRelease[] = await db.insert(rotation).values(newRotation).returning();
425+
const values: RotationAddRequest = { ...newRotation };
426+
427+
// Allowlist guard already runs at the controller layer, but the server-
428+
// derived fields below must always come from this function, not the
429+
// request — defense in depth.
430+
if (values.album_id != null) {
431+
const [identityRow] = await db
432+
.select({ discogs_release_id: library_identity.discogs_release_id })
433+
.from(library_identity)
434+
.where(eq(library_identity.library_id, values.album_id))
435+
.limit(1);
436+
const discogsReleaseId = identityRow?.discogs_release_id ?? null;
437+
438+
if (discogsReleaseId != null) {
439+
// The source-of-the-Discogs-id is library_identity, regardless of
440+
// whether the LML mint that follows succeeds.
441+
values.discogs_release_id = discogsReleaseId;
442+
values.discogs_release_id_source = 'library_identity';
443+
444+
let lmlIdentityId: number | null = null;
445+
try {
446+
const resolved = await resolveIdentity({
447+
kind: 'release',
448+
source: 'discogs_release',
449+
external_id: String(discogsReleaseId),
450+
});
451+
lmlIdentityId = resolved.identity_id;
452+
} catch (err) {
453+
const reason = classifyLmlResolveError(err);
454+
// BS#1380: classify once at fallback time so each reason bucket
455+
// signals a different operational response (timeout → LML
456+
// latency, 5xx → LML deploy regression, 4xx → BS-side caller bug,
457+
// network → infra noise). v10 SDK shape — `Sentry.metrics.count(
458+
// name, value, {attributes})`. Pre-v8
459+
// `Sentry.metrics.increment(..., {tags})` was removed.
460+
try {
461+
Sentry.metrics.count('lml.resolve.fallback_to_null', 1, {
462+
attributes: { caller: 'add_to_rotation', reason },
463+
});
464+
} catch {
465+
// Observability must never break the request path; swallow.
466+
}
467+
// lml_identity_id stays NULL; INSERT proceeds.
468+
}
469+
values.lml_identity_id = lmlIdentityId;
470+
}
471+
}
472+
473+
const insertedRotation: RotationRelease[] = await db.insert(rotation).values(values).returning();
345474
return insertedRotation[0];
346475
};
347476

tests/integration/library.spec.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,61 @@ describe('Library Rotation', () => {
346346

347347
expectErrorContains(res, 'Missing Parameters');
348348
});
349+
350+
// BS#1380 — pickAddRotationFields allowlist. A client that includes
351+
// server-derived (`discogs_release_id`, `discogs_release_id_source`,
352+
// `lml_identity_id`, `legacy_rotation_id`, `legacy_library_release_id`,
353+
// `tracklist_lookup_attempted_at`, `kill_date`) or ETL-only
354+
// (`add_date`, `artist_name`, `album_title`, `record_label`) fields
355+
// in the request body must not be able to set them through this
356+
// endpoint. Persisted row must reflect server defaults / NULL.
357+
test('allowlist drops client-supplied non-allowlisted fields', async () => {
358+
const res = await auth
359+
.post('/library/rotation')
360+
.send({
361+
album_id: 3,
362+
rotation_bin: 'L',
363+
// Forbidden fields the allowlist must drop.
364+
discogs_release_id: 999777,
365+
discogs_release_id_source: 'discogs_direct_backfill',
366+
lml_identity_id: 8888888,
367+
legacy_rotation_id: 555_000_001,
368+
legacy_library_release_id: 555_000_002,
369+
kill_date: '2027-01-01',
370+
artist_name: 'Forged Artist',
371+
album_title: 'Forged Album',
372+
record_label: 'Forged Label',
373+
})
374+
.expect(201);
375+
376+
// Server-derived fields land NULL when library_identity has no row
377+
// (seed_db.sql ships no library_identity rows for album_id=3) and
378+
// operator-only fields stay NULL.
379+
expect(res.body.discogs_release_id).toBeNull();
380+
expect(res.body.lml_identity_id).toBeNull();
381+
expect(res.body.legacy_rotation_id).toBeNull();
382+
expect(res.body.legacy_library_release_id).toBeNull();
383+
expect(res.body.kill_date).toBeNull();
384+
// ETL-only denormalized snapshot fields stay NULL.
385+
expect(res.body.artist_name).toBeNull();
386+
expect(res.body.album_title).toBeNull();
387+
expect(res.body.record_label).toBeNull();
388+
// The two allowed fields land verbatim.
389+
expect(res.body.album_id).toBe(3);
390+
expect(res.body.rotation_bin).toBe('L');
391+
// discogs_release_id_source falls back to the column default
392+
// ('tubafrenzy_paste') when addToRotation doesn't supply
393+
// library_identity. This is acceptable today for the no-
394+
// library_identity path; the BS#1380 plan is honest about it
395+
// (the library_identity branch tags 'library_identity' explicitly,
396+
// the no-library_identity branch leaves the column default).
397+
expect(res.body.discogs_release_id_source).toBe('tubafrenzy_paste');
398+
399+
// Clean up.
400+
if (res.body.id) {
401+
await auth.patch('/library/rotation').send({ rotation_id: res.body.id });
402+
}
403+
});
349404
});
350405

351406
describe('PATCH /library/rotation (Kill Rotation)', () => {

0 commit comments

Comments
 (0)