Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
<!-- markdownlint-disable MD024 -->
# 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"`