first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# `routes/download` — `GET /:id/download` handler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `GET /:id/download` route on `@suaveplan/api-plugin-export`. The handler
serves the rendered file body for a previously completed export job. The endpoint closes
the deferral noted in `archive/2026-05-19-add-api-plugin-export/proposal.md` §"Out of
scope" — the `POST /exports` response already returned `downloadUrl: "/{jobId}/download"`
for forward compatibility, and this module finally resolves that URL to a real response.
The handler is decoupled from Hono via the discriminated `DownloadOutcome` union: the
route registration call site in `routes/exports/exports.ts` calls `runDownloadExport(...)`,
branches on `outcome.kind`, and either streams the body or passes the carried `ExportError`
to the per-package RFC 7807 renderer. Every failure branch raises one of the existing
`EXPORT_*` codes so the spec-conformance pin (`api-plugin-export.spec-conformance.test.ts`)
keeps the registry fully exercised from a production path.
## Features
- Reads the job from the persistent `ExportJobStore` slot when wired
(`SurrealExportStore` / `MemoryExportJobStore`) or falls back to the in-memory
`Map<string, ExportJob>` shipped on `ExportRoutesDeps.jobs`.
- Validates the job is in `status: "completed"` — any other status raises
`EXPORT.JOB_NOT_READY` (HTTP `409`).
- Re-fetches the source rows through the registered `DataProvider`, passing `job.filters`
exactly as supplied to the original `POST /exports` call so the export is idempotent
against the current provider state.
- Re-renders through the existing `getExporter(format)` writer — no new format work, no
body caching. The `Content-Type` is taken from the writer's `mimeType` field.
- Emits `Content-Disposition: attachment; filename="<job.filename>"` with the filename
sanitised per RFC 6266 §4.1 (quotes / backslashes escaped, control characters replaced
with `_`).
- OTel: increments `export.download.served{format}` on success and
`export.download.failed{reason}` (`not-found` / `not-ready` / `provider-not-found` /
`failed`) on every failure branch.
## Usage: Basic
The function is consumed by the route registration in `routes/exports/exports.ts`:
```ts
import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download";
app.get("/:id/download", async (c) => {
const { id } = c.req.param();
const outcome = await runDownloadExport(deps, id);
if (outcome.kind === "ok") {
c.header("content-type", outcome.contentType);
c.header(
"content-disposition",
buildContentDisposition(outcome.filename)
);
return c.body(outcome.body, 200);
}
return respondProblemJson(c, outcome.error);
});
```
## Usage: Advanced
The handler can be driven directly outside Hono — e.g. from a CLI or worker entrypoint —
because the `DownloadDeps` interface only requires three plain fields:
```ts
import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download";
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[jobId, job]]),
jobs: new Map(),
providers: new Map([
[
"users",
{
resource: "users",
fetch: async () => [{ id: 1 }],
},
],
]),
},
jobId
);
if (out.kind === "ok") {
await Bun.write("out.csv", out.body);
}
```
When `jobStore` is omitted, the handler reads the job from the in-memory `deps.jobs` map
— the existing slot wired on `ExportRoutesDeps` since the initial plugin ship.
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `DownloadDeps` | `interface` | `{ jobStore?; jobs; providers }` |
| `DownloadOutcome` | discriminated union | `ok` / `not-found` / `not-ready` / `provider-not-found` / `failed` |
| `runDownloadExport(deps, id)` | async function | Returns `Promise<DownloadOutcome>` |
| `buildContentDisposition(filename)` | function | RFC 6266 §4.1-compliant string |
## Implementation Notes
- The handler never throws — every failure becomes an `ExportError` carried on the
`DownloadOutcome.error` field. This decouples the response shape from the discriminated
outcome and keeps the route layer free of `try { … } catch { … }` blocks.
- `context.statusCode` is set on every `ExportError` so the per-package problem-details
renderer can map directly to the right HTTP code without a separate code-to-status table.
- `inferColumns(data)` is used unconditionally on download. The original column projection
is not persisted on the `ExportJob` shape — re-inferring keeps the download
deterministic against the current provider response without bloating the persisted job.
- The handler is intentionally idempotent against the current provider state: a fresh
download after the source rows change reflects the change. This matches RFC 7231 §4.2.2
"idempotent methods" for `GET` and avoids the cache-invalidation problem a body-caching
scheme would introduce.
- `buildContentDisposition` strips `[0x00-0x1f]` plus `0x7f` rather than emitting them
literally — these are forbidden in HTTP header values per RFC 7230 §3.2.6.