8.7 KiB
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="<job.filename>".
Scenario: happy path streams CSV body
- WHEN a
completedjob for theusersresource exists, the provider returns one row, and aGET /{id}/downloadrequest is issued - THEN the response is
200, theContent-Typeistext/csv, theContent-Dispositioncarriesattachment; filename="…", and the body parses as RFC 4180 CSV
Scenario: missing job returns 404 problem-details
- WHEN
GET /missing/downloadis issued and no job is stored undermissing - THEN the response is
404, theContent-Typeisapplication/problem+json, and the body carries{ code: "EXPORT.JOB_NOT_FOUND" }
Scenario: not-ready job returns 409 problem-details
- WHEN
GET /pending-job/downloadis issued and the stored job is instatus: "processing" - THEN the response is
409, theContent-Typeisapplication/problem+json, and the body carries{ code: "EXPORT.JOB_NOT_READY" }
Scenario: missing provider returns 404 problem-details
- WHEN
GET /{id}/downloadis issued for a job whoseresourcehas no entry indeps.providers - THEN the response is
404with{ code: "EXPORT.PROVIDER_NOT_FOUND" }
Scenario: provider failure returns 502 problem-details
- WHEN
provider.fetchrejects during aGET /{id}/downloadrequest - THEN the response is
502with{ 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"anderror.exportCodenarrows 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", andtype: "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 nocodefield
Scenario: unknown throwable falls through to internal 500
- WHEN
renderProblem("raw-string")runs - THEN the returned object has
status: 500,title: "InternalError", anddetail: "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
200download response is produced for acsvjob - THEN an in-memory
MeterProvider's exporter sees one increment ofexport.download.served{format=csv}
Scenario: failed counter increments on not-ready
- WHEN a
409download response is produced because the job isprocessing - 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 withSurrealExportStore) underctx.registerService("exportJobStore", store)and the plugin'sonReadyruns - THEN the download handler resolves the job through
store.get(id)
Scenario: absent slot keeps in-memory fallback
- WHEN no
exportJobStoreslot 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"