# SDK changelog

Source: https://capacms.com/docs/changelog/sdk

Every release of @capacms/sdk, newest first.

The [SDK reference](https://capacms.com/docs/sdk) covers the current release. Install it with `pnpm add @capacms/sdk@next`.

## 1.0.0-next.8 (2026-10-02)

* 1.0.0-next.8. Preview on a live site, in draft mode on its production
  domain. `preview()` takes the legacy key a site already holds (`pk_`, `sk_`
  or unprefixed), as the API does: Capa checks the token with any key that
  reads the project, and refuses a token made for another project. It used to
  throw a `TypeError` for a legacy key before any request. Draft reads through
  the SDK still take a `cap_` key only, and the legacy-key warning says so.
* Next.js: `createPreviewRoute` and `exitPreviewRoute` take Next's `cookies`.
  Given it, the draft cookie is re-set
  `HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`
  (`maxAge` changes the hour), so Safari 26.2 and later send it inside the
  editor's frame and it no longer lasts until the browser closes. Exit and a
  bad link delete it partitioned, which is the only deletion that reaches
  it. `redirect` is optional now: left out, each route answers with its own
  307, marked `X-Robots-Tag: noindex, nofollow`,
  `Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`, and
  the preview route's carries the editor's `frame-ancestors`. Given
  `redirect`, a route calls it as before.
* Next.js: `capaHeaders({ adminOrigins })` returns rules for `next.config`'s
  `headers()` that send `X-Robots-Tag: noindex, nofollow` and
  `Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com`
  on a request carrying the draft cookie or a `capa-preview`, `capa-edit` or
  `capa-view` query, and on nothing else, so a visitor's response is
  unchanged. Also exported: `draftHeaders`, `frameAncestors` (it throws for a
  value that is not a bare origin), `frameDraftCookie`, `clearDraftCookie`,
  `CAPA_ADMIN_ORIGIN`, `DRAFT_ROBOTS_TAG`, `DRAFT_COOKIE_MAX_AGE`,
  `PREVIEW_PARAM` and `VIEW_PARAM`.
* Next.js: `capaMiddleware` marks every edit-mode response, and every request
  carrying `capa-edit` or `capa-view`, `X-Robots-Tag: noindex, nofollow`, and
  `resolveEditRequest` returns that value as `robotsTag`.
* Docs: "Add preview to a live site without changing it", the steps for a
  site that keeps its own Capa reads, and why `editMode()` and
  `getCapaClient()` make a static page dynamic.

## 1.0.0-next.7 (2026-09-29)

* 1.0.0-next.7. Browsers: the `/api/` and legacy clients called the platform `fetch` as a
  method of their config, which a browser refuses ("Failed to execute 'fetch'
  on 'Window': Illegal invocation"), so every read from a browser failed unless
  `fetch` was passed in. The default fetch is now called through `globalThis`
  on each request, which also picks up a fetch a framework patches in later.
* `next` is no longer a peer dependency. No range matches every Next canary,
  so a site on `next@16.3.0-canary.39` could not `npm install` the SDK without
  `--legacy-peer-deps`. `@capacms/sdk/nextjs/overlay` still imports `next`,
  which only a Next site loads.
* Docs: the edit mark is dropped wherever an entry is serialized to the
  browser (Pages Router props, SvelteKit and Remix loaders, Nuxt payload);
  pass the edit flag to `capaAttrs` there. Found testing preview on nine stacks.
* Next.js: a GraphQL read that gives neither `tags` nor `revalidate`, through
  `getCapaClient().graphql()`, its builder or `graphql()`, is no longer kept
  in Next's data cache. It is sent as `entries.list` and `entries.get` send
  theirs, with no `cache` and no `next`, so a publish shows on the next render
  whether the page reads by REST or by GraphQL. It used to be kept under
  `capa:graphql` until a webhook revalidated it, so a site with no webhook
  route kept serving the old answer by GraphQL while REST showed the new one.
  A read that gives `tags` or a `revalidate` is kept as before. To keep a read
  with no tags as it was kept, pass `revalidate: false`, which keeps it under
  `capa:graphql` until the next publish. `tags: []` now counts as no tags.

## 1.0.0-next.6 (2026-09-28)

* 1.0.0-next.6. GraphQL, on `@capacms/sdk/next` and `@capacms/sdk/nextjs`,
  and two commands, `capa-codegen --graphql` and `capa persist`. Additive:
  every call that existed behaves as before. `graphql` is an optional peer
  dependency that only the two commands load. The package's types now need
  TypeScript 5.0 or later, with `strict` on or off.

  Calls and errors. `client.graphql(document, variables?, options?)` runs a
  query against `/api/graphql` and resolves with
  `{ data, errors, extensions, cacheTags }` once the API has run it, even
  when `errors` is not empty: a root field that failed is `null` and the
  others keep their data. A request the API refuses as a whole, `errors` and
  no `data`, throws `CapaError` whatever its status: a 4xx, or the 200 that
  GraphQL over HTTP sends on `application/json`, thrown with `status` 400.
  401, 402, 403, 404, 405 and 429 keep their own. `CapaError` carries every
  error of the refusal in `graphqlErrors`. Each error is a
  `CapaGraphQLError`, a plain object: `message`, `locations`, `path` and
  `extensions` as the spec writes them, with `code`, `hint`, `docs`, `param`
  and `type` lifted out, so `Response.json(result)` and a client component's
  props keep every field. `isCapaGraphQLError` checks one by its shape.
  Where GraphQL is switched off the call throws
  "This Capa deployment does not serve GraphQL." with a hint to read over
  REST meanwhile. `extensions.cost` (`requestedQueryCost`,
  `actualQueryCost`, and `budget`, whose `counted` is what the 5,000-entry
  limit checks) and `extensions.deprecations` (`{ coordinate, reason }` for
  each deprecated member a document used) are typed.

  Sending. A read is a GET whenever its URL fits the API's 8,192-byte limit,
  so a production key's read is cached by the CDN and the API with no option
  set, and a POST when it does not; no document is refused for its length.
  `method: "POST"` always sends a POST. A host that serves GraphQL by POST
  only (the admin host) answers a GET as a path it does not serve; the read
  is repeated as a POST, and that host is read by POST for five minutes. A
  429 (over the API's read limits: 4 running per project, 3 of them per
  client, and 64 waiting per client or 256 per key, with `/api/entries`
  reads counted in the same limits) is sent again after its `Retry-After`
  plus up to 250 ms, up to 3 times; `retries: 0` throws it, and a thrown 429
  carries `retryAfter` in seconds, on REST calls too. `createClient` takes the legacy key a site holds for
  every read, `pk_`, `sk_` or unprefixed as older tenants were minted, and
  warns once per process without printing any of it; `preview()` and draft
  reads (`draftClient`, `CAPA_DRAFT_KEY`) take a `cap_` key only.

  Persisted queries. `persisted: true` sends the document's sha256 as a
  cacheable GET (a POST when the variables are too long for a URL), and on
  `PersistedQueryNotFound` one POST with the document. The API stores a
  document only for a development key; a hash a host declined is
  remembered for five minutes and sent by POST meanwhile. `capa persist`
  registers a project's documents at build time, with the development key
  in `CAPA_DRAFT_KEY`, on `CAPA_API_URL` (`CAPA_ADMIN_URL` for a self-hosted
  stack whose read host stores nothing), and refuses a production key
  before it sends anything. Every document is pinned (`Capa-Persist: pin`),
  so documents registered in the Explorer or a preview never evict it, and
  one stored without its pin fails the run. A refusal is reported with its
  code. `--release` and `--env` name the build its pins belong to
  (`Capa-Persist: pin; release=<id>; env=<env>`), read from Vercel's and
  Netlify's build variables when not given, so the API keeps the latest
  production releases' documents before a preview's.

  Typed documents. `capa-codegen --graphql` checks the project's `.graphql`
  files and its `#graphql`, `/* capa */` and gql\`\` literals against the
  key's schema, and writes one module: the schema's types, `CapaQuery`, a
  `TypedDocument` per operation with `<Name>Models`, the models it reads,
  beside it, a `<Name>Fragment` per fragment and `capaTreeLayout`.
  `tagsFor({ namespace: <Name>Models })` tags a Next.js read with every model
  its query reads, relations and filters through them included, so no model
  is left out by hand, and with `capa:media`, which `revalidateFromWebhook`
  revalidates when a file in the media library is edited. `client.graphql(doc, vars)` then infers data and
  variables with no cast, a literal is typed by its own text, and a
  document's required variables are required in the call. A literal codegen
  has not read yet does not compile (`RunCapaCodegen`). A fragment in a
  literal of its own, spread with `${FRAGMENT}` into a query kept
  `as const`, works as in Hydrogen. A field, argument or value a later
  `Capa-Version` phases out is `@deprecated`, and each use is printed with
  its file, line and reason. `--watch`, `--check`, `--save-schema` and
  `--schema` are supported. Both commands read `CAPA_API_URL` and `CAPA_KEY`
  (`CAPA_BASE_URL` and `CAPA_API_KEY` still work) from the shell or the
  project's `.env` files, as `next dev` does.

  REST types. `capa-codegen` without `--graphql` takes a `cap_` key: it
  writes the model interfaces the `/v2` schema gives a legacy key from the
  key's GraphQL schema, keyed by namespace, with no tenant id, and from a
  saved schema with `--schema`. Every field is optional there, an enum is a
  `string` and a field GraphQL leaves out is `unknown`, since GraphQL does
  not say more; no `CAPA_SCHEMA_CHECKSUM` is written. Whichever key reads
  it, the module now parses for any namespace: a field that is not an
  identifier is quoted (`"am/pm_indicator"?: string;`), a model whose
  PascalCase name is not one is named as GraphQL names it (`2024_events` is
  `_2024Events`, its references and `Select` and `Attrs` aliases too), and an
  enum's values are escaped. A read's `fields` is typed as the API returns
  it: `entries.list<Articles, typeof select>` types exactly the fields the
  select names, each always there and `null` when it was never filled, an
  expanded relation as the entry (with `fields`) or
  `{ id, model, missing: true }`, one it does not expand as `{ id, model }`,
  a relation list as `{ items, pageInfo }` and media as
  `{ id, url, alt, type, width, height }` (`EntryFields`). A select the
  compiler cannot read, a string or one typed `Select<T>`, types every field
  and each relation as any of the three. A flat read's relations are
  references, `included` holds partial entries, and `inflate` returns the
  tree read's type. BREAKING for code that compiled against the stored
  shape (`fields.author.name`, `fields.coauthors[0]`), which read
  `undefined` or threw at run time. A relation may be named alone in a
  typed select, as a reference.

  The typed builder. `client.graphql.query(selection)` builds the document
  from an object and, with `createClient<CapaQuery>()`, types the result from
  exactly what was selected. A misspelled field, argument, filter field or
  operator, an argument of the wrong type, a sort value outside the enum
  and a missing required argument (`article` without `args: { id }`) do not
  compile, and the compiler's error names the key and where it was written
  (`SelectionError<"titel is not a field of articles.nodes">`).
  `NodeOf<CapaQuery, typeof selection, "articles">` is one entry of a read,
  for a component's props, and `QueryResult` the whole of its `data`. An alias reads a field again in the same request:
  `{ latest: { __aliasFor: "articles", args, nodes } }` is sent as
  `latest: articles(...)`, and typed and checked as `articles`. A `Date` in
  `args` is sent as its ISO text. `selectionToDocument` returns the text a
  selection sends. It takes no `persisted`, and throws a `TypeError` for it
  before any request: its document is printed when it runs, so `capa persist`
  never stored it, and it is already a cacheable GET.

  Paging, tags and descriptions. The builders page back as the API does:
  REST's `before` with a `limit` is sent as `last` with `before`, and
  `before=end` as `last` alone, the end of the list. A model's filter takes
  `_tags`, as REST's `where` takes `$tags`, with the operators the key's
  schema declares for it. `graphqlSchema()`, the builders and codegen read a
  field's `Capa field ...` tag from the last line of its description, after
  the field's label, which is where the API writes it.

  REST's shape. `toTree(data, selection, layout)` turns a builder result into
  REST's `shape=tree` data for the same read, typed from the selection:
  system keys beside `fields`, each field under its namespace, media values
  in REST's public shape and order (`id`, `url`, `alt`, `type`, `width`,
  `height`), and each entry's `model` from the layout. A relation list
  without `pageInfo` selected is `{ items }`, and a missing item of one is
  left out, where REST keeps a `{ id, model, missing: true }` slot. The layout
  is the `capaTreeLayout` constant codegen writes, so a server reads no schema
  for it, or a schema read with `client.graphqlSchema()`. A root alias
  converts as the field it names; an alias below a root is refused, since
  REST reads each field once. `selectToSelection` writes a REST read as a
  builder selection, and `graphqlToSelect` takes a selection back to REST
  as it takes a tool spec.

  The tool spec. `buildGraphQLQuery`, `graphqlToSelect` and `selectToGraphQL`
  move between the spec the Explorer and `@capacms/mcp` share, GraphQL text and
  the equal REST request, written exactly as the API writes it in
  `extensions.capa.rest`: the filter as `where` JSON, and a system key a
  field shadows as `$tags`, `$createdAt` or `author.$id`. They nest at most 4
  relations below the root entry, which is the API's 5 levels of entries,
  and send each filter value as the type its filter input declares, so
  `has: "true"` on a list of true/false values goes as `true`. They check a
  filter's names through `and`, `or`, `not` and one hop, refuse a system
  field GraphQL does not filter (`_version`) and a hop through a relation the
  key cannot read without naming the hidden model, and refuse a key a spec
  does not take (`frist`) with the keys it does. `selectToGraphQL` selects
  what REST returns: all six media fields, and for `*` or no select the
  system keys, every field and each relation list as a connection of ids.
  `inflate` and `Select<T>` read `$tags`, `$createdAt` and the other `$`
  names as system keys (`SystemKey`). The schema summary lists the models
  GraphQL leaves out in `restOnly`, and the builder refuses one by naming
  its REST read, never as an unknown model with another model suggested.
  A read of one entry pages a relation list from a cursor: `after` on the
  relation's spec, `after:` in a select, `args.after` in a selection. A list
  read refuses it, as the API does. `sort` takes one value or a list, at the
  root too. A spec written as REST writes a read (`author.name`,
  `author(name)`, `*`, a sort of `-publishedAt`) throws with what to write
  instead.

  Next.js. `graphql()` on `@capacms/sdk/nextjs` reads `CAPA_API_URL`,
  `CAPA_KEY` and `CAPA_API_VERSION` (a setting in `config` wins), works out
  draft and edit mode from `{ draftMode, headers }` as `getCapaClient` does,
  and keeps a published read that answered with no `errors` in Next's data
  cache, through `unstable_cache`, under its `tags`: until one of them is
  revalidated, or for `revalidate` seconds. A read with no `tags` is tagged
  `capa:graphql` (`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates
  on every content and media event; `tagsFor({ namespace })` tags a read by
  the models it reads. Drafts are read uncached, by GET. It is not
  persisted by default. `draftClient`, `getCapaClient` and
  `getPublishedClient` take `CapaQuery` like `createClient`, and
  `getCapaClient` keeps its GraphQL reads, a document or the typed builder,
  in Next's data cache the same way, under the `tags` and `revalidate` each
  call gives.

  The package root. `createClient` from `@capacms/sdk`, the legacy `/v2/api`
  client, refuses a `cap_` key with a `TypeError` that names
  `@capacms/sdk/next`, before it asks for a tenant id or sends anything,
  where every read failed as a 401 "Invalid API key". Its `CapaError`
  message joins the body with a colon. The README opens with which import is
  which and links the API reference, and `homepage` is
  `https://docs.capacms.com/api`.

  Edit mode. A GraphQL read in edit mode is marked like a REST read: each
  object that selected `id` and `model` is an entry, and
  `capaAttrs(node, field)` and `fieldAttrs(node)` tag a field GraphQL renamed
  by its namespace. To know which fields those are, the client reads the
  key's type and field names once a minute, beside the page's read rather
  than after it; a document that never says `model` reads none. `toTree`
  keeps the mark, and `markGraphQLEntries` is exported. With next.5's
  `FieldAttrs<T>`: `fieldAttrs` also takes a GraphQL node and, for a REST
  entry typed with `Model`, still returns exactly `FieldAttrs<Model>`, so the
  `<Model>Attrs` aliases codegen writes keep working. `TaggableField<E>` is
  exported beside it.

## 1.0.0-next.5 (not published; its changes shipped in next.6)

* 1.0.0-next.5. `<CapaOverlay adminOrigins={[...]} />` from the new entry
  point `@capacms/sdk/nextjs/overlay`: the live preview overlay as one Next.js
  client component, refreshing with `router.refresh()` on save (M6). `react`
  and `next` are optional peer dependencies, needed only for that entry. The
  `FieldAttrs<T>` type is exported from `@capacms/sdk/next`, and `capa-codegen`
  writes a `<Model>Attrs` alias beside each `<Model>Select`, so
  `const a: ArticleAttrs = fieldAttrs(entry)` catches a wrong field name at
  compile time (M5).

## 1.0.0-next.4 (2026-09-24)

* 1.0.0-next.4. Edit mode. BREAKING for sites that call `capaAttrs(entry,
  field)` with no third argument: it now tags only entries read in edit mode, so
  a published page ships no `data-capa-` attributes. Create the client with
  `editMode: true` (work it out with `editMode({ draftMode, headers })` from
  `@capacms/sdk/nextjs`) and every entry it reads, related entries included, is
  marked. Sites that pass a flag keep working unchanged. New in
  `@capacms/sdk/nextjs`: `editMode`, `resolveEditRequest` for middleware (checks
  a `capa-edit` token with Capa, strips a forged `x-capa-edit`, returns the
  `private, no-store` Cache-Control to set), and the constants `EDIT_PARAM`,
  `EDIT_HEADER`, `DRAFT_COOKIE`, `EDIT_CACHE_CONTROL`. New on the client: a
  `path` option (config and per call) sent as `Capa-Path` beside `Capa-Page`, so
  Capa can list the concrete URLs an entry appears on. `markEditEntries`,
  `isEditEntry` and `CAPA_EDIT` are exported from `@capacms/sdk/next`.
  Five-minute integration (M6) in `@capacms/sdk/nextjs`: `getCapaClient`
  (keys and edit mode from env), `getPublishedClient`, `createPreviewRoute`,
  `exitPreviewRoute` and `capaMiddleware` (preview links, the Published view,
  edit mode and `no-store` in one line). Typed `fieldAttrs(entry).title` on
  `@capacms/sdk/next` (M5): a wrong field name is a compile error.
  Flat responses: `entries.list` and `entries.get` take `shape: "flat"`, which
  sends `?shape=flat` and returns every relation as a `{ id, model }`
  reference with each expanded entry once in `included`, typed by the select.
  The result carries the select it sent. `inflate(result)` turns it back into
  the tree result, deep-equal to a `shape=tree` read of the same request; it
  returns copies and stops where the select stops, so cycles end. New types:
  `FlatPage`, `FlatSingle`, `FlatListOptions`, `FlatGetOptions`, `Included`,
  `ExpandedTargets`, `ResponseShape`, `FlatResponse`. Additive: a read without
  `shape` sends the same URL and returns the same result as before.

## 1.0.0-next.3 (2026-09-23)

* 1.0.0-next.3. Reads made from a layout are no longer charged to `/`.
  `routeOf` returned the route a file sits at, so `app/layout.tsx` came out as
  `/` and every Site singleton or nav read in a root layout was recorded as a
  read of the home page, on every page of the site. `routeOf` now returns
  `"(layout)"` (exported as `LAYOUT_PAGE` from `@capacms/sdk/next` and
  `@capacms/sdk/nextjs`) for an app-router `layout.*` or `template.*`, and a
  read whose page is `"(layout)"` sends no `Capa-Page`, even when the client was
  created with a `page`. The return type is still `string`, so layout code that
  already passes `routeOf(import.meta.url)` needs no change: upgrading fixes it.
  No API change: the API already records nothing for a read without the
  header. In the pages router, `pages/layout.tsx` is now the page `/layout`
  rather than `/`.

## 1.0.0-next.2 (2026-09-23)

* 1.0.0-next.2. The overlay reports `visible { entryId, field }`, the tagged
  element at the centre of the viewport, while the page scrolls: at most every
  150ms and only when it changes (the topmost visible element at the very top
  of a page, the bottommost at the very bottom). The Capa editor's "Follow the
  page" scrolls the form to match. Additive and still protocol `v: 1`, so an older admin
  ignores it and an older overlay simply never sends it. `pickCentred` and
  `scrollEdge`, the pure choice behind it, and `visibleMessage` are exported
  for tests.

## 1.0.0-next.1 (2026-09-23)

* 1.0.0-next.1. Live preview. `capaAttrs(entry, field, enabled)` on
  `@capacms/sdk/next` tags an element with the entry and field it renders,
  typed so `field` is one of the entry's data keys. The new entry point
  `@capacms/sdk/overlay` exports `startOverlay({ adminOrigins, onRefresh })`,
  which, inside the Capa editor's preview frame, outlines the field being
  edited, reports clicks on tagged elements back to the editor and re-renders
  the draft after a save. It does nothing outside a frame and has no
  dependencies. `acceptMessage` is exported for tests. See "Live preview" in
  the README.
* `routeOf(import.meta.url)` now decodes the file URL. A real `import.meta.url`
  percent-encodes brackets, so a dynamic route such as `app/blog/[slug]/page.tsx`
  came out as `/blog/%5Bslug%5D` and every read with that `page` threw a
  `TypeError`.
* The package is published as `@capacms/sdk`. The `capa` scope on npm was
  already taken, so the org is `capacms`; entry points are `@capacms/sdk`
  (legacy `/v2` client), `@capacms/sdk/next` and `@capacms/sdk/nextjs`.
  Nothing else about the package changed. Earlier notes below that named
  `@capa/sdk` were written before the first publish and mean this package.
* Added `page` to `CapaNextConfig` and to every call's options, sending the
  `Capa-Page` header so Capa can report which of a site's pages read which
  entries. Telemetry only: it does not change a response, a cache key or an
  `ETag`. A malformed value throws a `TypeError`, because the API ignores a
  header it cannot store and a typo should surface where it is written.
* Added `client.pages.list()` and `client.pages.get(page)` for the page list and
  one page's detail. `list({ entry })` narrows to the pages that read one entry
  and adds `entryReads` to each row; `get` returns `null` for an unknown page.
* Added `client.preview(token)`, which verifies a preview token minted by the
  Capa admin and returns the claim, or `null` when the token is invalid or
  expired. Every other failure throws.
* Added `schemaChecksum` to `CapaNextConfig`, sending the `Capa-Schema` header on
  every call so Capa can tell a site built against the current models from one
  built against an older set. Telemetry only, like `page`: it changes no
  response, cache key or `ETag`. A malformed value throws a `TypeError`, because
  a stamp the API drops in silence looks exactly like a site that is up to date.
* `capa-codegen` now writes `export const CAPA_SCHEMA_CHECKSUM` beside the
  checksum comment it already wrote, so the value can be imported and handed to
  `createClient`. That constant is the only new byte in the generated file.
* `client.pages.get(page)` now carries `insights`, the suggestions Capa draws
  from that page's own reads (`overfetch`, `fanout`, `cache`, `drift`), each
  with the numbers behind it and a copyable rewrite where there is one, and
  every `queries[]` row carries `selection`, the parsed Selection IR of its
  `select`, or `selectionError` when it no longer parses.
* Every `queries[]` row on `client.pages.get(page)` also carries `url`, the
  request that group most often made, so a caller can print the real call with
  its filters, sort and limit instead of reconstructing one from `select`. It is
  `null` once the group has been folded into the daily rollup, which keeps
  counts rather than requests. The detail itself gains `lastReadAt`.
* `client.pages.list()` now carries `meta.insights`, the tenant-wide `unused`
  rows: entries no page has read in 30 days and nobody has edited in 90.
* Added `routeOf(file)`, `preview(token, client)` and `pagesFor(client)` to
  `@capacms/sdk/nextjs`. `routeOf` turns a Next route file into a page string,
  dropping route groups, parallel slots, leaf file names and extensions, and
  throws rather than guessing for files Next does not route.

## 1.0.0-next.0 (2026-09-22)

* Added `@capacms/sdk/next`, the dependency-free `/api/` read client for `cap_`
  keys, typed selects, cursor iteration, structured `/api/` errors, and
  surrogate cache tags.
* Added `@capacms/sdk/nextjs` helpers for Next fetch caching, surrogate tag
  construction, webhook revalidation, and server-only draft client selection.
* Kept the legacy `/v2/api` client as the root export.
* Added relation-aware `capa-codegen` output while preserving byte-identical
  output for schemas without relations.
