# Add `GET /:id/download` handler to `@suaveplan/api-plugin-export` **Package**: `@suaveplan/api-plugin-export` **Status**: PROPOSED **Type**: enhancement **Design**: ./design.md **Spec delta**: ./specs/api-plugin-export/spec.md ## Why The initial `@suaveplan/api-plugin-export` ship at `741e1b176` deferred the `GET /:id/download` endpoint (referenced from `archive/2026-05-19-add-api-plugin-export/proposal.md` §"Out of scope"). The `POST /exports` response already returns `downloadUrl: "/{jobId}/download"` for forward compatibility, but no handler resolved that URL — a 404 from a documented field is a worse defect than the missing capability itself. `SurrealExportStore` shipped at `230b5f3e8` (change `2026-05-19-wire-surreal-export-store`) and made this endpoint useful in earnest: jobs now persist across processes, so the download handler can look up the job state from the `exportJobStore` `ServiceRegistry` slot rather than the in-process `exportJobs` `Map`. With the persistent store in place, the deferral is no longer load-bearing. This change closes the §3/§4 deferral for the download endpoint **only**. It does NOT bring in the larger §4 surface (streaming-from-source exporters, S3-backed blob store, signed URLs, Parquet, chunked transfer) — each remains its own follow-up. ## What changes - ADDED: `GET /:id/download` route on `createExportRoutes(...)`. Looks up the job via `ExportJobStore.get(id)` (preferred, when a store is wired) or `deps.jobs.get(id)` (in-memory fallback for tests / lightweight deployments). Validates `status === "completed"`. Re-fetches source rows through the registered `DataProvider`. Re-renders the body via the existing `getExporter(format)` writer. Returns the body with `Content-Type` from the writer result and `Content-Disposition: attachment; filename=""`. - ADDED: `ExportRoutesDeps.jobStore?: ExportJobStore` — optional injection slot so consumers can pass `MemoryExportJobStore` or `SurrealExportStore` directly. When omitted, the download handler wraps `deps.jobs` in an inline adapter so the in-memory route path keeps working unchanged. - ADDED: `EXPORT_CODES.JOB_NOT_READY` — raised when the job exists but is not in `completed` state (i.e. `pending`, `processing`, or `failed`). Surfaced as HTTP `409 Conflict` via the existing RFC 7807 problem-details renderer. - ADDED: Per-package RFC 7807 problem-details renderer at `src/routes/problem-json/problem-json.ts` (mirrors the `@suaveplan/api-plugin-import` and `@suaveplan/api-plugin-database` siblings — every api-plugin package owns its own renderer so the per-package codes flow through unchanged). - ADDED: OTel instruments `export.download.served` (Counter, labelled `{ format }`) and `export.download.failed` (Counter, labelled `{ reason }`). Extended `ExportInstruments`, emitted on every download outcome. - WIRED: `createExportPlugin(...) → onReady` now resolves `ctx.getService("exportJobStore")` (if present) and threads it into the route deps as `jobStore`. The slot was declared at `2026-05-19-wire-surreal-export-store` but had no consumer until now. ## Affected dependents - **Direct consumers**: none on-tree yet — the plugin is consumed by `api-host` apps that mount `createExportPlugin()` and register a `DataProvider`. The new route is additive (no existing status code or response body changes), so no consumer adjustment is required. - **Downstream packages**: `@suaveplan/api-plugin-host` apps that wire `SurrealExportStore` into `ctx.registerService("exportJobStore", ...)` automatically gain a persistent-state download endpoint with no further configuration. ## Out of scope | Excluded surface | Justification | |---|---| | Streaming exporters (chunked / row-by-row encoding) | §4 work; the source rows still come from the existing in-memory provider per-call and the existing CSV/JSON/XLSX writers operate on materialised `Record[]`. | | S3 / blob-store backend for the rendered body | §4 work; download writes directly to the response body. | | Signed / time-limited download URLs | §4 work; the `downloadUrl` is currently the relative path the POST handler already returns. Authorisation is the host application's concern (mount the plugin behind `@suaveplan/api-plugin-auth`). | | Parquet / Markdown / new format writers | §4 work; this change reuses the three writers shipped at `741e1b176`. | | Caching the rendered body across downloads | Not a §4 item — explicitly out so the request is always idempotent against the current provider state. | ## Source mapping | Requirement | Source | Notes | |---|---|---| | `GET /:id/download` endpoint | `../suaveplan/enterprise/api-plugin-export.md` §2 "Smells" + §3 "Required enterprise-quality improvements" | Originally deferred at `archive/2026-05-19-add-api-plugin-export/proposal.md`. | | `Content-Disposition: attachment; filename="…"` header | RFC 6266 §4.1 | Standard download UX. | | `Content-Type` from `Exporter.export(...).mimeType` | `src/exporters/{csv,json,xlsx}/*.ts` (existing) | Reuse — no new format work. | | `JOB_NOT_READY` (409 Conflict) | Genesis convention — same shape as `JOB_NOT_FOUND` (404) | New code for an existing failure path. | | `exportJobStore` `ServiceRegistry` slot | `archive/2026-05-19-wire-surreal-export-store/specs/api-plugin-export/spec.md` | Slot declared previously; this change is the first consumer. |