From mateonunez-skills
Normalizes external API responses from vendors like Spotify, GitHub, Linear into typed NormalizedEntity with __type discriminator, namespaced id, and fixed shape. Use for ETL ingestion, new connectors, entity types, or mappers.
npx claudepluginhub mateonunez/skillsThis skill uses the workspace's default tool permissions.
> The vendor's wire format is not my domain model.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Processes PDFs: extracts text/tables/images, merges/splits/rotates pages, adds watermarks, creates/fills forms, encrypts/decrypts, OCRs scans. Activates on PDF mentions or output requests.
Share bugs, ideas, or general feedback.
The vendor's wire format is not my domain model.
External APIs return whatever they want — Spotify returns external_urls.spotify, GitHub returns html_url, Linear returns url. If raw vendor responses leak into the rest of the codebase, every consumer ends up special-casing every vendor. The fix is to normalise at the boundary: one shape, one discriminator, vendor-specific noise tucked into a metadata bag.
This is the ait ETL pattern. It's the data-channel counterpart to result-not-throw (the error channel). Together they make the type system useful: errors are typed, data is typed, the boundary is the only place either lives in the wild.
You are about to:
EntityType union or its VALID_ENTITY_TYPES setexport type EntityType =
| 'spotify_track' | 'spotify_artist' | 'spotify_album'
| 'github_repository' | 'github_pull_request' | 'github_issue'
| 'linear_issue' | 'notion_page' | 'slack_message'
// ...
;
export interface NormalizedEntity {
__type: EntityType;
id: string; // namespaced: `${__type}_${externalId}`
externalId: string; // vendor's ID, untouched
title: string;
description?: string;
url?: string;
metadata: Record<string, unknown>; // vendor-specific extras
createdAt: Date;
updatedAt: Date;
}
Two invariants:
__type is the discriminator the rest of the system switches on. Format: <vendor>_<resource>. Lowercase, snake_case. No exceptions — SpotifyTrack is wrong, spotify-track is wrong.id is namespaced: ${__type}_${externalId}. Two vendors can have the same externalId; the namespaced id is globally unique.Every connector exports a function per resource: map<Vendor><Resource>(raw) → NormalizedEntity.
// packages/connectors/src/domain/mappers/spotify.mapper.ts
export function mapSpotifyTrack(raw: SpotifyApi.TrackObject): NormalizedEntity {
return {
__type: 'spotify_track',
id: `spotify_track_${raw.id}`,
externalId: raw.id,
title: raw.name,
description: `${raw.artists.map(a => a.name).join(', ')} — ${raw.album.name}`,
url: raw.external_urls.spotify,
metadata: {
duration_ms: raw.duration_ms,
popularity: raw.popularity,
album: raw.album.name,
artists: raw.artists.map(a => a.name),
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
Mappers are pure. No IO, no logging, no side effects — just vendor → domain. That makes them trivial to test and trivial to compose.
EntityType union. Stable name: <vendor>_<resource>.VALID_ENTITY_TYPES set. That's the runtime validator.packages/connectors/src/domain/mappers/<vendor>.mapper.ts.packages/core/src/types/integrations/<vendor>.ts.Five steps, every time, in that order. Skipping any of them creates silent failure modes downstream.
Because every entity has the same shape, downstream code can be vendor-agnostic:
function summarise(entities: NormalizedEntity[]): string {
return entities
.map(e => `${e.__type}: ${e.title}`)
.join('\n');
}
The discriminator is there when you want to specialise:
switch (entity.__type) {
case 'spotify_track': return formatTrack(entity);
case 'github_pull_request': return formatPR(entity);
// ...
}
Exhaustiveness is checked by the compiler when you add a new EntityType.
__type: 'SpotifyTrack' (PascalCase) — break the convention. __type strings are kebab-style with _ separators. The compiler doesn't enforce this; the convention does.metadata. entity.album is wrong for a track that's also an album field on a playlist — vendor-specific extras live in metadata.Result<NormalizedEntity, ValidationError> (see result-not-throw) — don't throw inside a pure mapper.VALID_ENTITY_TYPES when adding a new literal. The TypeScript union is compile-time; the set is runtime. Both have to move together.id formats per vendor. spotify_${id} vs gh-${id} vs raw id — pick one (${__type}_${externalId}) and never deviate.metadata or it's gone. The vendor payload is not part of the domain.personal/ait/references/core-entity-types.mdpersonal/ait/references/features-connectors.mdCONTEXT.md