# Capability Spec Delta: api-plugin-export (add-api-plugin-export) **Package**: `@suaveplan/api-plugin-export` **Status**: SHIPPED ## ADDED Requirements ### Requirement: Plugin descriptor and lifecycle `createExportPlugin(options?)` SHALL return an immutable `PluginDescriptor` named `"export"` at version `"0.1.0"` with `optionalDependencies: ["database", "events"]` and `tags: ["export","data","csv","json","xlsx"]`. Construction-time Zod validation of `options` SHALL surface `ExportError(EXPORT_CODES.INVALID_CONFIG)` carrying the `ZodError` as `cause` on rejection. The `onInit` hook SHALL register `exportProviders: Map` and `exportJobs: Map` on the `ServiceRegistry`. The `onReady` hook SHALL look up the host `app` via `ctx.getService("app")` and mount routes built by `createExportRoutes(...)` at the configured `basePath`. The `onClose` hook SHALL clear both maps. #### Scenario: invalid options reject at construction time - **WHEN** `createExportPlugin({ maxRows: 0 })` is called - **THEN** `ExportError` is thrown with `code === EXPORT.INVALID_CONFIG` and the `ZodError` is attached as `cause` #### Scenario: caller-supplied options flow through to onReady - **WHEN** the registry calls `onReady(ctx)` after `createExportPlugin({ basePath: "/v1/exports", maxRows: 100 })` - **THEN** the routes are mounted at `/v1/exports` and `runExport` against a 101-row provider rejects with `EXPORT.ROW_LIMIT_EXCEEDED` #### Scenario: onClose drains plugin state - **WHEN** `onClose()` is invoked after providers and jobs have been populated - **THEN** both maps are empty **Verified by:** `src/plugin/plugin.test.ts::"createExportPlugin rejects invalid options"` AND `src/plugin/plugin.test.ts::"createExportPlugin honours caller-supplied options through onReady"` AND `src/plugin/plugin.test.ts::"onClose drains providers and jobs"` --- ### Requirement: CSV exporter (RFC 4180) `CsvExporter.export(data, columns, options?)` SHALL produce an `ExportResult` whose `content` is a CSV string conforming to RFC 4180. Field values containing the configured `delimiter`, `"`, `\n`, or `\r` SHALL be wrapped in double quotes with embedded `"` doubled. The header row SHALL be included unless `options.includeHeaders === false`. `Date` instances SHALL serialise via `Date.prototype.toISOString()`; `null`/`undefined` SHALL serialise to the empty string; plain objects SHALL serialise via `JSON.stringify`. #### Scenario: quoting rules - **WHEN** a row contains `'a"b'`, `'a,b'`, and `'a\nb'` - **THEN** each field appears double-quoted with `"` doubled #### Scenario: header opt-out - **WHEN** `options.includeHeaders === false` - **THEN** the output starts with the first data row **Verified by:** `src/exporters/csv/csv.test.ts::"escapes quote / delimiter / newline per RFC 4180"` AND `src/exporters/csv/csv.test.ts::"omits the header row when includeHeaders is false"` --- ### Requirement: JSON exporter `JsonExporter.export(data, columns, options?)` SHALL project each row to a `Record` keyed by `column.header`, applying `column.transform` when present, and return a string from `JSON.stringify(projected, null, options?.pretty ? 2 : undefined)`. The MIME type SHALL be `application/json`. #### Scenario: pretty-print - **WHEN** `options.pretty === true` - **THEN** the output contains a newline immediately after `[` #### Scenario: transform applied - **WHEN** a column declares `transform: (v) => Number(v) * 2` - **THEN** the projected value is the doubled number **Verified by:** `src/exporters/json/json.test.ts::"pretty-prints when options.pretty is true"` AND `src/exporters/json/json.test.ts::"applies column.transform"` --- ### Requirement: XLSX (SpreadsheetML) exporter `XlsxExporter.export(data, columns, options?)` SHALL produce a SpreadsheetML XML document carrying one `worksheet/sheetData` element. Column letters SHALL follow the Excel scheme: index 0 → `A`, 25 → `Z`, 26 → `AA`. Numbers SHALL emit `…`, booleans `0|1`, everything else `…` with XML special characters escaped. The MIME type SHALL be `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`. #### Scenario: column-letter overflow - **WHEN** the 27th column is emitted - **THEN** its cell reference uses the `AA` letter #### Scenario: numeric vs. inline-string cells - **WHEN** a row contains a number `42` and a string `"forty-two"` - **THEN** the numeric cell has no `t="…"` attribute and the string cell has `t="inlineStr"` **Verified by:** `src/exporters/xlsx/xlsx.test.ts::"emits AA for column index 26"` AND `src/exporters/xlsx/xlsx.test.ts::"distinguishes numeric from inline-string cells"` --- ### Requirement: Export service helpers `getExporter(format)` SHALL return a fresh instance of the matching exporter (`csv` / `json` / `xlsx`) and SHALL throw `ExportError(EXPORT_CODES.UNSUPPORTED_FORMAT)` on any other value. `inferColumns(data)` SHALL return one `ColumnDefinition` per distinct key across all rows, in first-seen order, with `key === header`. `runExport(provider, format, columns?, filters?, options?, config?)` SHALL fetch data through `provider.fetch(filters)`, reject with `ExportError(EXPORT_CODES.ROW_LIMIT_EXCEEDED)` when `data.length > config.maxRows`, run the exporter, then reject with `ExportError(EXPORT_CODES.FILE_SIZE_EXCEEDED)` when `result.byteSize > config.maxFileSizeBytes`. #### Scenario: row limit guard - **WHEN** `provider.fetch` resolves with 51 rows and `config.maxRows === 50` - **THEN** `runExport` rejects with `EXPORT.ROW_LIMIT_EXCEEDED` #### Scenario: file-size guard - **WHEN** the exporter result `byteSize` exceeds `config.maxFileSizeBytes` - **THEN** `runExport` rejects with `EXPORT.FILE_SIZE_EXCEEDED` #### Scenario: unsupported format - **WHEN** `getExporter("parquet" as ExportFormat)` is called - **THEN** it throws `EXPORT.UNSUPPORTED_FORMAT` **Verified by:** `src/services/export/export-service.test.ts::"runExport rejects when row count exceeds the configured maximum"` AND `src/services/export/export-service.test.ts::"runExport rejects when result byteSize exceeds the configured maximum"` AND `src/services/export/export-service.test.ts::"getExporter throws on unsupported format"` --- ### Requirement: Export routes `createExportRoutes(deps)` SHALL return an `OpenAPIHono` exposing four routes: - `GET /` SHALL apply `status` / `format` / `resource` filters from `ExportJobListQuerySchema`, then `offset`/`limit` window the in-memory `jobs` map. - `POST /` SHALL look up `deps.providers.get(body.resource)`, return `404 { error }` on miss, record a fresh `ExportJob`, fire `deps.onExportStarted`, run the export through `provider.fetch` → `inferColumns` → `getExporter` → `exporter.export`, enforce row + size caps (`400 { error }` on breach), update the job to `completed`, fire `deps.onExportCompleted`, and respond `200 { job, downloadUrl }`. - `GET /:id` SHALL return the job or `404 { error: "Export job not found" }`. - `DELETE /:id` SHALL remove the job (`200 { success: true }`) or `404 { error }`. All input parsing SHALL flow through `@hono/zod-openapi` validators on `ExportJobListQuerySchema`, `CreateExportJobSchema`, and `z.object({ id: z.string() })`. #### Scenario: POST with missing provider - **WHEN** `POST /` body references a `resource` not in `deps.providers` - **THEN** the response is `404` with `{ error: "No data provider registered for resource: " }` #### Scenario: POST honours row + size caps - **WHEN** the provider returns 51 rows and `exportConfig.maxRows === 50` - **THEN** the response is `400` and the job ends in `status: "failed"` with a row-limit error message #### Scenario: GET list filter + pagination - **WHEN** three jobs exist (two `completed`, one `failed`) and the query is `?status=completed&limit=1` - **THEN** the response carries `total: 2` and `items.length === 1` #### Scenario: DELETE missing job - **WHEN** `DELETE /missing-id` is called and the id is absent - **THEN** the response is `404 { error: "Export job not found" }` **Verified by:** `src/routes/exports/exports.test.ts::"POST /exports returns 404 when no provider is registered"` AND `src/routes/exports/exports.test.ts::"POST /exports rejects when row count exceeds the configured maximum"` AND `src/routes/exports/exports.test.ts::"GET /exports applies status filter and pagination"` AND `src/routes/exports/exports.test.ts::"DELETE /exports/{id} returns 404 when the job is missing"` --- ### Requirement: Error-code registry `EXPORT_CODES` SHALL be built via `defineCodes("EXPORT", […])` and include exactly these codes: `INVALID_CONFIG`, `PROVIDER_NOT_FOUND`, `ROW_LIMIT_EXCEEDED`, `FILE_SIZE_EXCEEDED`, `UNSUPPORTED_FORMAT`, `PROVIDER_FETCH_FAILED`, `JOB_NOT_FOUND`. `ExportError` SHALL extend `SuaveplanError` so `error.code` narrows to the union and `instanceof ExportError` narrows by class. Every code in the registry SHALL be thrown by at least one production code path (pinned by `api-plugin-export.spec-conformance.test.ts`). #### Scenario: code narrowing - **WHEN** `throw new ExportError(EXPORT_CODES.ROW_LIMIT_EXCEEDED, "…")` is caught - **THEN** `error.code === "EXPORT.ROW_LIMIT_EXCEEDED"` and `error.exportCode` narrows to the union #### Scenario: spec-conformance pin - **WHEN** the conformance test enumerates `EXPORT_CODES` - **THEN** every value is referenced from at least one non-test source file **Verified by:** `src/error-codes/error-codes.test.ts::"ExportError narrows code via the public type union"` AND `src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"` --- ### Requirement: OpenTelemetry instruments `getExportInstruments()` SHALL lazily resolve a `Meter` named `@suaveplan/api-plugin-export` from the global `MeterProvider` and return a cached bundle of four instruments: `jobsTotal: Counter` (with `outcome: "completed" | "failed"`), `rowsTotal: Counter`, `bytesTotal: Counter`, `durationMs: Histogram`. `resetExportInstruments()` SHALL drop the cache so the next call rebinds against the current global provider. The production POST route SHALL emit `jobsTotal` on every job outcome and `rowsTotal` / `bytesTotal` / `durationMs` on every `completed` outcome. #### Scenario: completed-job metrics emitted - **WHEN** a successful export completes - **THEN** an in-memory `MeterProvider`'s exporter sees one increment of `export.jobs_total{outcome=completed}` plus the row/byte/duration metrics #### Scenario: failed-job metric emitted on row-limit breach - **WHEN** an export exceeds `maxRows` - **THEN** the exporter sees one increment of `export.jobs_total{outcome=failed}` **Verified by:** `src/otel/instruments.test.ts::"emits completed-job metrics through an in-memory MeterProvider"` AND `src/otel/instruments.test.ts::"emits failed-job metric on row-limit breach"` --- ### Requirement: Public surface and side-effect imports The package root barrel SHALL import `./error-codes/error-codes.js` and `./services/registry-augmentation.js` for their side effects, and SHALL re-export every public symbol by name (no `export *` in any feature-module barrel). The barrel SHALL additionally re-export `ExportJobStore` (type), `MemoryExportJobStore`, `SurrealExportStore`, `SurrealExportStoreConfig` (type), and the `JOB_PERSISTENCE_FAILED` member of `EXPORT_CODES`. `package.json` SHALL declare `sideEffects: false` and ship subpath exports for `./plugin/plugin`, `./services/export/export-service`, `./routes/exports/exports`, `./exporters/csv/csv`, `./exporters/json/json`, `./exporters/xlsx/xlsx`, `./error-codes/error-codes`, `./otel/instruments`, `./types/types`, `./store/types/types`, `./store/memory/memory-store`, `./store/surreal/surreal-store`. The `files` array SHALL include `"surql"` so the migration ships to npm. #### Scenario: side-effect imports present - **WHEN** `src/index.ts` is read - **THEN** the file contains both `./error-codes/error-codes.js` and `./services/registry-augmentation.js` import statements #### Scenario: no `export *` in feature barrels - **WHEN** `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" src/` is run against the package - **THEN** no matches are found #### Scenario: store subpath exports resolve - **WHEN** a consumer imports from `@suaveplan/api-plugin-export/store/surreal/surreal-store` - **THEN** the import resolves to a module exporting `SurrealExportStore` **Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"side-effect imports of error-codes and registry-augmentation are present in index.ts"` AND `src/api-plugin-export.spec-conformance.test.ts::"store subpath exports resolve from package.json"` --- ### Requirement: Export job store contract (`ExportJobStore`) The package SHALL expose an `ExportJobStore` interface defining the persistence contract for export jobs. The interface SHALL declare async methods `get(id)`, `set(id, job)`, `delete(id)`, `list(query?)`, `has(id)`, and `clear()`. `list(query?)` SHALL accept an `ExportJobStoreListQuery` carrying optional `status`, `format`, `resource` filters and required `limit`, `offset` pagination, returning `{ items: ExportJob[]; total: number }`. The interface SHALL be runtime-free (a type-only export) so consumers can implement it without pulling in any backend dependency. #### Scenario: interface members - **WHEN** `ExportJobStore` is consumed as a TypeScript type - **THEN** it declares the six async methods above with the documented signatures **Verified by:** `src/store/memory/memory-store.test.ts::"MemoryExportJobStore satisfies the ExportJobStore contract"` --- ### Requirement: In-memory backend (`MemoryExportJobStore`) `MemoryExportJobStore` SHALL implement `ExportJobStore` over an internal `Map`. Every method SHALL resolve synchronously-derived results through a `Promise`. `list({ status, format, resource, limit, offset })` SHALL apply each provided filter as an AND-conjunction over the in-memory map values, then window with `offset`/`limit`, returning `total` as the pre-window match count. #### Scenario: round-trip a job - **WHEN** `set("a", job)` is called and then `get("a")` - **THEN** the resolved job deep-equals the input #### Scenario: list filter conjunction - **WHEN** three jobs differ on `status` / `format` and the query is `{ status: "completed", format: "csv", limit: 10, offset: 0 }` - **THEN** only the matching job appears in `items` and `total === 1` #### Scenario: pagination - **WHEN** five jobs are stored and the query is `{ limit: 2, offset: 2 }` - **THEN** `items.length === 2` and `total === 5` #### Scenario: clear drains the store - **WHEN** `clear()` is called after jobs are stored - **THEN** `list()` resolves with `{ items: [], total: 0 }` **Verified by:** `src/store/memory/memory-store.test.ts::"set + get round-trips a job"` AND `src/store/memory/memory-store.test.ts::"list applies AND-conjunction filters"` AND `src/store/memory/memory-store.test.ts::"list paginates with offset and limit"` AND `src/store/memory/memory-store.test.ts::"clear empties the store"` --- ### Requirement: SurrealDB backend (`SurrealExportStore`) `SurrealExportStore` SHALL implement `ExportJobStore` over a SurrealDB `export_job` table reached through `@suaveplan/surrealdb` `SurrealAdapter`. All SurQL SHALL use positional `$p0…$pN` placeholders (genesis-canonical pattern; the adapter rewrites positional arrays into named bindings). The constructor SHALL accept a Zod-validated `SurrealExportStoreConfig` carrying `db: SurrealAdapter` and an optional `tableName: string` defaulting to `"export_job"`. Every method SHALL wrap underlying driver / network / parse failures in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` with the original error chained as `cause`. `set(id, job)` SHALL upsert the row via `UPSERT type::record($p0, $p1) CONTENT $p2 RETURN AFTER`. `delete(id)` SHALL be idempotent — deleting a missing row SHALL resolve without throwing. `list({ status, format, resource, limit, offset })` SHALL emit a `SELECT * FROM ` with an AND-conjunction `WHERE` clause and a parallel `SELECT count() AS count FROM GROUP ALL` (also filtered) to derive `total` in a single round-trip latency via `Promise.all`. #### Scenario: round-trip a job through SurrealDB - **WHEN** `set("abc", job)` resolves and then `get("abc")` resolves - **THEN** the resolved job carries the same `format`, `resource`, `status`, `createdAt` as the input #### Scenario: list with filters - **WHEN** three rows are seeded (two `completed`, one `failed`) and `list({ status: "completed", limit: 10, offset: 0 })` is called - **THEN** `items.length === 2` and `total === 2` #### Scenario: delete idempotency - **WHEN** `delete("missing-id")` is called against an empty table - **THEN** the call resolves with no error #### Scenario: driver failure surfaces JOB_PERSISTENCE_FAILED - **WHEN** the underlying adapter `query(...)` rejects (e.g. connection drop, schema mismatch) - **THEN** the call rejects with `ExportError` whose `code === "EXPORT.JOB_PERSISTENCE_FAILED"` and whose `cause` is the underlying error #### Scenario: invalid config rejects at construction time - **WHEN** `new SurrealExportStore({})` is called without a `db` adapter - **THEN** `ExportError` is thrown with `code === "EXPORT.INVALID_CONFIG"` and the `ZodError` is attached as `cause` **Verified by:** `tests/integration/surreal-store.test.ts::"set + get round-trips a job through SurrealDB"` AND `tests/integration/surreal-store.test.ts::"list applies status filter and pagination through SurrealDB"` AND `tests/integration/surreal-store.test.ts::"delete on a missing row is idempotent"` AND `src/store/surreal/surreal-store.test.ts::"driver failure surfaces JOB_PERSISTENCE_FAILED"` AND `src/store/surreal/surreal-store.test.ts::"invalid config rejects with INVALID_CONFIG"` --- ### Requirement: `JOB_PERSISTENCE_FAILED` error code `EXPORT_CODES` SHALL include the additional code `JOB_PERSISTENCE_FAILED`. The code SHALL be thrown from at least one production path — the `SurrealExportStore` driver-failure branch — and SHALL be pinned by `api-plugin-export.spec-conformance.test.ts`. #### Scenario: registry membership - **WHEN** `Object.values(EXPORT_CODES)` is enumerated - **THEN** the result contains `"EXPORT.JOB_PERSISTENCE_FAILED"` #### Scenario: conformance pin - **WHEN** the spec-conformance suite walks the registry against `src/**/*.ts` non-test files - **THEN** `JOB_PERSISTENCE_FAILED` is present in `src/store/surreal/surreal-store.ts` **Verified by:** `src/error-codes/error-codes.test.ts::"JOB_PERSISTENCE_FAILED 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: SurrealDB migration (`export_job.surql`) The package SHALL ship `surql/tables/export_job.surql` defining the `export_job` table as `SCHEMAFULL` with one `DEFINE FIELD OVERWRITE` per `ExportJobSchema` property (`format`, `resource`, `status`, `filters`, `filename`, `totalRows`, `exportedRows`, `fileSize`, `error`, `createdAt`, `completedAt`) and indexes on `(status)`, `(format)`, `(resource)`, `(createdAt)`. The migration SHALL be idempotent (`DEFINE TABLE OVERWRITE`, `DEFINE FIELD OVERWRITE`, `DEFINE INDEX OVERWRITE`) so consecutive boots succeed. #### Scenario: migration applies cleanly - **WHEN** the migration is loaded twice against a fresh SurrealDB testcontainer - **THEN** both applies succeed without error **Verified by:** `tests/integration/surreal-store.test.ts::"export_job migration is idempotent"` --- ### 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=""`. #### 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"`