# Capability Spec Delta: api-plugin-export (add-api-plugin-export) **Package**: `@suaveplan/api-plugin-export` **Status**: PROPOSED ## 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). `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`. #### 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 **Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"side-effect imports of error-codes and registry-augmentation are present in index.ts"`