# Capability Spec Delta: api-plugin-export (add-api-plugin-export) **Package**: `@suaveplan/api-plugin-export` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Plugin descriptor factory The package SHALL expose `createExportPlugin(options?: Partial): PluginDescriptor`, returning a `definePlugin(...)`-shaped descriptor named `"export"`, version `0.1.0`, with `optionalDependencies: ["database", "events"]`, `tags: ["export","data","csv","json","xlsx"]`, a `configSchema` bound to `ExportPluginConfigSchema`, and `defaults` merged from caller-supplied input + `DEFAULT_EXPORT_CONFIG`. The four lifecycle hooks (`onInit`, `onReady`, `onClose`) MUST be present and typed against `PluginContext`. #### Scenario: descriptor metadata is correct - **WHEN** `createExportPlugin()` is called with no arguments - **THEN** the returned descriptor has `name === "export"`, `version === "0.1.0"`, `tags` contains `"csv"`/`"json"`/`"xlsx"`, and `optionalDependencies` contains `"database"` and `"events"` #### Scenario: factory-supplied options merge with defaults - **WHEN** `createExportPlugin({ basePath: "/api/data-export" })` is called - **THEN** `descriptor.defaults.basePath === "/api/data-export"` AND `descriptor.defaults.maxRows === 50000` **Verified by:** `src/plugin/plugin.test.ts::"returns a plugin descriptor with the expected metadata"` AND `src/plugin/plugin.test.ts::"merges factory-supplied options with DEFAULT_EXPORT_CONFIG"` --- ### Requirement: onInit registers services and emits event When `onInit(ctx)` runs, the plugin SHALL register `exportProviders` (a `Map`) and `exportJobs` (a `Map`) on `ctx.registerService`, then emit `"export:initialized"` on `ctx.eventBus`. #### Scenario: services and event after init - **WHEN** the descriptor's `onInit` is invoked with a real `PluginContext` - **THEN** `ctx.getService("exportProviders")` returns a `Map`, `ctx.getService("exportJobs")` returns a `Map`, and the bus has received `"export:initialized"` **Verified by:** `src/plugin/plugin.test.ts::"onInit registers exportProviders and exportJobs and emits export:initialized"` --- ### Requirement: onReady mounts routes on the host app `onReady(ctx)` SHALL resolve `app` from `ctx.getService("app")`, build the route group via `createExportRoutes({ providers, exportConfig, jobs, onExportStarted, onExportCompleted })`, mount it via `app.route(basePath, routes)`, then emit `"export:ready"`. If `app` is not present on the registry the hook SHALL throw `ExportError(EXPORT_CODES.PROVIDER_NOT_FOUND)` is NOT the right code — the hook SHALL throw a `ExportError` carrying code `EXPORT_CODES.UNSUPPORTED_FORMAT`-style precondition? No — the hook SHALL throw `ExportError` carrying a clear message naming the missing `app` slot. #### Scenario: routes mount at configured basePath - **WHEN** `onReady` runs against a `PluginContext` whose `ServiceRegistry` carries an `OpenAPIHono` at `app` and the config's `basePath === "/exports"` - **THEN** `app.route` has been invoked with `"/exports"` and an `OpenAPIHono` instance, AND `"export:ready"` has been emitted #### Scenario: missing app slot fails fast - **WHEN** `onReady` runs against a context with no `app` service registered - **THEN** the hook throws an `Error` (or `ExportError`) whose message names `"app"` **Verified by:** `src/plugin/plugin.test.ts::"onReady mounts routes on the host app and emits export:ready"` AND `src/plugin/plugin.test.ts::"onReady throws when no app service is registered"` --- ### Requirement: onClose clears in-process state `onClose()` SHALL clear both the `providers` and `jobs` maps held by the plugin closure so the next boot starts from an empty state. #### Scenario: maps are cleared - **WHEN** the descriptor's `onClose` is invoked after `onInit` populated state - **THEN** the same map instances passed to `onInit` are `.size === 0` **Verified by:** `src/plugin/plugin.test.ts::"onClose clears providers and jobs maps"` --- ### Requirement: registerDataProvider helper The package SHALL export `registerDataProvider(providers: Map, provider: DataProvider): void` that sets `providers.set(provider.resource, provider)` (last-write-wins). #### Scenario: register and overwrite - **WHEN** the same `resource` is registered twice with different `DataProvider` values - **THEN** the second registration replaces the first **Verified by:** `src/plugin/plugin.test.ts::"registerDataProvider stores the provider keyed by its resource"` AND `src/plugin/plugin.test.ts::"registerDataProvider overwrites an existing provider for the same resource"` --- ### Requirement: Zod-validated plugin config `ExportPluginConfigSchema` SHALL Zod-validate `{ basePath: string default "/exports", maxRows: int 1..1_000_000 default 50_000, maxFileSizeBytes: int >=1024 default 52_428_800, defaultFormat: "csv"|"json"|"xlsx" default "csv" }`. `ExportPluginConfig = z.infer`. #### Scenario: empty input fills all defaults - **WHEN** `ExportPluginConfigSchema.safeParse({})` runs - **THEN** the result is `{ success: true, data: { basePath: "/exports", maxRows: 50000, maxFileSizeBytes: 52428800, defaultFormat: "csv" } }` #### Scenario: invalid bounds rejected - **WHEN** `ExportPluginConfigSchema.safeParse({ maxRows: 0 })`, `({ maxRows: 2_000_000 })`, `({ defaultFormat: "pdf" })`, or `({ maxFileSizeBytes: 100 })` run - **THEN** each result is `{ success: false }` **Verified by:** `src/schemas/schemas.test.ts::"ExportPluginConfigSchema fills every default for empty input"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects out-of-range maxRows"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects maxRows above the 1_000_000 ceiling"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects an invalid defaultFormat"` AND `src/schemas/schemas.test.ts::"ExportPluginConfigSchema rejects maxFileSizeBytes below 1024"` --- ### Requirement: Domain Zod schemas The package SHALL export Zod schemas `ExportFormatSchema` (`"csv"|"json"|"xlsx"`), `ExportStatusSchema` (`"pending"|"processing"|"completed"|"failed"`), `ColumnDefinitionSchema` (non-empty `key` + non-empty `header` + optional `transform`), `CreateExportJobSchema` (format + resource[1..128] + optional filters/columns(≥1)/filename(≤256)/options), `ExportJobSchema` (id + format + resource + status + filters? + filename? + nullable totalRows/exportedRows/fileSize/error + createdAt + completedAt nullable), `ExportJobListQuerySchema` (status? + format? + resource? + limit 1..100 default 20 + offset ≥0 default 0). #### Scenario: rejects empty resource and out-of-range limit - **WHEN** `CreateExportJobSchema.safeParse({ format:"csv", resource:"" })` and `ExportJobListQuerySchema.safeParse({ limit: 101 })` run - **THEN** both results are `{ success: false }` **Verified by:** `src/schemas/schemas.test.ts::"CreateExportJobSchema rejects an empty resource"` AND `src/schemas/schemas.test.ts::"ExportJobListQuerySchema rejects limit above 100"` AND `src/schemas/schemas.test.ts::"ColumnDefinitionSchema rejects an empty header"` AND `src/schemas/schemas.test.ts::"ExportJobSchema accepts a completed job with all fields populated"` --- ### Requirement: CSV exporter — RFC 4180 `CsvExporter.export(data, columns, options?)` SHALL produce a `text/csv` `ExportResult` whose `content` quotes any field containing the configured delimiter, a literal `"`, `\n`, or `\r`, doubling embedded `"` per RFC 4180. The header row is emitted unless `options.includeHeaders === false`. The default delimiter is `,`; `options.delimiter` overrides it. #### Scenario: quoting and delimiter - **WHEN** `escapeCsvField('say "hi"', ",")` runs - **THEN** the return value equals `'"say ""hi"""'` #### Scenario: convertToCsv emits header then rows - **WHEN** `convertToCsv([{id:1,name:"Alice"}], [{key:"id",header:"ID"},{key:"name",header:"Name"}])` runs - **THEN** the return value equals `"ID,Name\n1,Alice"` **Verified by:** `src/exporters/csv/csv.test.ts::"escapeCsvField doubles embedded double-quotes per RFC 4180"` AND `src/exporters/csv/csv.test.ts::"escapeCsvField quotes a value containing the delimiter"` AND `src/exporters/csv/csv.test.ts::"convertToCsv emits a header row followed by data rows"` AND `src/exporters/csv/csv.test.ts::"convertToCsv applies column transforms before escaping"` AND `src/exporters/csv/csv.test.ts::"CsvExporter.export returns text/csv with a non-zero byteSize"` --- ### Requirement: JSON exporter `JsonExporter.export(data, columns, options?)` SHALL produce an `application/json` `ExportResult` whose `content` is `JSON.stringify` of each row projected through `projectRow(row, columns)` (`col.header` as key, `col.transform(row[col.key])` as value when transform is set, raw `row[col.key]` otherwise). `options.pretty === true` SHALL use 2-space indentation. #### Scenario: projection and pretty-print - **WHEN** `convertToJson([{id:1,name:"Alice"}], [{key:"name",header:"Full Name"}], { pretty: true })` runs - **THEN** the parsed result equals `[{"Full Name":"Alice"}]` AND the raw string contains a `\n` **Verified by:** `src/exporters/json/json.test.ts::"projectRow rekeys using column.header"` AND `src/exporters/json/json.test.ts::"projectRow applies column.transform when present"` AND `src/exporters/json/json.test.ts::"convertToJson produces compact output by default"` AND `src/exporters/json/json.test.ts::"convertToJson produces pretty output when options.pretty is true"` AND `src/exporters/json/json.test.ts::"JsonExporter.export returns application/json with a non-zero byteSize"` --- ### Requirement: XLSX (SpreadsheetML XML) exporter `XlsxExporter.export(data, columns, options?)` SHALL produce an `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` `ExportResult` whose `content` is a SpreadsheetML XML document with a single `` block, one `` per input record (plus a header row unless `options.includeHeaders === false`), and cells typed per JS value: numbers → ``, booleans → `t="b"` with `1`/`0`, `null`/`undefined` → empty inline-string, everything else (incl. `Date`) → escaped inline-string. #### Scenario: column-letter math wraps after Z - **WHEN** `columnIndexToLetter(0)`, `columnIndexToLetter(25)`, and `columnIndexToLetter(26)` run - **THEN** the return values are `"A"`, `"Z"`, `"AA"` #### Scenario: XML escaping - **WHEN** `escapeXml('')` runs - **THEN** the return value equals `"<a & "b">"` **Verified by:** `src/exporters/xlsx/xlsx.test.ts::"escapeXml escapes all five XML special characters"` AND `src/exporters/xlsx/xlsx.test.ts::"columnIndexToLetter wraps to AA after Z"` AND `src/exporters/xlsx/xlsx.test.ts::"formatXlsxCell emits a numeric cell for finite numbers"` AND `src/exporters/xlsx/xlsx.test.ts::"formatXlsxCell emits a boolean cell as t=b with 1 or 0"` AND `src/exporters/xlsx/xlsx.test.ts::"formatXlsxCell emits an inline-string cell for NaN"` AND `src/exporters/xlsx/xlsx.test.ts::"buildSpreadsheetXml includes a header row by default"` AND `src/exporters/xlsx/xlsx.test.ts::"buildSpreadsheetXml omits the header row when includeHeaders is false"` AND `src/exporters/xlsx/xlsx.test.ts::"XlsxExporter.export returns the spreadsheetml MIME type"` --- ### Requirement: Export service surface The package SHALL export `getExporter(format)` returning a fresh exporter per format, `inferColumns(data)` returning unique `{key,header:key}` entries deduped across rows, `runExport(provider, format, columns?, filters?, options?, config?)` that fetches via `provider.fetch(filters)`, applies row + size limits, and returns the `ExportResult`, plus `DEFAULT_EXPORT_CONFIG` and `ExportServiceConfig`. #### Scenario: row limit raises ExportError - **WHEN** `runExport(provider, "csv", undefined, undefined, undefined, { maxRows: 5, ... })` is called with a provider returning 10 rows - **THEN** the promise rejects with `ExportError` whose `code === EXPORT_CODES.ROW_LIMIT_EXCEEDED` AND `context.actual === 10` AND `context.limit === 5` #### Scenario: file-size limit raises ExportError - **WHEN** `runExport(provider, "csv", undefined, undefined, undefined, { maxFileSizeBytes: 100, ... })` is called with data that produces a >100-byte CSV - **THEN** the promise rejects with `ExportError` whose `code === EXPORT_CODES.SIZE_LIMIT_EXCEEDED` #### Scenario: unsupported format raises ExportError - **WHEN** `getExporter("pdf" as ExportFormat)` is called - **THEN** the call throws `ExportError` whose `code === EXPORT_CODES.UNSUPPORTED_FORMAT` **Verified by:** `src/services/export-service.test.ts::"getExporter returns a CsvExporter for csv"` AND `src/services/export-service.test.ts::"getExporter throws ExportError(UNSUPPORTED_FORMAT) for an unknown format"` AND `src/services/export-service.test.ts::"inferColumns deduplicates keys across rows"` AND `src/services/export-service.test.ts::"runExport throws ExportError(ROW_LIMIT_EXCEEDED) when data exceeds maxRows"` AND `src/services/export-service.test.ts::"runExport throws ExportError(SIZE_LIMIT_EXCEEDED) when output exceeds maxFileSizeBytes"` AND `src/services/export-service.test.ts::"runExport passes filters through to the data provider"` --- ### Requirement: HTTP route group `createExportRoutes(deps)` SHALL return an `OpenAPIHono` exposing `GET /` (list with optional `status`/`format`/`resource` filters + `limit`/`offset` pagination), `POST /` (create export, returning `{ job, downloadUrl }` with `downloadUrl === \`/${jobId}/download\``), `GET /:id` (lookup with 404), `DELETE /:id` (delete with 404). The create handler MUST validate the body via `CreateExportJobSchema`, return 404 with `{ error }` when the resource has no provider, return 400 with `{ error }` when the row or size limit is exceeded, return 400 with `{ error }` when the provider's `fetch` rejects, persist the job in `deps.jobs` regardless of outcome, and call the optional `onExportStarted` / `onExportCompleted` callbacks. #### Scenario: 200 happy path with CSV - **WHEN** `POST /` is called with `{ format:"csv", resource:"users" }` and the `users` provider returns two rows - **THEN** the response status is 200, `body.job.status === "completed"`, `body.job.exportedRows === 2`, `body.downloadUrl` ends with `/download` #### Scenario: 404 on unknown resource - **WHEN** `POST /` is called with `resource:"unknown"` - **THEN** the response status is 404 AND `body.error` contains `"No data provider registered"` #### Scenario: 400 on row-limit overflow - **WHEN** `POST /` is called against a `maxRows: 10` config with a provider returning 100 rows - **THEN** the response status is 400 AND the persisted job's `status === "failed"` #### Scenario: 400 on provider failure - **WHEN** the provider's `fetch` throws - **THEN** the response status is 400 AND `body.error` contains the thrown message #### Scenario: list, get, delete - **WHEN** a job is created, then listed (with each of `status=completed`/`format=json`/`resource=orders`/pagination filters), then fetched by id, then deleted, then fetched again - **THEN** the list filters are honoured, the GET /:id returns 200 with the job, the DELETE returns 200 with `{success:true}`, the second GET /:id returns 404, AND DELETE on an unknown id returns 404 **Verified by:** `src/routes/exports.test.ts::"POST / creates a CSV export job"` AND `src/routes/exports.test.ts::"POST / creates a JSON export job"` AND `src/routes/exports.test.ts::"POST / creates an XLSX export job"` AND `src/routes/exports.test.ts::"POST / uses custom columns and filename"` AND `src/routes/exports.test.ts::"POST / returns 404 when the resource has no provider"` AND `src/routes/exports.test.ts::"POST / returns 400 when the row limit is exceeded"` AND `src/routes/exports.test.ts::"POST / returns 400 when the file-size limit is exceeded"` AND `src/routes/exports.test.ts::"POST / returns 400 when the provider rejects"` AND `src/routes/exports.test.ts::"POST / persists the job in the jobs map"` AND `src/routes/exports.test.ts::"GET / returns the empty list by default"` AND `src/routes/exports.test.ts::"GET / filters by status, format, and resource"` AND `src/routes/exports.test.ts::"GET / supports limit + offset pagination"` AND `src/routes/exports.test.ts::"GET /:id returns the job by id"` AND `src/routes/exports.test.ts::"GET /:id returns 404 for an unknown id"` AND `src/routes/exports.test.ts::"DELETE /:id deletes the job"` AND `src/routes/exports.test.ts::"DELETE /:id returns 404 for an unknown id"` --- ### Requirement: Stable error-code registry `EXPORT_CODES = defineCodes("EXPORT", [...])` SHALL register `UNSUPPORTED_FORMAT`, `ROW_LIMIT_EXCEEDED`, `SIZE_LIMIT_EXCEEDED`, `PROVIDER_NOT_FOUND`, `PROVIDER_FETCH_FAILED`. Every code MUST be reachable from a real production path inside `src/`. `ExportError extends SuaveplanError` and exposes a `public` constructor `(code, message, options?)`. #### Scenario: each code is thrown from production - **WHEN** the test calls (a) `getExporter("pdf" as never)`, (b) `runExport` with row-overflow, (c) `runExport` with size-overflow, (d) a `POST /` to an unknown resource, (e) a `POST /` whose provider rejects - **THEN** each of the five codes appears in the resulting `ExportError.code` **Verified by:** `src/error-codes/error-codes.test.ts::"every EXPORT_CODES entry is thrown from at least one production path"` AND `src/error-codes/error-codes.test.ts::"ExportError narrows code to ExportErrorCode"` --- ### Requirement: OpenTelemetry instruments `getExportInstruments()` SHALL return a cached bundle of OTel instruments — `exportsTotal{format,outcome}` (Counter), `exportRowsTotal{format}` (Counter), `exportDurationMs` (Histogram) — built against `metrics.getMeter("@suaveplan/api-plugin-export")`. `runExport` and the route create-handler MUST increment `exportsTotal` with `outcome ∈ "success"|"failure"`, record `exportDurationMs`, and add `exportRowsTotal` on success. `resetExportInstruments()` clears the cache so tests can rebind. #### Scenario: counters fire against an in-memory MeterProvider - **WHEN** a successful `runExport` followed by a failing `runExport` (row-overflow) is executed against a global `MeterProvider` backed by `InMemoryMetricExporter` - **THEN** the collected metrics contain `export.exports_total` with both `outcome=success` and `outcome=failure` data points, `export.export_rows_total` with the success row count, and `export.export_duration_ms` with ≥2 recorded samples **Verified by:** `src/otel/instruments.test.ts::"runExport emits exports_total, export_rows_total, and export_duration_ms on success"` AND `src/otel/instruments.test.ts::"runExport emits exports_total{outcome=failure} on row-limit overflow"` AND `src/otel/instruments.test.ts::"resetExportInstruments forces a re-bind on next get"` --- ### Requirement: ServiceRegistry module augmentation The package SHALL `declare module "@suaveplan/api-plugin/service-registry"` to add typed slots `exportProviders: Map`, `exportJobs: Map`, AND re-declare host-owned slots (`app: OpenAPIHono`, `eventBus`, `logger`) so callers typecheck against `ctx.getService("exportProviders")` without casts. The augmentation MUST be side-effect imported from the package barrel. #### Scenario: type narrowing on ctx.getService - **WHEN** `expectTypeOf()` and `expectTypeOf()` are asserted - **THEN** the first equals `Map` AND the second equals `Map` **Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"ServiceRegistry augmentation declares exportProviders and exportJobs slots"` --- ### Requirement: Public surface and subpath exports The package root barrel (`src/index.ts`) SHALL use **explicit named re-exports** (never `export *`) for every public symbol listed in `proposal.md` "What changes". `package.json#exports` SHALL list a subpath for every public module (`./plugin/plugin`, `./routes/exports`, `./services/export-service`, `./exporters/csv/csv`, `./exporters/json/json`, `./exporters/xlsx/xlsx`, `./error-codes/error-codes`, `./otel/instruments`, `./schemas/schemas`, `./types/types`). #### Scenario: barrel is explicit - **WHEN** the source of `src/index.ts` is scanned - **THEN** zero `export *` lines exist in the file **Verified by:** `src/api-plugin-export.spec-conformance.test.ts::"package barrel re-exports the full public surface as explicit named exports"`