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,78 @@
# Design — add-export-download-handler
## Goal
Close the deferred `GET /:id/download` endpoint without expanding scope into §4 streaming /
S3 / signed-URL work. The handler reads the persisted job, re-runs the registered
`DataProvider`, re-renders through the existing writer, and streams the result back. Errors
flow through a per-package RFC 7807 problem-details renderer so consumers branch on the
existing `EXPORT.*` codes.
## File layout
```
packages/api/api-plugin-export/src/
error-codes/
error-codes.ts ← add JOB_NOT_READY
otel/
instruments.ts ← add downloadServed / downloadFailed
routes/
download/
download.ts ← runDownloadExport(deps, jobId)
download.md
download.test.ts
exports/
exports.ts ← extend deps + mount /:id/download
problem-json/
problem-json.ts ← RFC 7807 renderer (per-package)
problem-json.md
problem-json.test.ts
plugin/
plugin.ts ← thread exportJobStore through to routes
```
## Discriminated outcome
`runDownloadExport(...)` returns a union so the route registration call site stays free of
`c.json(...)` typing. The four failure variants each carry the data the renderer needs:
```ts
type DownloadOutcome =
| { kind: "ok"; body: string; contentType: string; filename: string; format: ExportFormat }
| { kind: "not-found"; id: string }
| { kind: "not-ready"; id: string; status: ExportStatus }
| { kind: "provider-not-found"; resource: string }
| { kind: "failed"; reason: string; cause: unknown };
```
Every branch throws / instantiates an `ExportError` so the spec-conformance pin keeps every
`EXPORT_*` code reachable from a production path.
## `jobStore` injection vs the in-memory `jobs` Map
`ExportRoutesDeps.jobStore?` is added as an optional dependency. When absent, the download
handler builds an inline `ExportJobStore` adapter over `deps.jobs` so the route works in
lightweight tests and host apps that opt out of SurrealDB. When present, the handler routes
all reads through the store — consistent with the persistence narrative in
`2026-05-19-wire-surreal-export-store`.
## OTel labels
- `export.download.served{format}` — labelled with `format: csv | json | xlsx`.
- `export.download.failed{reason}` — labelled with `reason: not-found | not-ready | provider-not-found | failed`.
Tests assert via `flushTestMetrics()` against the existing in-memory `MeterProvider` in
`tests/setup/polyfills.ts`.
## Status-code mapping
| Outcome | HTTP | Code |
|---|---|---|
| `ok` | 200 | n/a |
| `not-found` | 404 | `EXPORT.JOB_NOT_FOUND` |
| `not-ready` | 409 | `EXPORT.JOB_NOT_READY` |
| `provider-not-found` | 404 | `EXPORT.PROVIDER_NOT_FOUND` |
| `failed` | 502 | `EXPORT.PROVIDER_FETCH_FAILED` |
The mapping lives in the per-package `problem-json` renderer via `context.statusCode`
attached at throw time.

View File

@@ -0,0 +1,77 @@
# 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. |

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

View File

@@ -0,0 +1,63 @@
# Tasks — add-export-download-handler
## 1. Error codes
- [ ] Add `JOB_NOT_READY` to `EXPORT_CODES` registry in `src/error-codes/error-codes.ts`
- [ ] Update the JSDoc registry summary to describe `JOB_NOT_READY` semantics (raised by download handler when job is not in `completed` state)
- [ ] Update `src/error-codes/error-codes.test.ts` to assert `JOB_NOT_READY` narrows through the public type union
## 2. Problem-details renderer
- [ ] Author `src/routes/problem-json/problem-json.ts` mirroring the `api-plugin-import` sibling (`PROBLEM_JSON_CONTENT_TYPE`, `ProblemDetails`, `renderProblem`, `respondProblemJson`)
- [ ] Map `SuaveplanError` → `status` via `context.statusCode` (404 for `JOB_NOT_FOUND`, 409 for `JOB_NOT_READY`, 400 for everything else under 500)
- [ ] Author `src/routes/problem-json/problem-json.test.ts` covering: SuaveplanError mapping, plain Error fallback, unknown throwable fallback, missing `statusCode` defaults to 500
- [ ] Author `src/routes/problem-json/problem-json.md` (≥200 words, Purpose / Features / Usage Basic / Usage Advanced / API Reference / Implementation Notes)
- [ ] Add `./routes/problem-json/problem-json` subpath export to `package.json`
- [ ] Re-export `renderProblem`, `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`, `ProblemDetails` from `src/index.ts`
## 3. OTel instruments
- [ ] Extend `ExportInstruments` in `src/otel/instruments.ts` with `downloadServed: Counter` and `downloadFailed: Counter`
- [ ] Register the two counters via `meter.createCounter("export.download.served", …)` and `meter.createCounter("export.download.failed", …)`
- [ ] Update `src/otel/instruments.md` with the two new instrument descriptions
- [ ] Extend `src/otel/instruments.test.ts` with: "emits download.served on a successful download" and "emits download.failed{reason} on a not-ready download"
## 4. Download handler
- [ ] Create `src/routes/download/download.ts` exporting `runDownloadExport(deps, jobId)` returning a discriminated `DownloadOutcome` union (`{ kind: "ok"; body; contentType; filename }` / `{ kind: "not-found" }` / `{ kind: "not-ready"; status }` / `{ kind: "provider-not-found"; resource }` / `{ kind: "failed"; error }`)
- [ ] Implementation flow: `jobStore.get(id)` → `null` ⇒ `not-found` raising `EXPORT.JOB_NOT_FOUND`; `status !== "completed"` ⇒ `not-ready` raising `EXPORT.JOB_NOT_READY`; `providers.get(job.resource)` → `null` ⇒ `provider-not-found` raising `EXPORT.PROVIDER_NOT_FOUND`; on any exception during fetch/render emit `EXPORT.PROVIDER_FETCH_FAILED`
- [ ] Author `src/routes/download/download.md` (≥200 words)
- [ ] Author `src/routes/download/download.test.ts` covering every branch with real CSV / JSON / XLSX renders
## 5. Route registration
- [ ] Extend `ExportRoutesDeps` in `src/routes/exports/exports.ts` with optional `jobStore?: ExportJobStore`
- [ ] Mount `GET /{id}/download` after the existing `GET /{id}` route; build the inline `Map<string, ExportJob>` adapter when `jobStore` is absent
- [ ] Wire the download outcome through `respondProblemJson` for every non-ok branch
- [ ] OpenAPI route definition: `params: z.object({ id: z.string() })`, `responses`: `200` (binary), `404`, `409`, `502`, all carrying `application/problem+json` schema for the failure shapes
- [ ] Update `src/routes/exports/exports.md` with a paragraph on the new endpoint
- [ ] Extend `src/routes/exports/exports.test.ts` with an end-to-end happy-path download + a 404 + a 409 + a 502 test using a real provider + real `MemoryExportJobStore`
## 6. Plugin wiring
- [ ] In `src/plugin/plugin.ts` `runOnReady`, look up `ctx.getService("exportJobStore")` and thread it into `createExportRoutes({ …, jobStore })`. Absent slot ⇒ omit; existing behaviour preserved
- [ ] Extend `src/plugin/plugin.test.ts` with "download route uses exportJobStore service when wired" using a `MemoryExportJobStore`
## 7. Spec conformance
- [ ] `src/api-plugin-export.spec-conformance.test.ts` already pins every `EXPORT_*` code; verify `JOB_NOT_READY` is thrown by the download handler so the pin holds
- [ ] Run `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-export` — expect zero errors
## 8. Pre-archive gates
- [ ] `bunx biome check src/` clean
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-export` green
- [ ] `bunx turbo test:coverage --filter=@suaveplan/api-plugin-export` ≥100/100/100/100 on touched modules
- [ ] `bunx turbo build --filter=@suaveplan/api-plugin-export` green
- [ ] `bun run docs:check` (package-scoped, via `package.json` script) clean
## 9. Archive
- [ ] Fold `specs/api-plugin-export/spec.md` delta into `openspec/specs/api-plugin-export/spec.md`
- [ ] Move `openspec/changes/add-export-download-handler` → `openspec/archive/2026-05-20-add-export-download-handler`
- [ ] Single commit `feat(api-plugin-export): add GET /:id/download handler (was §4-deferred)` and push (pre-pull-rebase autostash)