5.3 KiB
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/downloadroute oncreateExportRoutes(...). Looks up the job viaExportJobStore.get(id)(preferred, when a store is wired) ordeps.jobs.get(id)(in-memory fallback for tests / lightweight deployments). Validatesstatus === "completed". Re-fetches source rows through the registeredDataProvider. Re-renders the body via the existinggetExporter(format)writer. Returns the body withContent-Typefrom the writer result andContent-Disposition: attachment; filename="<job.filename>". - ADDED:
ExportRoutesDeps.jobStore?: ExportJobStore— optional injection slot so consumers can passMemoryExportJobStoreorSurrealExportStoredirectly. When omitted, the download handler wrapsdeps.jobsin 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 incompletedstate (i.e.pending,processing, orfailed). Surfaced as HTTP409 Conflictvia 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-importand@suaveplan/api-plugin-databasesiblings — 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 }) andexport.download.failed(Counter, labelled{ reason }). ExtendedExportInstruments, emitted on every download outcome. - WIRED:
createExportPlugin(...) → onReadynow resolvesctx.getService("exportJobStore")(if present) and threads it into the route deps asjobStore. The slot was declared at2026-05-19-wire-surreal-export-storebut had no consumer until now.
Affected dependents
- Direct consumers: none on-tree yet — the plugin is consumed by
api-hostapps that mountcreateExportPlugin()and register aDataProvider. The new route is additive (no existing status code or response body changes), so no consumer adjustment is required. - Downstream packages:
@suaveplan/api-plugin-hostapps that wireSurrealExportStoreintoctx.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. |