# Capability Spec Delta: api-plugin-export (add-export-download-handler) **Package**: `@suaveplan/api-plugin-export` **Status**: PROPOSED ## ADDED Requirements ### Requirement: `GET /:id/download` endpoint `createExportRoutes(deps)` SHALL register an additional route `GET /{id}/download` that serves the rendered file body for a previously completed export job. The handler SHALL look up the job via `deps.jobStore?.get(id)` when an `ExportJobStore` is wired and otherwise via `deps.jobs.get(id)` against the in-memory map. When the job is missing the handler SHALL respond `404 application/problem+json` with `code === "EXPORT.JOB_NOT_FOUND"`. When the job is present but `status !== "completed"` the handler SHALL respond `409 application/problem+json` with `code === "EXPORT.JOB_NOT_READY"`. When the job is present but the originally registered `DataProvider` has been removed from `deps.providers`, the handler SHALL respond `404 application/problem+json` with `code === "EXPORT.PROVIDER_NOT_FOUND"`. On any throw from `provider.fetch(...)` or the exporter the handler SHALL respond `502 application/problem+json` with `code === "EXPORT.PROVIDER_FETCH_FAILED"`. On success the handler SHALL respond `200` with `Content-Type` set to the writer's `mimeType` (`text/csv` / `application/json` / `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) and `Content-Disposition: attachment; filename=""`. #### Scenario: happy path streams CSV body - **WHEN** a `completed` job for the `users` resource exists, the provider returns one row, and a `GET /{id}/download` request is issued - **THEN** the response is `200`, the `Content-Type` is `text/csv`, the `Content-Disposition` carries `attachment; filename="…"`, and the body parses as RFC 4180 CSV #### Scenario: missing job returns 404 problem-details - **WHEN** `GET /missing/download` is issued and no job is stored under `missing` - **THEN** the response is `404`, the `Content-Type` is `application/problem+json`, and the body carries `{ code: "EXPORT.JOB_NOT_FOUND" }` #### Scenario: not-ready job returns 409 problem-details - **WHEN** `GET /pending-job/download` is issued and the stored job is in `status: "processing"` - **THEN** the response is `409`, the `Content-Type` is `application/problem+json`, and the body carries `{ code: "EXPORT.JOB_NOT_READY" }` #### Scenario: missing provider returns 404 problem-details - **WHEN** `GET /{id}/download` is issued for a job whose `resource` has no entry in `deps.providers` - **THEN** the response is `404` with `{ code: "EXPORT.PROVIDER_NOT_FOUND" }` #### Scenario: provider failure returns 502 problem-details - **WHEN** `provider.fetch` rejects during a `GET /{id}/download` request - **THEN** the response is `502` with `{ code: "EXPORT.PROVIDER_FETCH_FAILED" }` **Verified by:** `src/routes/download/download.test.ts::"streams a completed CSV job as application/csv with attachment disposition"` AND `src/routes/download/download.test.ts::"returns 404 problem-details when the job is missing"` AND `src/routes/download/download.test.ts::"returns 409 problem-details when the job is not yet completed"` AND `src/routes/download/download.test.ts::"returns 404 problem-details when the provider was deregistered"` AND `src/routes/download/download.test.ts::"returns 502 problem-details when provider.fetch rejects"` AND `src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"` --- ### Requirement: `JOB_NOT_READY` error code `EXPORT_CODES` SHALL include the additional code `JOB_NOT_READY`. The code SHALL be thrown from at least one production path — the `runDownloadExport` not-ready branch — and SHALL be pinned by `api-plugin-export.spec-conformance.test.ts`. `ExportError` SHALL narrow `error.exportCode` through the public type union. #### Scenario: registry membership - **WHEN** `Object.values(EXPORT_CODES)` is enumerated - **THEN** the result contains `"EXPORT.JOB_NOT_READY"` #### Scenario: code narrowing - **WHEN** `throw new ExportError(EXPORT_CODES.JOB_NOT_READY, "…")` is caught - **THEN** `error.code === "EXPORT.JOB_NOT_READY"` and `error.exportCode` narrows to the union **Verified by:** `src/error-codes/error-codes.test.ts::"JOB_NOT_READY narrows via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"` --- ### Requirement: Per-package RFC 7807 problem-details renderer The package SHALL ship a per-package RFC 7807 renderer at `src/routes/problem-json/problem-json.ts` exporting `PROBLEM_JSON_CONTENT_TYPE = "application/problem+json"`, a `ProblemDetails` shape, `renderProblem(err)` (pure mapping), and `respondProblemJson(c, err)` (writes the `Content-Type` header and JSON body to a Hono context). `SuaveplanError` instances SHALL surface their `code` and read `context.statusCode` for the HTTP status; non-numeric or sub-400 values SHALL coerce to `500`. Plain `Error` instances SHALL render as `{ type: "urn:suaveplan:error:internal", status: 500 }`. Unknown throwables SHALL render as `{ type: "urn:suaveplan:error:internal", title: "InternalError", status: 500 }`. #### Scenario: SuaveplanError maps to typed problem-details - **WHEN** `renderProblem(new ExportError(EXPORT_CODES.JOB_NOT_READY, "...", { context: { statusCode: 409 } }))` runs - **THEN** the returned object has `status: 409`, `code: "EXPORT.JOB_NOT_READY"`, and `type: "urn:suaveplan:error:export.job_not_ready"` #### Scenario: plain Error falls through to internal 500 - **WHEN** `renderProblem(new Error("boom"))` runs - **THEN** the returned object has `status: 500`, `title: "Error"`, and no `code` field #### Scenario: unknown throwable falls through to internal 500 - **WHEN** `renderProblem("raw-string")` runs - **THEN** the returned object has `status: 500`, `title: "InternalError"`, and `detail: "Internal Server Error"` **Verified by:** `src/routes/problem-json/problem-json.test.ts::"maps SuaveplanError to typed problem-details with status from context"` AND `src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for plain Error"` AND `src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for unknown throwables"` --- ### Requirement: Download OTel instruments `getExportInstruments()` SHALL additionally expose `downloadServed: Counter` (registered as `export.download.served`, labelled `{ format }`) and `downloadFailed: Counter` (registered as `export.download.failed`, labelled `{ reason }`). The download handler SHALL emit exactly one counter increment per request — `downloadServed{format}` on the `ok` branch and `downloadFailed{reason}` on every failure branch with `reason ∈ { "not-found", "not-ready", "provider-not-found", "failed" }`. #### Scenario: served counter increments on happy path - **WHEN** a `200` download response is produced for a `csv` job - **THEN** an in-memory `MeterProvider`'s exporter sees one increment of `export.download.served{format=csv}` #### Scenario: failed counter increments on not-ready - **WHEN** a `409` download response is produced because the job is `processing` - **THEN** the exporter sees one increment of `export.download.failed{reason=not-ready}` **Verified by:** `src/otel/instruments.test.ts::"emits export.download.served on a successful CSV download"` AND `src/otel/instruments.test.ts::"emits export.download.failed{reason=not-ready} when the job is not completed"` --- ### Requirement: Plugin wires `exportJobStore` slot into routes `createExportPlugin(...)` `onReady` SHALL look up `ctx.getService("exportJobStore")`. When the slot resolves to an `ExportJobStore`, the plugin SHALL pass it through to `createExportRoutes({ …, jobStore })` so the download handler reads job state from the persistent backend rather than the in-memory `Map`. When the slot is undefined, the route deps SHALL omit `jobStore` and the download handler SHALL fall back to the inline `deps.jobs` adapter — preserving the existing in-memory route surface for host apps that opt out of persistence. #### Scenario: wired SurrealExportStore is honoured - **WHEN** an app registers `MemoryExportJobStore` (interface-compatible with `SurrealExportStore`) under `ctx.registerService("exportJobStore", store)` and the plugin's `onReady` runs - **THEN** the download handler resolves the job through `store.get(id)` #### Scenario: absent slot keeps in-memory fallback - **WHEN** no `exportJobStore` slot is registered - **THEN** the download handler still resolves jobs through `deps.jobs.get(id)` **Verified by:** `src/plugin/plugin.test.ts::"download route uses exportJobStore service when wired"` AND `src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"`