Under the page

How this site is actually put together

The CV says every word on it is a row read from an API. This is that claim in detail: the tiers a request passes through, what each of the four verbs does on the way down and back — pick one and the diagram traces it — and the schema at the bottom of it, reflected out of the database rather than drawn from memory.

The system, end to end

Five tiers, and only two boxes in the whole diagram ship JavaScript to the browser. The middle two are separate columns because of the seam between them: nothing in apps/web imports the API client or knows the service’s origin — it asks a port, and one adapter answers. Pick a call below and the diagram shows you its route through all five.

counted from the diagram

tiers
5
parts
13
calls between them
12
ship JavaScript
2

Pick a call to trace it through the diagram.

How this site is wired together13 parts across 5 tiers — Browser, Next.js server, @cv/data, FastAPI, PostgreSQL — joined by 12 calls. Only 2 of them run in the browser; everything else answers on a server.BROWSERNEXT.JS SERVER@CV/DATAFASTAPIPOSTGRESQLGET /GET/api/trace/snapshotPOST PATCH DELETE+ bearer tokenGET /api/v1/*writes /api/v1/*SQLThe CV pageHTML that arrivescompleteTraceLayerclientThe one client island/adminclientThe editor, behind acookiepage.tsxServer components, noJS/api/statusand/api/trace/snapshot/api/admin/*Reads the httpOnlycookiecvRepositoryThe read port, oneadapterwrite repositoryBuilt per request,from the tokenapi/v1/routes.pyReads open, writesgatedservices/The rules. Owns thetransactionrepositories/Where the queries livemodels/SQLAlchemy 2, and theERDcv databaseEvery word on the page
  • reads · public
  • writes · bearer token
  • a box marked “client” runs in the browser
GET

Reading the page

Public

The only flow a visitor causes. Nothing is fetched from the browser: the server component asks the repository port, the answer becomes HTML, and what arrives is already finished.

  • GET /api/v1/profile
  • GET /api/v1/jobs
  • GET /api/v1/projects
  • GET /api/v1/nodes
  • GET /api/v1/skills/categories
  • GET /api/v1/schema/erd
  • GET /api/v1/trace/snapshot
  1. 1
    Browser

    Asks for a route. This is the last thing the browser does on the read path — there is no client fetch behind it.

    GET /
  2. 2
    Next.js server

    The route's server component reads everything it needs in one Promise.all, through the port rather than the API.

    cvRepository.listJobs()
  3. 3
    @cv/data

    HttpCvRepository is the only module that knows the API's origin. It calls the typed client generated from the OpenAPI schema, and attaches Next's revalidation window here.

    next: { revalidate: 60 }
  4. 4
    FastAPI

    A read route, so no token is asked for. The handler is a lookup and a response model; the service beneath it has done the work.

    GET /api/v1/jobs
  5. 5
    Service → repository

    The service composes, the repository holds the SQLAlchemy query. The trace snapshot is the one that earns the split: it resolves the whole graph in a single pass rather than in 36 round trips.

  6. 6
    PostgreSQL → the page

    Rows come back, Pydantic serialises them, the adapter narrows tone and status into domain types, and the section renders on the server. The trace snapshot is dehydrated into the client cache so the one client island starts warm.

200 · JSON, then server-rendered HTML. Revalidated every 60s.
POST

Adding a row

Bearer token

The editor's create path. The browser never learns the API's address or its token: it posts to a proxy on this origin, and the proxy is where the credential is attached.

  • POST /api/v1/jobs
  • POST /api/v1/projects
  • POST /api/v1/nodes
  • POST /api/v1/skills
  • POST /api/v1/skills/categories
  1. 1
    Admin editor

    The form posts to this origin. Same-origin, so no CORS — and no credential within reach of any script the page loads.

    POST /api/admin/jobs
  2. 2
    Next route handler

    A catch-all that validates rather than forwards: the resource name must be one of five, and every segment is checked against a character class, so nothing can walk out of the namespace.

    parseTarget(segments)
  3. 3
    Next route handler

    Reads the httpOnly session cookie. That token has never been readable by a script on the page, which is the whole reason this hop exists.

    readAdminToken()
  4. 4
    @cv/data

    The write port is built from that token per request, not once from the environment — a module-level singleton would mean one process-wide credential shared by everyone who opens the editor.

    repository.create(resource, body)
  5. 5
    FastAPI

    The write gate compares the bearer token in constant time. No token configured answers 503, a wrong one answers 401 — an operator problem and a caller problem are not the same thing.

  6. 6
    Service → PostgreSQL

    Pydantic validates the shape; the service refuses a payload naming a skill that does not exist, then commits the row, its bullets and its skill relations as one unit of work.

    422 · Unknown skill(s) in uses: COBOL
201 Created · the row as the API now holds it.
PATCH

Editing a row

Bearer token

The same door as a create, with one difference that matters: the payload is partial. A field that is not sent is left alone rather than nulled, so editing a role's dates cannot silently erase its bullets.

  • PATCH /api/v1/profile
  • PATCH /api/v1/jobs/{id}
  • PATCH /api/v1/projects/{id}
  • PATCH /api/v1/nodes/{id}
  • PATCH /api/v1/skills/{id}
  • PATCH /api/v1/skills/categories/{id}
  1. 1
    Admin editor

    The form sends only the fields the editor changed.

    PATCH /api/admin/jobs/{id}
  2. 2
    Next route handler

    The profile is the one row addressed without an id — it is a single row, so it has no create and no delete — and it is matched before the id check every other resource goes through.

  3. 3
    @cv/data

    The same per-request write port and the same bearer token, with a different verb.

    repository.update(resource, id, body)
  4. 4
    FastAPI

    The write gate again, then the row is loaded — or the request 404s before anything has been changed.

    self.jobs.require(job_id)
  5. 5
    Service

    Only the fields actually supplied are copied across. Lists are the exception: bullets and skill relations are replaced wholesale, because merging an ordered list by index would quietly reorder it.

    payload.model_dump(exclude_unset=True)
  6. 6
    PostgreSQL

    One commit. A role plus its bullets plus its two sets of skill rows either all land or none of them do.

200 · the updated row. The public page picks it up within 60s.
DELETE

Removing a row

Bearer token

The shortest flow, and the one with the widest blast radius, because the join tables cascade. Deleting a skill removes it from every role, project and architecture card that referenced it.

  • DELETE /api/v1/jobs/{id}
  • DELETE /api/v1/projects/{id}
  • DELETE /api/v1/nodes/{id}
  • DELETE /api/v1/skills/{id}
  • DELETE /api/v1/skills/categories/{id}
  1. 1
    Admin editor

    No body to send, so there is nothing to validate on the way in.

    DELETE /api/admin/jobs/{id}
  2. 2
    Next route handler

    An id is required here: the same handler refuses a delete addressed at a collection, which is the one mistake in this shape of API that cannot be undone.

  3. 3
    @cv/data

    A 204 carries no body, so the write port returns undefined rather than trying to parse one, and the proxy answers 204 in turn.

    repository.remove(resource, id)
  4. 4
    FastAPI

    Gated like every other write, then loaded — deleting something that was already gone is a 404, not a quiet success.

  5. 5
    Service → PostgreSQL

    The row goes, and ON DELETE CASCADE takes the join rows with it. A category is the exception: it is refused while it still holds skills, because taking a category out from under them would delete work nobody asked to delete.

  6. 6
    The page

    Nothing else has to be told. The trace graph, the skill rail and the ERD are all derived from rows, so they are correct on the next revalidation without a line changing.

204 No Content · nothing comes back, and nothing needs to.

The schema

Drawn from /api/v1/schema/erd, which the service reflects out of its own SQLAlchemy metadata — so this is the schema rather than a picture of it, and a migration redraws it. The tinted boxes are join tables: a role’s skills are rows in job_skills, not a list stored on the role, which is what lets the trace be a query.

as the API served it

tables
14
columns
59
foreign keys
15
join tables
5
Database schema for this CV14 tables and 15 foreign keys. 5 of the tables exist only to relate two others: a role's skills are rows in job_skills, not a column on the role.arch_nodesidvarchar(64)layervarchar(64)namevarchar(128)notetextstatusvarchar(16)positionintegerjobsidvarchar(64)orgvarchar(128)rolevarchar(128)datesvarchar(64)started_atvarchar(7)profilesidvarchar(64)namevarchar(128)titlevarchar(128)locationvarchar(128)headlinevarchar(255)summarytextprojectsidvarchar(64)namevarchar(128)statusvarchar(16)notetextpersonalbooleanpositionintegerskill_categoriesidvarchar(64)namevarchar(64)tonevarchar(16)iconvarchar(64)positionintegerjob_pointsidintegerjob_idvarchar(64)texttextpositionintegercontact_channelsidvarchar(64)profile_idvarchar(64)labelvarchar(64)iconvarchar(64)valuevarchar(255)hrefvarchar(255)positionintegerskillsidvarchar(64)namevarchar(64)category_idvarchar(64)notetextpositionintegernode_skillsjoinnode_idvarchar(64)skill_idvarchar(64)job_skillsjoinjob_idvarchar(64)skill_idvarchar(64)job_stack_itemsidintegerjob_idvarchar(64)skill_idvarchar(64)labelvarchar(64)positionintegerjob_tagsjoinjob_idvarchar(64)skill_idvarchar(64)project_skillsjoinproject_idvarchar(64)skill_idvarchar(64)project_tagsjoinproject_idvarchar(64)skill_idvarchar(64)

● primary key   ○ foreign key

Every file and route named on this page is in the repository. If one of them stops being true, that is a bug in the page, and the same one the whole project exists to avoid.

routes named above
23
request flows
4