Files
suaveplan/openspec/archive/2026-05-20-add-export-download-handler/design.md
2026-05-27 23:29:53 +01:00

79 lines
2.9 KiB
Markdown

# Design — add-export-download-handler
## Goal
Close the deferred `GET /:id/download` endpoint without expanding scope into §4 streaming /
S3 / signed-URL work. The handler reads the persisted job, re-runs the registered
`DataProvider`, re-renders through the existing writer, and streams the result back. Errors
flow through a per-package RFC 7807 problem-details renderer so consumers branch on the
existing `EXPORT.*` codes.
## File layout
```
packages/api/api-plugin-export/src/
error-codes/
error-codes.ts ← add JOB_NOT_READY
otel/
instruments.ts ← add downloadServed / downloadFailed
routes/
download/
download.ts ← runDownloadExport(deps, jobId)
download.md
download.test.ts
exports/
exports.ts ← extend deps + mount /:id/download
problem-json/
problem-json.ts ← RFC 7807 renderer (per-package)
problem-json.md
problem-json.test.ts
plugin/
plugin.ts ← thread exportJobStore through to routes
```
## Discriminated outcome
`runDownloadExport(...)` returns a union so the route registration call site stays free of
`c.json(...)` typing. The four failure variants each carry the data the renderer needs:
```ts
type DownloadOutcome =
| { kind: "ok"; body: string; contentType: string; filename: string; format: ExportFormat }
| { kind: "not-found"; id: string }
| { kind: "not-ready"; id: string; status: ExportStatus }
| { kind: "provider-not-found"; resource: string }
| { kind: "failed"; reason: string; cause: unknown };
```
Every branch throws / instantiates an `ExportError` so the spec-conformance pin keeps every
`EXPORT_*` code reachable from a production path.
## `jobStore` injection vs the in-memory `jobs` Map
`ExportRoutesDeps.jobStore?` is added as an optional dependency. When absent, the download
handler builds an inline `ExportJobStore` adapter over `deps.jobs` so the route works in
lightweight tests and host apps that opt out of SurrealDB. When present, the handler routes
all reads through the store — consistent with the persistence narrative in
`2026-05-19-wire-surreal-export-store`.
## OTel labels
- `export.download.served{format}` — labelled with `format: csv | json | xlsx`.
- `export.download.failed{reason}` — labelled with `reason: not-found | not-ready | provider-not-found | failed`.
Tests assert via `flushTestMetrics()` against the existing in-memory `MeterProvider` in
`tests/setup/polyfills.ts`.
## Status-code mapping
| Outcome | HTTP | Code |
|---|---|---|
| `ok` | 200 | n/a |
| `not-found` | 404 | `EXPORT.JOB_NOT_FOUND` |
| `not-ready` | 409 | `EXPORT.JOB_NOT_READY` |
| `provider-not-found` | 404 | `EXPORT.PROVIDER_NOT_FOUND` |
| `failed` | 502 | `EXPORT.PROVIDER_FETCH_FAILED` |
The mapping lives in the per-package `problem-json` renderer via `context.statusCode`
attached at throw time.