# Data Flow

How a vehicle travels from a JSON file on disk to a card in the browser, and how a bid
travels back. One rule shapes everything: **derive, don't store**. Windows, statuses,
galleries, and the 100k inventory are all computed from stable ids, never persisted.

[![TheYard data flow: the read path from the seed file to the cards, top to bottom, and the write path of a bid beside it, every box a file](/api/docs/images/dataflow.svg#1400x2005)](https://theyard.stevenstout.biz/api/docs/diagrams/dataflow)

*A preview. [Open the data flow diagram in a new page](https://theyard.stevenstout.biz/api/docs/diagrams/dataflow)
to zoom in and follow it; every diagram on this site opens that way (ADR: Diagram pages).
The source is [`docs/images/dataflow.svg`](https://github.com/SteveStout/TheYard/blob/main/docs/images/dataflow.svg).*

Every box in the diagram is a file; each step below names its path.

**Every HTTP endpoint lives in `api/TheYard.Api/Endpoints/`, one file per area.**
`api/TheYard.Api/Program.cs` is the table of contents: it calls the registrations in
`api/TheYard.Api/Composition/` (sources, then services), then maps each endpoint file. A
handler binds the request and hands straight off to the Application layer; the auction's
handlers ask `Auction` (`api/TheYard.Application/Auction.cs`), which composes the
catalogue, everybody's bids and the simulated room in the one order the rules need. ADR:
Program.cs, explained walks the entry point top to bottom.

| Route | Handled by |
| --- | --- |
| `GET /api/vehicles` (filter/sort/page params) | `Auction.Search` → `InventoryService.Search` |
| `GET /api/vehicles/{id}` | `Auction.Find`, which hands the vehicle back at its standing price with its sold flag |
| `GET /api/facets` | `Auction.Facets` |
| `POST /api/vehicles/{id}/bids` | `Auction.PlaceBidAsync` → `BidService` → `BidRules` |
| `POST /api/vehicles/{id}/buy-now` | `Auction.BuyNowAsync` → `BidService` → `BidRules` |
| `GET` / `DELETE /api/bids` | `Auction.BidsOf` / `Auction.ResetAsync` |
| `GET /api/docs/{slug}` · `/api/docs/diagrams/{name}` | the documents catalog and the diagram pages (ADR-017, ADR-020) |
| `GET /api/docs/bicep` · `/api/docs/resume` | files on disk |
| `GET /api/images/{file}` | static files (day-long `Cache-Control`) |
| `GET /api/version` | build provenance (ADR-005) |
| `GET /healthz` · `/readyz` | liveness and readiness |
| `GET /api/health` · `/api/errors` · `/api/admin/azure` | the Admin tab (ADR-010) |

## In plain words

This page follows one vehicle from a data file on disk to a card on screen, and one bid on its way back. The rule behind it is to work facts out from stable ids each time (derive, don't store) instead of saving them, and to keep every auction rule on the server.

What that is worth: a developer can trace any request to the file that handles it, because every step names its path. The organization gets one home for the auction rules, so the screen shows what the server decided.

## The read path

1. **Seed.** The catalogue is read once at startup from the store, Azure SQL Database,
   Azure Cosmos DB or SQLite; `data/vehicles.json` (the challenge's 200 records, untouched)
   seeds a fresh store on first boot through `JsonFileVehicleSource`
   (`api/TheYard.Infrastructure/JsonFileSources.cs`), deserializing into the `Vehicle` record, which lives with the other pure data shapes
   in `api/TheYard.Data/`.
2. **Scale.** `SyntheticVehicleSource` (`api/TheYard.Infrastructure/SyntheticVehicleSource.cs`)
   expands it to 100,000 deterministic variants: each new id is hashed (FNV-1a,
   `api/TheYard.Domain/Fnv1a.cs`) to vary VIN, year, odometer, prices, and bid state
   while inheriting the seed's make/model/trim mix.
3. **Enrich and cache.** `InventoryService` (`api/TheYard.Application/InventoryService.cs`)
   applies each vehicle's photo gallery (hash-picked by `api/TheYard.Domain/PhotoGallery.cs`
   from `api/TheYard.Api/photo-manifest.json` pools, preferring the vehicle's own make)
   and materializes the list plus an id index, once, eagerly at startup.
4. **Query.** A request like `GET /api/vehicles?make=Ford&status=live&sort=price-asc`
   binds through `api/TheYard.Api/VehicleQueryParams.cs`, which validates and produces
   a filter, a sort, and a clock (`api/TheYard.Api/Clocks.cs`): now, and the UTC
   midnight that began the day, the same for every caller, so schedule math
   (`api/TheYard.Domain/AuctionSchedule.cs`, `AuctionClock.cs`) puts every visitor in
   the same auction. Everybody's bids, then the simulated room's, overlay **before**
   filtering (`Auction.Overlay` in `api/TheYard.Application/Auction.cs`, each raise made by
   `StandingRules.RaisedTo` in `api/TheYard.Domain/StandingRules.cs`), so price bounds see
   the same figures the UI shows. Then `Where` → `OrderBy` → `Skip/Take`, all in memory:
   `api/TheYard.Domain/VehicleFilter.cs` and `VehicleOrdering.cs`, applied in
   `InventoryService.Search`.
5. **Wire.** `api/TheYard.Api/VehicleWire.cs` stamps the server-derived auction facts
   onto each vehicle (`auction_starts_at`, `auction_ends_at`, `auction_status`,
   `min_next_bid`, and `sold`, from everybody's standing), and the endpoint
   (`api/TheYard.Api/Endpoints/VehicleEndpoints.cs`) responds with a snake_case envelope
   `{ total, vehicles }`.
6. **Fetch.** `src/lib/data.ts` is the browser's single seam: it debounces filter
   changes (500 ms), caches responses per query string (5-minute TTL; hits skip the
   debounce), and aborts superseded requests. The query string itself is built by
   `src/lib/inventory.ts`, the same serializer that feeds the address bar.
7. **Render.** `src/app/hooks/useAddressBar.ts` holds the view and mirrors filters plus `?vehicle={id}`
   into the URL; components (`src/components/`) format currency
   (`src/lib/format.ts`), tick countdowns from the server's window, and recompute
   live/ended locally as time passes (`src/lib/auction.ts`). No business math runs in
   the browser.

Step 4's overlay, read from this build. It is chosen once per listing, so a cold catalogue
with no bid and no room pays for neither:

```csharp
/// <summary>
/// The overlay a listing applies to every row: everybody's bids, then the room's. Each one only
/// raises the price (StandingRules.RaisedTo), so the page shows the highest of the dataset, the
/// bids and the room, and a competitor who has gone higher is what the buyer sees. Null when
/// nobody has bid at all, so a cold listing pays for neither. IsEmpty rather than a snapshot
/// count, because a snapshot copies the whole dictionary just to ask whether it is empty.
/// </summary>
private Func<Vehicle, Vehicle>? Overlay() => (bids.IsEmpty, market.IsEmpty) switch
{
    (true, true) => null,
    (false, true) => bids.Apply,
    (true, false) => market.Apply,
    _ => AsItStands,
};

/// <summary>The vehicle at the price it stands at: the dataset, then everybody's bids, then the room's.</summary>
private Vehicle AsItStands(Vehicle vehicle) => market.Apply(bids.Apply(vehicle));
```
*Live from [`api/TheYard.Application/Auction.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/Auction.cs#L24-L40), region `overlays`, read from this build at 1d6b50b.*

Step 6's seam, the one function every list view goes through:

```ts
// The one function every list view goes through. Three things share this seam:
// a cache keyed by the exact query string and the UTC day (a filter
// combination revisited renders instantly, and nothing cached before the
// server re-seeds at UTC midnight is served after it), an AbortSignal so an
// effect's cleanup can cancel a request the visitor has already typed past,
// and forceRefresh for the paths that must not read a cache (retry buttons,
// the periodic status refresh).
export interface FetchVehiclesOptions {
  sort?: SortKey;
  offset?: number;
  signal?: AbortSignal;
  /** Skip the cache and overwrite it with a fresh response. */
  forceRefresh?: boolean;
}

export async function fetchVehicles(
  filters?: InventoryFilters,
  { sort, offset = 0, signal, forceRefresh = false }: FetchVehiclesOptions = {}
): Promise<VehiclePage> {
  const query = queryString(filters, sort, offset);
  const key = cacheKey(query);

  if (!forceRefresh) {
    const hit = cachedPage(key);
    if (hit) {
      return hit;
    }
  }

  const response = await fetch(`/api/vehicles${query ? `?${query}` : ''}`, { signal });
  if (!response.ok) {
    throw new Error(
      await problemDetail(response, `The inventory API responded with ${response.status}`)
    );
  }
  const page = (await response.json()) as VehiclePage;

  // Re-inserting moves the key to the back, so eviction drops the stalest.
  queryCache.delete(key);
  queryCache.set(key, { at: Date.now(), page });
  if (queryCache.size > CACHE_MAX_ENTRIES) {
    const oldest = queryCache.keys().next().value;
    if (oldest !== undefined) queryCache.delete(oldest);
  }
  return page;
}
```
*Live from [`src/lib/data.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/data.ts#L140-L185), region `fetch-vehicles`, read from this build at 1d6b50b.*

## The write path (bids)

1. `src/components/vehicle/BidPanel/BidPanel.tsx` posts `{ amount }` to
   `POST /api/vehicles/{id}/bids` via `src/lib/data.ts`; the clock is the server's.
   `api/TheYard.Api/Endpoints/BidEndpoints.cs` hands it to `Auction.PlaceBidAsync`, which
   measures it against the room's standing price as well as everybody's bids.
2. `api/TheYard.Domain/BidRules.cs` is the sole authority: sold first (a vehicle anybody
   has bought takes no bid and no second purchase, a fact `BidService` supplies from
   everybody's standing), then the live-window check, the tiered minimum increment, and
   the buy-now override (a bid at or above `buy_now_price` wins outright at that price).
3. Accepted or won bids are written to the store first, Azure SQL Database or Azure
   Cosmos DB through `IBidStore`, and then to the in-memory standing in
   `api/TheYard.Application/BidService.cs`, under the signed-in account's id (ADR:
   Accounts and per-user bids); rejections return 400 with a human-readable reason the
   panel shows.
4. The response carries the updated vehicle (fresh `min_next_bid` included); `src/lib/data.ts`
   clears the query cache and the client (`src/hooks/useBids.ts`) refetches, so lists, filters, and
   totals all reflect the new bid, because the overlay in read-step 4 feeds the same
   pipeline every read uses.

Step 2's first check, read from this build. Sold is a fact the rules cannot know from the
vehicle alone, so the caller that holds everybody's standing supplies it, and it is asked
before the clock and before the buy-now shortcut:

```csharp
/// <summary>
/// The sentence a sold vehicle answers every bid and every purchase with.
/// One string, because the store's fact and the page's words should not be
/// able to drift apart.
/// </summary>
public const string SoldReason = "This vehicle has been sold.";

/// <summary>
/// <paramref name="sold"/> is whether anybody has bought this vehicle
/// outright, which the rules cannot know from the vehicle alone: the
/// dataset has no such field and the schedule has no such state. The caller
/// that holds everybody's standing says so, and it is asked before the
/// clock and before the buy-now shortcut, so a second account bidding the
/// buy-now price on a vehicle already bought is refused, not told it has
/// won (ADR: Accounts and per-user bids, the addendum on the second buyer).
/// </summary>
public static BidOutcome ResolveBid(Vehicle vehicle, int amount, AuctionClock clock, bool sold)
{
    if (sold)
    {
        return BidOutcome.Rejected(SoldReason);
    }
    var status = AuctionSchedule.StatusFor(vehicle.Id, clock);
    if (status == AuctionStatus.Live && vehicle.BuyNowPrice is { } buyNow && amount >= buyNow)
    {
        // Instant win, charged the buy-now price rather than the overbid.
        return BidOutcome.Won(buyNow);
    }
    if (status == AuctionStatus.Upcoming)
    {
        return BidOutcome.Rejected("This auction has not started yet.");
    }
    if (status == AuctionStatus.Ended)
    {
        return BidOutcome.Rejected("This auction has ended.");
    }

    int min = MinNextBid(vehicle);
    return amount < min
        ? BidOutcome.Rejected($"Bid must be at least ${min.ToString("N0", CultureInfo.InvariantCulture)}.")
        : BidOutcome.Accepted(amount);
}

public static BidOutcome ResolveBuyNow(Vehicle vehicle, AuctionClock clock, bool sold)
{
    if (sold)
    {
        return BidOutcome.Rejected(SoldReason);
    }
    if (vehicle.BuyNowPrice is not { } buyNow)
    {
        return BidOutcome.Rejected("This vehicle has no Buy Now price.");
    }
    return AuctionSchedule.StatusFor(vehicle.Id, clock) == AuctionStatus.Live
        ? BidOutcome.Won(buyNow)
        : BidOutcome.Rejected("Buy Now is only available while the auction is live.");
}
```
*Live from [`api/TheYard.Domain/BidRules.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Domain/BidRules.cs#L44-L100), region `sold`, read from this build at 1d6b50b.*
