@cardog/entities
v0.1.1
Published
The Cardog ref grammar — permanent, typed identifiers for the vehicle graph. Builders, parsers, VIN-derived grains (nano/squish), domain type unions, and the canonical spec attribute catalog. Validate refs offline; resolve them via the Cardog API.
Maintainers
Readme
@cardog/entities
The ref grammar for the Cardog vehicle graph — permanent, typed, human-readable identifiers for automotive entities, and the pure functions that build, parse, and validate them.
import { modelYearRef, parseRef, isRefOf, squishFromVin } from "@cardog/entities";
modelYearRef("model:tesla/model-y", 2024); // "model-year:tesla/model-y/2024"
isRefOf("make:honda", "make"); // true — and narrows the type
parseRef("model-year:honda/civic/2021"); // { domain, key, segments }
squishFromVin("5TDGSKFC8RS123456"); // "5TDGSKFCR" — the market grain, offlineEverything in this package is pure and offline. Validating a ref, building one, deriving a nano or squish from a VIN — none of it touches the network, and none of it ever will. Resolving a ref to data (names, children, listings, specs, recalls, live market quotes) is what the Cardog API does.
What a ref is, and why it is permanent
Every entity in the vehicle graph — a make, a model, a model year, a recall campaign —
has exactly one ref: an identifier of the form {domain}:{key}, with / separating
the segments of composite keys.
make:honda
model:honda/civic
model-year:honda/civic/2021
fuel-type:electric
recall:nhtsa/23v123Refs are identifiers, not display strings — make:mini is the ref; "MINI" is the
display name carried in the entity's data. Refs are permanent join keys. Store them
in your database columns, your config, your agent's memory: make:honda will mean
Honda for as long as the platform exists, and the ref grammar is versioned
(GRAMMAR_VERSION) precisely so that promise is checkable. Data keyed on refs today
joins cleanly against every future API response.
Install
npm install @cardog/entitiesESM, TypeScript-first, zero dependencies. The root export is runtime-neutral — Node, browsers, workers, edge runtimes.
The grammar
A ref is {domain}:{key}. Domains are lowercase ([a-z][a-z0-9-]*). Keys are lowercase
alphanumerics plus -, ., _, with / between segments — except the two
machine-derived VIN domains, which are uppercase (see casing).
| Domain | Shape | Example |
| --- | --- | --- |
| make | make:{slug} | make:tesla |
| model | model:{make}/{model} | model:tesla/model-y |
| model-year | model-year:{make}/{model}/{year} | model-year:tesla/model-y/2024 |
| recall | recall:{authority}/{campaign} | recall:nhtsa/23v123 |
| complaint | complaint:{authority}/{odino} | complaint:nhtsa/11434120 |
| safety-rating | safety-rating:{authority}/{make}/{model}/{year}/{body}/{drivetrain\|na}/{release} | safety-rating:nhtsa/tesla/model-y/2024/suv/awd/1 |
| nano | nano:{10-or-13-char VIN-derived key} | nano:5TDGSKFCRS |
| squish | squish:{9-char VIN-derived key} | squish:5TDGSKFCR |
| attribute domains | {domain}:{slug} | fuel-type:electric, body-style:pickup, drive-type:awd-all-wheel-drive, … |
The attribute domains (body-style, fuel-type, drive-type, transmission,
vehicle-type, engine-configuration, electrification-level, gvwr, country, and
friends) are enumerated as TypeScript unions — see typed unions.
Builders and parsers
Every function validates against the grammar and throws rather than normalizes — a malformed ref is an error at the boundary, never a silent correction that diverges from the canonical form.
Core (@cardog/entities or @cardog/entities/ref)
import {
isRef, isRefOf, buildRef, parseRef, domainOf, keyOf,
modelYearRef, parseModelYearRef, recallRef, complaintRef, safetyRatingRef,
GRAMMAR_VERSION,
} from "@cardog/entities";
isRef("make:tesla"); // true
isRef("make:Tesla"); // false — casing is part of the grammar
isRefOf(x, "model-year"); // narrows x to EntityRef<"model-year">
buildRef("make", "tesla"); // "make:tesla" (throws on grammar violations)
parseRef("model:tesla/model-y");
// { domain: "model", key: "tesla/model-y", segments: ["tesla", "model-y"] }
domainOf("make:tesla"); // "make"
keyOf("model:tesla/model-y"); // "tesla/model-y"
// Composite builders — the only correct way to assemble composite refs:
modelYearRef("model:tesla/model-y", 2024); // "model-year:tesla/model-y/2024"
parseModelYearRef("model-year:tesla/model-y/2024");
// { make: "make:tesla", model: "model:tesla/model-y", year: 2024 }
recallRef("nhtsa", "23V123"); // "recall:nhtsa/23v123" — normalizes case
complaintRef("nhtsa", "11434120"); // "complaint:nhtsa/11434120"
safetyRatingRef("nhtsa", "model-year:tesla/model-y/2024", {
bodyStyle: "SUV",
driveTrain: "AWD",
productionRelease: 1,
}); // "safety-rating:nhtsa/tesla/model-y/2024/suv/awd/1"recallRef lowercases campaign numbers deliberately: the authority's casing
(23V123) is display data; the ref is a join key. complaintRef addresses the
incident (NHTSA's ODI number), not a source row — one complaint spans many component
rows in the source data. safetyRatingRef addresses one rated vehicle
configuration — body style, drivetrain, production release — because that is the
grain NHTSA rates at; a blank drivetrain becomes the literal segment na so the ref
stays structurally complete.
VIN grains (@cardog/entities or @cardog/entities/nano)
Between "one specific vehicle" (a VIN) and "a model year" (millions of vehicles) sit two machine-derived grains, both pure functions of the VIN — derivable offline, forever:
- squish (9 chars):
vin[0:8] + vin[9]— WMI + VDS + model-year char. Every VIN sharing a squish is the same configuration of the same model year. The market grain. - nano (11 or 14 chars): the squish plus the plant char (plus the extended-WMI
suffix for low-volume manufacturers), with the check digit masked as
*. Vehicles sharing a nano are fungible: same build, same origin. The deduplication and comparables grain.
import {
squishFromVin, nanoFromVin, squishFromNano, nanoFromSquish, parseSquish,
isSquish, isNano, isExtWmiVin, nano11FromVin, nano14FromVin,
squishRef, squishFromRef, nanoRef, nanoFromRef,
} from "@cardog/entities";
squishFromVin("5TDGSKFC8RS123456"); // "5TDGSKFCR"
nanoFromVin("5TDGSKFC8RS123456"); // "5TDGSKFC*RS" — 11-char, check digit masked
squishFromNano("5TDGSKFC*RS"); // "5TDGSKFCR" — always agrees with squishFromVin
// Ref form: the masked check digit is a constant, so the ref omits it:
nanoRef("5TDGSKFC*RS"); // "nano:5TDGSKFCRS"
nanoFromRef("nano:5TDGSKFCRS"); // "5TDGSKFC*RS" — pure round-trip
squishRef("5TDGSKFCR"); // "squish:5TDGSKFCR"The 11- vs 14-char split follows the SAE rule: a WMI whose third character is 9
marks a low-volume manufacturer whose identity needs VIN positions 12–14 (the extended
WMI) — for everyone else those positions are serial, and including them would split
one identity per serial prefix. nanoFromVin applies the rule for you; the fixed-width
variants (nano11FromVin, nano14FromVin) throw if used against the wrong VIN class.
The casing laws
Refs are all-lowercase, as a design position: display casing varies across data
sources ("MINI", "Mini", "mini"), and case-splits are how catalogs silently fracture —
two spellings of one identity become two entities, and joins quietly miss. Source
casing belongs in data columns; the ref never varies. isRef("make:Tesla") is false
on purpose.
The one bounded exception: the nano and squish domains are UPPERCASE-only (VIN
charset — no I, O, Q). Their keys are machine-derived from VINs by these functions —
no human types one from memory and no free-text source produces one, so the
mixed-case-producers risk that motivates lowercase everywhere else cannot arise, and
uppercase keeps the keys byte-recognizable against the VINs they came from. The
exception is enforced in both directions: nano:5tdgskfcrs is invalid.
Typed domain unions
The enumerable domains ship as TypeScript unions (@cardog/entities/types), so a
misspelled ref in a fixed vocabulary is a compile error:
import type {
EntityDomain, // "make" | "model" | "body-style" | "fuel-type" | ...
FuelTypeRef, // "fuel-type:gasoline" | "fuel-type:electric" | ...
BodyStyleRef, DriveTypeRef, TransmissionRef, VehicleTypeRef,
ElectrificationLevelRef, EngineConfigurationRef, GvwrRef, CountryRef,
} from "@cardog/entities/types";
const fuel: FuelTypeRef = "fuel-type:electric"; // ✓ compile-time checkedThe open-ended domains (make, model) are typed as EntityRef<"make"> /
EntityRef<"model"> — validate those at runtime with isRefOf, and resolve free text
against the live registry with the API's /v2/entities/resolve.
The spec attribute catalog
@cardog/entities/spec is the canonical spec attribute catalog: 155 attributes
(fuelEconomyCity, curbWeight, heatedSeatsFront, …) with names, types, units,
and value constraints — the same catalog the API's /v2/specs/catalog serves, and the
vocabulary behind spec.{attributeId} filters on /v2/listings/search.
import spec from "@cardog/entities/spec";
import type { SpecAttributeId } from "@cardog/entities";
spec.attributes.fuelEconomyCity;
// { name: "Fuel Economy (City)", type: "quantitative", unit: "MPG", min: 1, max: 200, ... }
const id: SpecAttributeId = "curbWeight"; // compile-time checked attribute idsStability contract
GRAMMAR_VERSIONis1. It bumps only for a breaking change to the grammar itself — shape, charset, or canonicalization — which would force a migration for every ref-carrying column anywhere, so it effectively never moves. New domains and new entities are additive and do not bump it.- Refs are permanent. Once issued, a ref keeps its meaning. Store refs; they will not rot.
- The vocabulary is versioned.
vocabulary(from the package root) carries the provenance of the build: the source-registry commit (registrySha), the generation timestamp, and the grammar version. API responses can be audited against it.
import { vocabulary, domainNames } from "@cardog/entities";
vocabulary; // { registrySha, generatedAt, grammarVersion }
domainNames(); // ["bed-type", "body-cab", ..., "vehicle-type"]What this package does NOT contain
Deliberately, and permanently:
- No entity data. The domain vocabularies themselves — which makes exist, which
models belong to them, display names, identifiers — are not in the tarball.
Importing
@cardog/entities/domains/*fails with an error saying exactly that, andloadDomain()(the/nodesubpath) throws the same. Resolution and enumeration are API surfaces:GET /v2/entities/resolve?q=...turns free text into refs,GET /v2/entities/{ref}dereferences one, and paginated browse endpoints enumerate. - No VIN decoding. Deriving a nano/squish is character arithmetic; decoding a
VIN to its identity is
GET /v2/vin/{vin}(or the open-source@cardog/corgidecoder). - No network calls. Ever. That is the point: you can hold the grammar — validate, build, type, and store refs — with zero API dependency, and reach for the API exactly when you need answers about what a ref names.
Docs: cardog.app/docs/ref-grammar · API reference: cardog.app/docs
