Files
2026-05-27 23:29:53 +01:00

78 lines
5.3 KiB
Markdown

# 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="<job.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<string, unknown>[]`. |
| 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. |