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

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/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.