Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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. |
|
||||
@@ -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"`
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user