# @warmhub/sdk-ts changelog

<!-- warmhub-changelog-artifact:v1 -->

## 0.128.0

### Documentation

Versioned release notes are available as [HTML, Markdown, and JSON](https://docs.warmhub.ai/sdk/typescript/changelog/).

---

Historical changelog shipped with @warmhub/sdk-ts 0.127.0:

# @warmhub/sdk-ts changelog

Release 0.127.0.

Changelog generation is temporarily disabled while the release integration is repaired.


Earlier changelog shipped with @warmhub/sdk-ts 0.126.0:

# @warmhub/sdk-ts changelog

Release 0.126.0.

Changelog generation is temporarily disabled while the release integration is repaired.


Earlier changelog shipped with @warmhub/sdk-ts 0.125.0:

# @warmhub/sdk-ts changelog

Release 0.125.0.

Changelog generation is temporarily disabled while the release integration is repaired.


Earlier changelog shipped with @warmhub/sdk-ts 0.124.0:

# @warmhub/sdk-ts changelog

Release 0.124.0.

Changelog generation is temporarily disabled while the release integration is repaired.


Earlier changelog shipped with @warmhub/sdk-ts 0.123.0:

# @warmhub/sdk-ts changelog

Release 0.123.0.

Changelog generation is temporarily disabled while the release integration is repaired.


Earlier changelog shipped with @warmhub/sdk-ts 0.122.3:

# @warmhub/sdk-ts changelog

Release 0.122.3.

Changelog generation is temporarily disabled while the release integration is repaired.


Earlier changelog shipped with @warmhub/sdk-ts 0.120.0:

# Changelog — `@warmhub/sdk-ts`

## How to read this file

Entries are grouped by release. `## Unreleased` collects everything merged but
not yet published; the release pipeline is the sole version authority and
stamps the exact version at publish time, so no heading here ever invents one.

Within a release, four change sections appear, in this order. A section is
present only when it has entries; a release may add plain narrative sections
after them ("Behavior worth knowing", "Still supported") when the change needs
context that is not itself a change entry.

- **Breaking** — your code must change. Every item is written as a migration:
  what it was → what it is now → the exact edit, with before/after code → why,
  in one line, citing the ruling.
- **Added** — new surface. Nothing to do unless you want it.
- **Fixed** — behavior that was wrong and now is not.
- **Removed** — surface that is gone with nothing to put in its place. Anything
  a caller can migrate to is filed under Breaking instead, so this section is
  often absent.

WarmHub's source repository is private, so rulings are cited by pull-request
number rather than linked. Release notes live at
<https://docs.warmhub.ai/releases/overview/>.

The Python SDK, `warmhub`, keeps the same file in the same shape.

---

## Unreleased

## 0.120.0

Repository export v3. The exact version is stamped at publish time by the
release pipeline; source manifests stay at `0.0.0-development`.

### Breaking

#### Checkpoint generation is retired — take an export instead

**What it was.** Producing a fresh snapshot of a repository meant asking the
server to mint a checkpoint, polling until the job completed, requesting a
short-lived signed URL for the archive, downloading it outside the SDK, and
verifying the bytes locally.

**What it is now.** One call that streams verified rows, with the header,
per-row canonical decode, ordering and trailer checks applied as the bytes
arrive. There is no job to start, no state to poll, and no separate verify
step — a row that reaches you has already been proven.

**Migration.**

Before:

```ts
import { createReadStream } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { verifyRepositoryCheckpointArchive } from '@warmhub/sdk-ts/checkpoint'

let status = await client.repo.checkpoint.generate('acme', 'catalog')
while (status.state !== 'complete') {
  await new Promise((resolve) => setTimeout(resolve, 1_000))
  status = await client.repo.checkpoint.status('acme', 'catalog', {
    checkpointId: status.checkpointId,
  })
}

const access = await client.repo.checkpoint.getAccess('acme', 'catalog', {
  checkpoint: 'latest',
  artifact: 'archive',
})
// Signed URL: fetched WITHOUT the WarmHub bearer token.
const archive = await fetch(access.url)
await writeFile('catalog.zip', Readable.fromWeb(archive.body))
await verifyRepositoryCheckpointArchive(createReadStream('catalog.zip'))
```

After:

```ts
for await (const row of client.repo.export('acme', 'catalog')) {
  console.log(row.kind, row.durableId, row.version)
}
```

If you were relying on `generate` returning before the download — starting the
build in one process and fetching it in another, or on another machine — that
shape survives as the async token flow:

```ts
// Process A: ask for it, get a token, do not read a byte.
const prepared = await client.repo.exportPrepare('acme', 'catalog')
console.log(prepared.exportToken, prepared.atRepoSeq)

// Process B: redeem it. `export` polls while the fold is still running.
for await (const row of client.repo.export('acme', 'catalog', {
  token: prepared.exportToken,
})) {
  // ...
}
```

The token is redeemable only at that repository's URL, by a principal holding
checkpoint read there.

**Why.** Export format v3 replaces checkpoint generation outright; the whole
generation pipeline is deleted server-side (PR #10020).

#### `client.repo.checkpoint.generate` and `.retry` are gone

**What it was.** `generate(orgName, repoName, { atLeastRepoSeq })` minted a
checkpoint; `retry(orgName, repoName, checkpointId)` re-ran a failed one. Both
returned a `RepositoryCheckpointStatus`.

**What it is now.** Neither method exists, and the tRPC procedures behind them
(`repo.checkpoint.generate`, `repo.checkpoint.retry`) are no longer served. The
`repo:checkpoint-generate` scope no longer buys anything.

**Migration.** Replace both with `client.repo.export`.

Before:

```ts
const fresh = await client.repo.checkpoint.generate('acme', 'catalog', {
  atLeastRepoSeq: 42,
})
const recovered = await client.repo.checkpoint.retry(
  'acme',
  'catalog',
  failed.checkpointId,
)
```

After:

```ts
// `atRepoSeq` pins the fence the way `atLeastRepoSeq` asked for a floor.
// Omit it and the server pins current and echoes it back in the header.
for await (const row of client.repo.export('acme', 'catalog', {
  atRepoSeq: 42,
})) {
  // ...
}
```

There is no retry to replace: an export is a read, so a failed one is re-read,
not re-queued.

**Why.** Same retirement (PR #10020).

#### Failed checkpoint statuses now all answer `nextAction: 'none'`

**What it was.** `RepositoryCheckpointStatus` carried `nextAction: 'retry'` for
`deadline_exceeded` and `attempts_exhausted`, and `nextAction: 'generate'` for
`invalid_source`. Each named a procedure a reader could call.

**What it is now.** Those three failure codes join `repository_deleted` in a
single variant with `nextAction: 'none'`. The `'retry'` and `'generate'`
members are gone from the union, so a `switch` over `nextAction` that had cases
for them will no longer typecheck against them.

**Migration.**

Before:

```ts
if (status.state === 'failed') {
  switch (status.nextAction) {
    case 'retry':
      await client.repo.checkpoint.retry('acme', 'catalog', status.checkpointId)
      break
    case 'generate':
      await client.repo.checkpoint.generate('acme', 'catalog')
      break
    case 'contact_support':
      report(status.checkpointId, status.failureCode)
      break
  }
}
```

After:

```ts
if (status.state === 'failed') {
  if (status.nextAction === 'contact_support') {
    report(status.checkpointId, status.failureCode)
  } else {
    // Terminal. Take an export instead of trying to revive the checkpoint.
    for await (const row of client.repo.export('acme', 'catalog')) {
      // ...
    }
  }
}
```

**Why.** Each retired value pointed at a route the server no longer serves;
leaving them in the vocabulary would send readers to a 404 (PR #10020).

### Added

- **`client.repo.export(orgName, repoName, options?)`** — an
  `AsyncGenerator<RepositoryExportRow>` over a verified export stream. Options:
  `mode` (`'heads'`, the default, or `'ops'`), `sinceRepoSeq` (delta base;
  `0` means a full export), `atRepoSeq` (pin the fence), `prefer`
  (`'sync'` | `'async'`), `token`, `session`, `afterDurableId`,
  `pollIntervalMs`, and `signal`.

- **`client.repo.exportPrepare(orgName, repoName, options?)`** — returns
  `{ exportToken, atRepoSeq }` without waiting for the fold or reading the
  body. Takes `mode`, `sinceRepoSeq`, `atRepoSeq`, and `signal`.

- **`RepositoryExportSession` / `createRepositoryExportSession()`** — the
  rolling verification state of one logical export: the opening `header`, the
  resume cursor `lastDurableId`, `rowCount`, `complete`, and — once the stream
  is finalized — `contentSha256`. Pass the same session back into `export` to
  resume a stream that broke:

  ```ts
  import {
    createRepositoryExportSession,
    RepositoryExportError,
  } from '@warmhub/sdk-ts'

  const session = createRepositoryExportSession()
  const rows: unknown[] = []
  for (let attempt = 0; attempt < 3 && !session.complete; attempt += 1) {
    try {
      // The session's own cursor and fence pin the resumed request; do not
      // recompute them.
      for await (const row of client.repo.export('acme', 'catalog', {
        session,
      })) {
        rows.push(row)
      }
    } catch (error) {
      if (
        !(error instanceof RepositoryExportError) ||
        error.reason !== 'truncated'
      ) {
        throw error
      }
    }
  }
  console.log(session.rowCount, session.contentSha256)
  ```

- **`readRepositoryExportStream(source, session, expected, options?)`** — read
  a response body you fetched with your own transport, verified against a
  session as it goes. `client.repo.export` is the usual entry point.

- **`restoreRepositoryExportSession(source, options?)`** — the offline twin:
  replay a saved export's bytes back through the same header, canonical-row,
  ordering and trailer checks, and get the session back rather than the rows.
  Pass `{ partial: true }` for a download that was interrupted, then resume
  from the session it returns.

  ```ts
  import { createReadStream } from 'node:fs'
  import { restoreRepositoryExportSession } from '@warmhub/sdk-ts'

  const session = await restoreRepositoryExportSession(
    createReadStream('catalog.ndjson'),
    { partial: true },
  )
  for await (const row of client.repo.export('acme', 'catalog', { session })) {
    // continues from session.lastDurableId
  }
  ```

- **`applyRepositoryExportDelta(base, delta)`** — fold a delta export onto a
  base by `durableId`: active rows upsert, tombstones remove. Pure; neither
  argument is mutated. Both arguments accept either an iterable of rows or a
  `Map` returned by a previous fold, so folds chain.

  ```ts
  import { applyRepositoryExportDelta } from '@warmhub/sdk-ts'

  const session = createRepositoryExportSession()
  const base = []
  for await (const row of client.repo.export('acme', 'catalog', { session })) {
    base.push(row)
  }
  const fence = session.header?.atRepoSeq ?? 0

  // Later: only what changed since that fence.
  const delta = []
  for await (const row of client.repo.export('acme', 'catalog', {
    sinceRepoSeq: fence,
  })) {
    delta.push(row)
  }

  const current = applyRepositoryExportDelta(base, delta)
  ```

  `wref` is an opaque rendering and is never keyed on, so the fold survives a
  rename.

- **`RepositoryExportError`** with a typed `reason`:
  `'count_mismatch'`, `'digest_mismatch'`, `'fence_mismatch'`,
  `'header_invalid'`, `'row_invalid'`, `'row_out_of_order'`,
  `'since_below_epoch_floor'`, `'trailer_invalid'`, `'truncated'`. Every reason
  except `'since_below_epoch_floor'` is a local verification verdict — the
  bytes did not prove what the trailer claimed. `'since_below_epoch_floor'` is
  the server refusing a delta base it no longer retains; recover by re-running
  the export with `sinceRepoSeq: 0`.

- **`RepositoryExportRowMap` / `RepositoryExportRows`** — the collection types
  `applyRepositoryExportDelta` accepts and returns.

### Behavior worth knowing

- **A redeemed token's bytes come from storage, not the API, and the client
  checks what the API said.** A complete redemption answers with a short-lived
  presigned URL rather than the export bytes, and the SDK fetches that URL
  *without* your WarmHub bearer: the signature in the URL is the whole
  capability, and the storage host has no business seeing a credential. The
  record count and content digest the session derives are then checked against
  the ones the API named — a download that does not match raises
  `count_mismatch` or `digest_mismatch` rather than handing you rows nothing
  independent vouched for. A refused or expired URL re-redeems the token, which
  is a deterministic lookup, and retries; a break after rows have already been
  yielded surfaces as an error instead, because a silent restart would hand you
  those rows twice.

- **Ordering is verified, in `heads` mode only.** A heads export is a set,
  emitted in ascending `durableId` by unsigned UTF-8 bytes, and that order is
  part of what the digest proves. A row that does not advance the cursor raises
  `row_out_of_order`. An `ops` export is a log — it repeats identities by
  design — and is not order-checked.

- **Resume trailers are segment-scoped.** A resumed request carries no header,
  and its trailer covers only that segment's rows. The session keeps two
  hashers: a per-segment one the trailer is checked against, and a whole-stream
  one that accumulates across segments. `session.contentSha256` is the
  whole-stream value, and is what you should record as the export's digest.

- **A trailer failure poisons the session.** `truncated` is the resumable
  failure. `count_mismatch`, `digest_mismatch`, `fence_mismatch` and
  `trailer_invalid` are not: the whole-stream digest has already absorbed that
  segment's rows, so the session refuses to resume and you must start a new
  one.

- **`afterDurableId` requires a session with a header.** A resumed request gets
  no header back, so with nothing to check the repository and fence against the
  call is refused before it is sent. Reuse the session the broken stream wrote,
  or rebuild one with `restoreRepositoryExportSession`.

- **The async redemption poll has a floor of 50 ms**, and defaults to 1000 ms.
  Each poll re-runs the full authorization gate, so `pollIntervalMs: 0` — or a
  `NaN` from an unvalidated config value — is clamped rather than honored.

- **Flat server error bodies keep their message.** The export route answers
  with a flat `{ code, message }` body rather than the client's usual nested
  `{ error: { ... } }` envelope. Those responses are re-wrapped before the
  standard mapping runs, so classification stays standard and the server's
  message is not discarded.

### Still supported

The checkpoint **read** plane is untouched: `client.repo.checkpoint.status`,
`.latest`, `.getAccess`, and `verifyRepositoryCheckpointArchive` from
`@warmhub/sdk-ts/checkpoint` all continue to work against archives already in
object storage. Nothing mints new ones.

**Deprecation horizon:** those read surfaces are supported until the announced
cleanup, 30 days after GA. Their removal is a separately-filed post-GA change.
Move download-and-verify workflows to `client.repo.export` before then.
