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,77 @@
# Add @suaveplan/api-plugin-export
**Package**: `@suaveplan/api-plugin-export`
**Status**: PROPOSED
**Type**: new-package (migration from `../suaveplan/packages/server/api-plugin-export`)
- **Design**: ./design.md (n/a — straight upstream parity; rationale documented inline below)
- **Spec delta**: ./specs/api-plugin-export/spec.md
## Why
Genesis needs the upstream `api-plugin-export` capability so downstream consumers (admin dashboards, analytics surfaces, GDPR subject-access endpoints) can export any registered resource to CSV, JSON, or XLSX through a uniform OpenAPI route group. Upstream `1.0.5` ships three exporters + four routes + an in-process job store + a Zod-validated config schema. Migrating it at functional parity unblocks the api-plugin-{user, audit, ...} consumers that already type against `DataProvider`, `ExportJob`, and the `export:*` event surface.
## What changes
- ADDED: `createExportPlugin(options?)` — typed `PluginDescriptor<ExportPluginConfig>` factory, registers `exportProviders` and `exportJobs` services in `ServiceRegistry`, mounts `createExportRoutes(...)` onto `ctx.getService("app")` at the configured `basePath`, emits `export:initialized` / `export:ready` / `export:started` / `export:completed` on the bus.
- ADDED: `ExportPluginConfigSchema` + `ExportPluginConfig` — Zod `{ basePath, maxRows, maxFileSizeBytes, defaultFormat }` with defaults.
- ADDED: `registerDataProvider(providers, provider)` — typed registration helper.
- ADDED: `CsvExporter`, `JsonExporter`, `XlsxExporter` — `Exporter` implementations with helper functions (`escapeCsvField`, `formatCellValue`, `convertToCsv`, `projectRow`, `convertToJson`, `escapeXml`, `columnIndexToLetter`, `formatXlsxCell`, `buildSpreadsheetXml`).
- ADDED: `createExportRoutes(deps)` — `OpenAPIHono` with `GET /`, `POST /`, `GET /:id`, `DELETE /:id`.
- ADDED: `runExport`, `getExporter`, `inferColumns`, `DEFAULT_EXPORT_CONFIG`, `ExportServiceConfig` — service-layer surface.
- ADDED: Zod schemas `ExportFormatSchema`, `ExportStatusSchema`, `ColumnDefinitionSchema`, `CreateExportJobSchema`, `ExportJobSchema`, `ExportJobListQuerySchema`.
- ADDED: Types `ExportFormat`, `ExportStatus`, `ColumnDefinition`, `CreateExportJob`, `ExportJob`, `ExportJobListQuery`, `ExportOptions`, `ExportResult`, `Exporter`, `DataProvider`, `ExportEventMap`.
- ADDED: `EXPORT_CODES` + `ExportError` — 5 stable codes (`UNSUPPORTED_FORMAT`, `ROW_LIMIT_EXCEEDED`, `SIZE_LIMIT_EXCEEDED`, `PROVIDER_NOT_FOUND`, `PROVIDER_FETCH_FAILED`) extending `SuaveplanError`.
- ADDED: `getExportInstruments()` / `resetExportInstruments()` — OTel `exports_total{format,outcome}`, `export_rows_total{format}`, `export_duration_ms` histogram.
- ADDED: `ServiceRegistry` module augmentation for `exportProviders` and `exportJobs` slots.
## Out of scope
- SurrealDB-backed persistence, `GET /:id/download` streaming endpoint, OOXML-binary `.xlsx`, JWT/permissions/rate-limit/idempotency/audit middleware, and `surql/` migrations — the upstream `1.0.5` `src/` does not implement these. Enterprise audit doc (`../suaveplan/enterprise/api-plugin-export.md` §§3–4) lists them as future work for the upstream package itself; landing them here would diverge from the parity goal and pull in `@suaveplan/api-plugin-database` / `@suaveplan/api-plugin-audit` / `@suaveplan/api-plugin-rate-limit` which are themselves pending migration.
- Replacing upstream's plain `Error("…")` throws with a typed hierarchy — done in this change as a genesis-quality uplift (§7 Required enterprise-quality improvements item: "Error hierarchy"). The class names and codes shipped here mirror the upstream audit's call-out and become the canonical contract.
## Source mapping
This change derives from `../suaveplan/enterprise/api-plugin-export.md` and the upstream `src/` tree.
| Requirement | Source lines | Notes |
|---|---|---|
| Plugin factory + lifecycle | L17-L22 / `src/index.ts` L38-L104 | rewritten against the genesis `@suaveplan/api-plugin` descriptor shape (`configSchema: ZodType` + `defaults: Partial<T>`) |
| Zod config schema (basePath/maxRows/maxFileSizeBytes/defaultFormat) | L17-L18 / `src/index.ts` L21-L30 | verbatim |
| Three exporter classes (CSV / JSON / XLSX-XML) | L17 / `src/exporters/{csv,json,xlsx}` | verbatim |
| `runExport`, `getExporter`, `inferColumns`, `DEFAULT_EXPORT_CONFIG` | L24 / `src/services/export/export.service.ts` | verbatim |
| `OpenAPIHono` route group at `/exports` with GET/POST/GET/:id/DELETE/:id | L25 / `src/routes/exports/exports.ts` | verbatim |
| `DataProvider` registry as `Map<string, DataProvider>` | L23 / `src/types.ts` L127-L132 | verbatim |
| Job store as `Map<string, ExportJob>` (in-process) | L13 / `src/index.ts` L42, L66 | verbatim (memory-only is upstream's documented state; persistence is enterprise-future) |
| `export:*` event emissions | L25 / `src/index.ts` L65, L87-L92, L96 | verbatim |
| RFC 4180 CSV (delimiter / quoting / newlines) | L26 / `src/exporters/csv/csv.exporter.ts` | verbatim |
| SpreadsheetML XLSX (XML, not OOXML/ZIP) | L28, L38 / `src/exporters/xlsx/xlsx.exporter.ts` | verbatim — OOXML binary is enterprise-future |
| `registerDataProvider(providers, provider)` helper | `src/index.ts` L109-L114 | verbatim |
| `ExportEventMap` and `@suaveplan/api` `EventMap` declaration | `src/types.ts` L139-L170 | trimmed to genesis equivalent — augments `@suaveplan/api-plugin/service-registry` for `exportProviders`/`exportJobs` (genesis pattern) |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `EXPORT_CODES` registry + `ExportError` class | Required by genesis rule: every package extends `SuaveplanError` via `defineCodes`. Upstream uses `new Error(...)`; the audit doc (L52) explicitly flags this as a gap to fix during migration. |
| OTel instruments (`exports_total{format,outcome}`, `export_rows_total{format}`, `export_duration_ms`) | Required by genesis pre-archive checklist row 5 ("OTel instruments asserted with real in-memory `MeterProvider`"). Upstream audit doc L51 calls for this. |
| `ServiceRegistry` module augmentation for `exportProviders` / `exportJobs` | Required by genesis `@suaveplan/api-plugin` typed-slot contract; upstream's `@suaveplan/api` had a looser `services: Map<string, unknown>` so no augmentation was needed. |
| 100% Istanbul coverage; sibling `.md` per impl file | Genesis-wide policy, not package-specific. |
## Verification approach
Every requirement carries a `**Verified by:**` line in `specs/api-plugin-export/spec.md` pointing at concrete `it(...)` titles in the listed files:
- `src/exporters/csv/csv.test.ts` — RFC 4180 CSV, helpers, exporter shape.
- `src/exporters/json/json.test.ts` — projection, pretty-print, exporter shape.
- `src/exporters/xlsx/xlsx.test.ts` — XML escaping, column-letter math, cell formatting, builder.
- `src/services/export.service.test.ts` — `getExporter` (incl. unsupported-format error), `inferColumns`, `runExport` with row + size limits.
- `src/routes/exports.test.ts` — GET list with filters/pagination, POST create (CSV/JSON/XLSX, custom columns/filename), 404 on missing resource, 400 on limits, 400 on provider error, GET/:id, DELETE/:id.
- `src/plugin/plugin.test.ts` — descriptor metadata, `onInit` registers services + emits `export:initialized`, `onReady` mounts routes + emits `export:ready`, custom basePath, `onClose` clears state.
- `src/error-codes/error-codes.test.ts` — every code reachable from a production path.
- `src/otel/instruments.test.ts` — counters/histogram emit against a real in-memory `MeterProvider`.
- `src/api-plugin-export.spec-conformance.test.ts` — module augmentation, public surface, type-level claims (`expectTypeOf`).
## Migration impact
None — additive only. No genesis consumer currently imports `@suaveplan/api-plugin-export`; the package is brand new on this branch.

View File

@@ -0,0 +1,273 @@
<!-- markdownlint-disable MD024 -->
# 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<ExportPluginConfig>): PluginDescriptor<ExportPluginConfig>`, 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<ExportPluginConfig>`.
#### 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<string, DataProvider>`) and `exportJobs` (a `Map<string, ExportJob>`) 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<string, DataProvider>, 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<typeof ExportPluginConfigSchema>`.
#### 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 `<sheetData>` block, one `<row>` per input record (plus a header row unless `options.includeHeaders === false`), and cells typed per JS value: numbers → `<v>`, 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('<a & "b">')` runs
- **THEN** the return value equals `"&lt;a &amp; &quot;b&quot;&gt;"`
**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<ExportErrorCode>` 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<string, DataProvider>`, `exportJobs: Map<string, ExportJob>`, 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<ServiceRegistry["exportProviders"]>()` and `expectTypeOf<ServiceRegistry["exportJobs"]>()` are asserted
- **THEN** the first equals `Map<string, DataProvider>` AND the second equals `Map<string, ExportJob>`
**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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,73 @@
# Tasks — Add @suaveplan/api-plugin-export
> Every checkbox MUST reflect work actually done in this change.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-export.md` end-to-end
- [x] 1.2 Read upstream `../suaveplan/packages/server/api-plugin-export/src/` end-to-end
- [x] 1.3 Read `openspec/AGENTS.md` §§ 2a, 6a, 7, 12, 13, 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` table — every requirement cites a line range
- [x] 1.6 Draft `specs/api-plugin-export/spec.md` with `## ADDED Requirements`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 `src/error-codes/error-codes.ts` — `EXPORT_CODES` (5 codes) + `ExportError` extending `SuaveplanError`
- [x] 2.2 `src/types/types.ts` — `ExportFormat`, `ExportStatus`, `ExportOptions`, `ExportResult`, `Exporter`, `DataProvider`
- [x] 2.3 `src/schemas/schemas.ts` — `ExportFormatSchema`, `ExportStatusSchema`, `ColumnDefinitionSchema`, `CreateExportJobSchema`, `ExportJobSchema`, `ExportJobListQuerySchema`, `ExportPluginConfigSchema`
- [x] 2.4 `src/otel/instruments.ts` — `getExportInstruments()` + `resetExportInstruments()`
- [x] 2.5 `src/exporters/csv/csv.ts` — `escapeCsvField`, `formatCellValue`, `convertToCsv`, `CsvExporter`
- [x] 2.6 `src/exporters/json/json.ts` — `projectRow`, `convertToJson`, `JsonExporter`
- [x] 2.7 `src/exporters/xlsx/xlsx.ts` — `escapeXml`, `columnIndexToLetter`, `formatXlsxCell`, `buildSpreadsheetXml`, `XlsxExporter`
- [x] 2.8 `src/services/export-service.ts` — `getExporter`, `inferColumns`, `runExport`, `DEFAULT_EXPORT_CONFIG`, `ExportServiceConfig`
- [x] 2.9 `src/routes/exports.ts` — `createExportRoutes(deps)` mounting GET / POST / GET /:id / DELETE /:id
- [x] 2.10 `src/plugin/plugin.ts` — `createExportPlugin`, `registerDataProvider`
- [x] 2.11 `src/services/registry-augmentation.ts` — augment `ServiceRegistry` with `exportProviders`/`exportJobs`/`app`
- [x] 2.12 `src/events/event-augmentation.ts` — augment `EventBusLike` event topics (`export:initialized`, etc.)
- [x] 2.13 `src/index.ts` — explicit named re-exports of the full surface; subpath exports in package.json
## 3. Tests
- [x] 3.1 `src/exporters/csv/csv.test.ts`
- [x] 3.2 `src/exporters/json/json.test.ts`
- [x] 3.3 `src/exporters/xlsx/xlsx.test.ts`
- [x] 3.4 `src/services/export-service.test.ts`
- [x] 3.5 `src/routes/exports.test.ts` — full Hono route exercise via `app.request`
- [x] 3.6 `src/plugin/plugin.test.ts` — lifecycle + service registration + event emission against a real `PluginContext` built from `createPluginContext`
- [x] 3.7 `src/error-codes/error-codes.test.ts` — every code thrown from a real production path
- [x] 3.8 `src/otel/instruments.test.ts` — real in-memory `MeterProvider`, assert counter + histogram emission
- [x] 3.9 `src/api-plugin-export.spec-conformance.test.ts` — `expectTypeOf` on public surface
- [x] 3.10 `src/schemas/schemas.test.ts` — Zod-boundary assertions
- [x] 3.11 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, API reference)
- [x] 4.2 README links every co-located `.md` under `## Modules`
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-export` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-export` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-export` — exit 0
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-export` — green
## 6. Pre-archive 9-row checklist
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [ ] 6.2 Zod schemas wired at every declared boundary
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
- [ ] 6.4 Strict-mode behaviors tested
- [ ] 6.5 OTel instruments emitted from production paths and asserted with real in-memory `MeterProvider`
- [ ] 6.6 Lifecycle dedup tested
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
- [ ] 6.8 No `export *` in any feature-module barrel
- [ ] 6.9 No direct `vitest`/`bun:test` imports in test files (all via `@suaveplan/testing/runner`)
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/api-plugin-export/spec.md`
- [ ] 7.2 Move `openspec/changes/add-api-plugin-export/` → `openspec/archive/2026-05-18-add-api-plugin-export/`