buildFromSources
import { tools } from 'tods-competition-factory';
Assemble a tournamentRecord from partial, heterogeneous or third-party sources — a set of API
responses, a network-tab paste, an adapter's output — without knowing in advance what each one is.
It exists because the factory cannot control how other systems present their data, and because the alternative is every ingest tool re-deriving the same assembly logic in a place that has no access to the generators which already know the answer.
buildFromSources
Pass an array of raw objects in any combination and any order. Each is classified by shape, routed to the extractor for its kind, and merged into one record. Draw links are then repaired so the result is readable — see Link repair below.
function buildFromSources(sources?: any[]): BuildFromSourcesResult;
interface BuildFromSourcesResult {
record: Record<string, any>;
classification: { index: number; kind: SourceKind }[];
unknownCount: number;
inferredLinks: { drawId?: string; inferred: InferredLink[]; issues: string[] }[];
unplacedMatchUps: UnplacedMatchUp[];
unbuiltStructures: UnbuiltStructure[];
derivedLinks: { drawId?: string; derived: DerivedLink[]; issues: string[] }[];
}
type SourceKind = 'event-data' | 'matchups' | 'participants' | 'unknown';
const { record, classification, unknownCount, inferredLinks, unplacedMatchUps } = tools.buildFromSources([
eventDataResponse, // { data: { tournamentPublicEventData: { ... } } }
scheduleResponse, // { tournamentMatchUps: [ ... ] }
participantsResponse, // { tournamentParticipants: [ ... ] }
]);
// classification: [{ index: 0, kind: 'event-data' }, { index: 1, kind: 'matchups' }, ...]
Nothing is dropped silently. Five of the seven returned keys exist only so the caller can see what the assembler could not do, or how it decided:
| key | reports |
|---|---|
classification | what each input was taken to be, by index — so a caller can show its work |
unknownCount | inputs matching no known shape. Reported rather than discarded |
inferredLinks | links added to make reconstructed draws readable, with confidence |
unplacedMatchUps | matchUps naming a structure their draw does not contain |
unbuiltStructures | source structures that could not be built, and why |
derivedLinks | the links given to each multi-structure draw, how, and any disagreement |
A caller that would rather refuse than accept a repaired or lossy record inspects these and decides.
Classification
Sources are sniffed rather than labelled. A single-key { data: { ... } } envelope is peeled
recursively first — network-tab copies sometimes carry it and sometimes not — then:
- a wrapper key (
tournamentPublicEventData,tournamentMatchUps,tournamentParticipants) names the kind outright; - an array is classified from its first object member (
matchUpId→ matchups,participantIdwithouteventInfo→ participants); - an object is classified from its distinguishing keys (
dateMatchUps→ matchups,eventData→ event data).
Anything else is 'unknown' and increments unknownCount.
classifySource, extractEventData, extractMatchUps and extractParticipants are exported
separately for a caller that already knows what it holds.
unplacedMatchUps
augmentDrawWithMatchUps inserts a matchUp only into an existing structure whose structureId
matches. When no structure matches, the matchUp cannot be placed.
It is reported rather than dropped, because a shorter draw with no complaint is the hardest kind of loss to notice:
const { unplacedMatchUps } = tools.buildFromSources(sources);
// [{ matchUpId, eventId, drawId, structureId }]
// structureId is the one the matchUp NAMED — which no structure in that draw carries
Each entry carries enough identity to find the matchUp in the source. An empty array is the ordinary case.
Every structure of a draw
Each drawsData entry carries every structure of its draw: the main structure and its consolations,
playoffs and every COMPASS direction. All of them are built. The first is built exactly as it always has
been, so a one-structure draw is unchanged. Each later structure is built from its own roundMatchUps; a
CONTAINER (the round robin of ROUND_ROBIN_WITH_PLAYOFF) derives its groups as a first-structure
container does.
Until this was fixed only the first structure was read, and the rest were dropped without a word. Measured 2026-10-14 across two ingest corpora, 12,603 of 33,221 matchUps were lost that way.
A structure that cannot be built (no structureId, or one already built) is reported, not dropped:
const { unbuiltStructures } = tools.buildFromSources(sources);
// [{ drawId, structureId, structureName, reason }]
Links from the drawType
The projection carries no links, and links cannot be recovered from structure shape alone (see
inferDrawLinks). It does carry the drawType, so a multi-structure draw gets its links
from the generator that knows them:
drawType— the samedrawTypeis generated at the same size, its structures are mapped onto the source's (stage + stageSequence + size, else name + size), and every link whose ends both map is copied. Generating the template draws nothing from the random source.playoff— a structure the template lacks, added to an elimination draw (Playoff 3-4,Playoff 5-8), is fed by the losers of the round of its source structure that has as many matchUps as the playoff has positions,TOP_DOWN, asaddPlayoffStructuresgenerates it.roundRobinPlayoff— a round robin playoff takes the finishing positions its participants held in their groups, byPOSITIONwithfeedProfile: DRAW. A playoff nobody has reached yet takes the number at the end of its name instead, reported asroundRobinPlayoffByName.
Each link is then checked against the source wherever the source can speak to it. A loserMatchUpId that
lands outside the link's target, a source pointer no link covers, and a decided loser missing from an added
playoff are each reported in issues, never resolved silently:
const { derivedLinks } = tools.buildFromSources(sources);
// [{ drawId, derived: [{ linkType, sourceStructureId, targetStructureId, basis }], issues: [] }]
Anything still unlinked is joined by repairDrawLinks and reported in inferredLinks.
Stored shape, TEAM events included
Every source matchUp is query output, and so hydrated: it carries event and draw context, and each side carries a
full copy of its participant. In a TEAM event the same is true of every collection matchUp inside every tie, on both
sides, with the side's team attached too. The assembled record is therefore passed through
dehydrateMatchUps before it is returned, so ties and collection matchUps are stored
as the factory stores them: a tie's sides hold its lineUp, a collection matchUp's sides hold only their
sideNumber, and every participant is read back from positionAssignments and the lineUps.
The sources carry a tieFormat only on each tie (getEventData has none at the event or draw level), so the
assembler also gives each draw the tieFormat most of its ties share, and an event whose draws all share one keeps
it on the event instead, as a factory-built TEAM event does. Dehydration then drops every tie's copy that equals
what it inherits; a tie whose format really differs keeps its own.
Measured on a USTA playtennis TEAM tournament (121 teams, 127 ties, 2,467 collection matchUps): 19.7 MB without these steps, 4.3 MB with them, with every matchUp reading the same through the engine.
buildTournamentRecord
The lower-level assembler, for a caller that has already separated its sources.
function buildTournamentRecord(args?: {
eventDataDocs?: any[];
matchUpDocs?: any[];
participantDocs?: any[];
unplaced?: UnplacedMatchUp[];
unbuilt?: UnbuiltStructure[];
derivedLinks?: { drawId?: string; derived: DerivedLink[]; issues: string[] }[];
}): Record<string, any>;
const record = tools.buildTournamentRecord({ eventDataDocs, matchUpDocs, participantDocs });
It builds every structure, derives links from the drawType as above, because a draw with more than
one structure is unreadable without them, and returns its matchUps in stored shape (above). It does not
repair links, deliberately: a caller assembling
a record by hand opts in to inference via repairDrawLinks. Unplaced matchUps, unbuilt structures and
derived links are collected only if the caller hands over an array to collect them into, so the
function's shape is unchanged for callers that do not want the report.
Link repair
A drawDefinition whose structures are not all joined into one group is refused by
getDrawData — not degraded, refused (ERR_MISSING_STRUCTURE_LINKS). The record still saves and
still lists in a calendar, so the failure is invisible until something asks for the draw.
Links are a property of the draw while matchUps are a projection of it, so reconstruction from matchUps can never preserve them. Inference is the only route back.
inferDrawLinks
function inferDrawLinks(params: { drawDefinition: DrawDefinition }): InferDrawLinksResult;
interface InferDrawLinksResult {
links: DrawLink[]; // the links to ADD — existing links are never modified or removed
inferred: InferredLink[];
alreadyLinked: boolean; // the draw already formed a single group; nothing was needed
issues: string[];
}
interface InferredLink {
linkType: string;
sourceStructureId: string;
targetStructureId: string;
basis: string; // why this pairing was chosen, in the stages' own vocabulary
confidence: 'exact' | 'shape';
}
This repairs; it does not reconstruct, and it says which. Link count and feedProfile follow the
drawType, not the structure shape:
| drawType | structures | links |
|---|---|---|
FIRST_MATCH_LOSER_CONSOLATION | MAIN, CONSOLATION | 2, LOSER round 1 + 2 TOP_DOWN |
FEED_IN_CHAMPIONSHIP | MAIN, CONSOLATION | 4, alternating TOP_DOWN / BOTTOM_UP |
ROUND_ROBIN_WITH_PLAYOFF | MAIN, PLAY_OFF | 1, POSITION with feedProfile DRAW |
COMPASS | 8 | 7 |
Two draws with identical structures therefore have different correct links, and nothing can recover
that from the structures alone. So inferDrawLinks emits the minimum set of links that joins
every structure into one group, and reports how confident it is:
'exact'— a two-structure draw whose stages name an unambiguous relationship.'shape'— more than two structures, or stages that do not name one. The draw becomes readable; the feed pattern is a guess and is flagged as one.
The rule is read from the data rather than assumed: a CONTAINER source (round robin) feeds by
POSITION with feedProfile: DRAW; an ITEM source feeds by LOSER TOP_DOWN; and QUALIFYING
feeds MAIN by position — matching generateQualifyingLink.
A caller that knows the drawType and wants the true feed pattern should generate the draw
properly rather than repair it here. buildFromSources does exactly that for draws whose drawType it is
given — see Links from the drawType — and repairs only what remains.
repairDrawLinks
Applies inferDrawLinks across every drawDefinition in a record, mutating it in place and
returning the report.
const inferredLinks = tools.repairDrawLinks(record);
// [{ drawId, inferred: [{ linkType, basis, confidence, ... }], issues: [] }]
Empty when nothing needed repair. buildFromSources calls this before returning; buildTournamentRecord
does not.