Files
suaveplan/openspec/archive/2026-05-20-add-export-download-handler/specs/api-plugin-export/spec.md
2026-05-27 23:29:53 +01:00

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 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"