Skip to content

Consolidate ArtistSearchAliasSource type and partition into a single source of truth #1390

@jakebromberg

Description

@jakebromberg

Background

BS#1383 introduced the synonym/relational partition for artist_search_alias.source in jobs/concerts-artist-resolver/query.ts (the resolver's FK-write site). To avoid scope creep, that PR exported SYNONYM_ALIAS_SOURCES + RELATIONAL_ALIAS_SOURCES from the resolver file itself and updated only the immediate consumer (the unit test).

This ticket tracks the broader consolidation surfaced by the BS#1383 round-2 review: the ArtistSearchAliasSource type union is declared (or referenced) in three other places, none of which import from each other or from the resolver's partition constants.

Drift sites

  1. jobs/artist-search-alias-consumer/lml-types.ts:17-21 — closed union of the four sources. The composer/writer side.
  2. apps/backend/services/requestLine/types.ts:17-22open union (| (string & {})), used by the catalog-search wire-shape projection. The open-vs-closed call is load-bearing — see "Open-vs-closed wire shape" below.
  3. tests/unit/jobs/artist-search-alias-consumer/orchestrate.test.ts:39 — inline literal union duplicating the same four values.
  4. jobs/concerts-artist-resolver/query.ts:68-71 — the SYNONYM_ALIAS_SOURCES + RELATIONAL_ALIAS_SOURCES const tuples and derived types (added by fix(concerts-artist-resolver): exclude discogs_member from alias arm (BS#1383) #1384 / BS#1383). Resolver-side tests in tests/unit/jobs/concerts-artist-resolver/query.test.ts already import these constants — that file is not a drift site; the 'discogs_alias'-style literals in its mock-row fixtures are intentional data, not type drift.

Failure mode

LML adds a fifth source (e.g., discogs_collaborator, intended as relational-class).

  • Consumer's ArtistSearchAliasSource union is widened in lml-types.ts. The writer accepts the new source string (already iterated generically; DB column is text with no CHECK).
  • The resolver's SYNONYM_ALIAS_SOURCES is unchanged — so the new source stays out of the FK-write path (safe-by-default; that's the point of the positive allowlist).
  • BUT: nothing fails to remind the maintainer that the new source needs a deliberate classification choice. If it should have been synonym-class, it silently drops every concert mention to manual review with no operator-facing signal.
  • And: nothing checks that lml-types.ts's union ⊇ SYNONYM_ALIAS_SOURCES ∪ RELATIONAL_ALIAS_SOURCES. The two could drift in either direction.

Proposed shape

A single canonical module — shared/database/src/artist-search-alias-sources.ts — keyed on a const tuple of all known sources, with the type derived from it and the partition tuples derived against it:

export const SYNONYM_ALIAS_SOURCES = ['discogs_name_variation', 'discogs_alias', 'wxyc_library_alt'] as const;
export const RELATIONAL_ALIAS_SOURCES = ['discogs_member'] as const;
export const ALL_ALIAS_SOURCES = [...SYNONYM_ALIAS_SOURCES, ...RELATIONAL_ALIAS_SOURCES] as const;
export type ArtistSearchAliasSource = (typeof ALL_ALIAS_SOURCES)[number];

// Type-level exhaustiveness: every value in the union is in exactly one partition.
// The brand name embeds the ticket so a future tsc failure grep-points back here.
type _AssertAllSourcesClassified_BS1390 =
  Exclude<ArtistSearchAliasSource, (typeof SYNONYM_ALIAS_SOURCES)[number] | (typeof RELATIONAL_ALIAS_SOURCES)[number]> extends never
    ? true
    : 'BS#1390: a source in ALL_ALIAS_SOURCES is not classified into SYNONYM_ALIAS_SOURCES or RELATIONAL_ALIAS_SOURCES';
const _check: _AssertAllSourcesClassified_BS1390 = true;

This is the compile-error-on-omission property the ticket actually wants: adding a 5th value to ALL_ALIAS_SOURCES without also placing it in one partition tuple fails tsc with a message naming this ticket.

shared/database is the right home — semantically these strings are values of the artist_search_alias.source text column, and every consumer (apps/backend, jobs/concerts-artist-resolver, jobs/artist-search-alias-consumer) already depends on @wxyc/database. New file, not schema.ts. The lml-types.ts "decoupled from @wxyc/shared" rationale does not apply because @wxyc/database is a local workspace, not a GitHub Packages registry.

The resolver's SYNONYM_ALIAS_SOURCES + RELATIONAL_ALIAS_SOURCES exports become re-exports from the canonical module so existing callers keep working without churn.

Classifier function — deferred follow-up

A typed classifyAliasSource(s): 'synonym' | 'relational' is the natural next step, but its value is runtime branching (e.g., iOS / dj-site rendering "related artist" vs "alternate name" off the wire). The resolver's compile-time IN-list does not need it — SYNONYM_ALIAS_SOURCES already gives the same compile-time guarantee. Defer until the first runtime consumer lands; track separately.

Open-vs-closed wire shape

requestLine/types.ts currently leaves ArtistSearchAliasSource open with | (string & {}) so the projection cast stays tolerant of unknown DB values. This was load-bearing pre-consolidation; it stops being so once the canonical module exists. Close it. Rationale:

  • The DB only contains values the writer wrote.
  • The writer only writes values that came from LML's ArtistSearchAliasVariant.source, typed against the canonical union.
  • The "wire-format break" risk was hypothetical — closing the union forces iOS / dj-site to update when a 5th source is added, which is the point of the consolidation. Without that, the open union absorbs unknown values into the projection cast and the silent-mislabel hazard from the failure-mode section recurs at the wire boundary.

If a future use case genuinely needs an opaque variant (e.g., a search projection that wants to round-trip an unrecognised DB value through to the client), introduce OpaqueArtistSearchAliasSource = ArtistSearchAliasSource | (string & {}) then. Today none of the consumers want that — the projection cast at library.service.ts:111 and library-search.service.ts:319 only consumes the value to pass it back to iOS, which is itself going to be typed against the closed union.

Acceptance

  • New module shared/database/src/artist-search-alias-sources.ts exports:
    • SYNONYM_ALIAS_SOURCES (const tuple)
    • RELATIONAL_ALIAS_SOURCES (const tuple)
    • ALL_ALIAS_SOURCES (const tuple = [...SYNONYM, ...RELATIONAL])
    • ArtistSearchAliasSource (type derived from (typeof ALL_ALIAS_SOURCES)[number])
  • Type-level exhaustiveness assertion whose identifier embeds the ticket number (e.g., _AssertAllSourcesClassified_BS1390) so a missing classification fails tsc with a grep-able pointer to this ticket.
  • jobs/artist-search-alias-consumer/lml-types.ts imports ArtistSearchAliasSource from the canonical module (no inline union).
  • apps/backend/services/requestLine/types.ts imports ArtistSearchAliasSource from the canonical module and is closed (drop | (string & {})). Update the as ArtistSearchAliasSource casts in library.service.ts:111 and library-search.service.ts:319 to stay typecheck-clean — they should remain casts (the values come from raw SQL), but against the closed union.
  • tests/unit/jobs/artist-search-alias-consumer/orchestrate.test.ts:39 imports ArtistSearchAliasSource from the canonical module (drop the inline literal union in makeLmlResult).
  • jobs/concerts-artist-resolver/query.ts:68-71 re-exports SYNONYM_ALIAS_SOURCES and RELATIONAL_ALIAS_SOURCES from the canonical module. Resolver SQL IN-list still builds from SYNONYM_ALIAS_SOURCES, unchanged. The drift-hazard JSDoc paragraph (currently referencing BS#1390) gets replaced by a one-liner pointing at the canonical module.
  • Adding a 5th value to ALL_ALIAS_SOURCES without classifying it fails tsc — verified by a deliberate local edit during PR review (no need to land a permanent failing test).
  • classifyAliasSource() function — out of scope; deferred to a follow-up when the first runtime consumer of the partition lands.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    catalog-search-perf2026-04-28 catalog-search investigation cluster (incl. overlapping work)choreMaintenance and housekeepingkind:followupPost-MVP follow-up (paired with cross-cache-identity-followup)status:readyActionable now — no upstream blockers

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions