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,81 @@
# Ship `@suaveplan/api-plugin-export`
**Package**: `@suaveplan/api-plugin-export`
**Status**: PROPOSED
**Type**: new-package
## Why
The genesis monorepo needs a data-export plugin for `@suaveplan/api` that turns any registered resource into a CSV (RFC 4180), JSON, or XLSX (SpreadsheetML XML) blob. The upstream package at `../suaveplan/packages/server/api-plugin-export/` (v1.0.5) ships three self-contained exporter classes, a `DataProvider` registry, a job-lifecycle store, and an `OpenAPIHono` route group at `/exports`. This change rebuilds that surface at genesis-quality (`SuaveplanError` codes, OTel instruments asserted with a real `MeterProvider`, 100 % istanbul coverage, sibling `.md` for every implementation file, no mocks, no `vi.mock`, no `export *` in feature barrels).
## What changes
- ADDED: `createExportPlugin(options?)` — returns a `PluginDescriptor` honouring caller-supplied options (the upstream `_config`-discard bug is fixed at migration time).
- ADDED: `CsvExporter`, `JsonExporter`, `XlsxExporter` — three RFC-4180 / JSON / SpreadsheetML implementations of the `Exporter` interface.
- ADDED: `getExporter(format)`, `inferColumns(data)`, `runExport(...)`, `DEFAULT_EXPORT_CONFIG` — service-layer helpers.
- ADDED: `createExportRoutes(deps)` — `OpenAPIHono` with `GET /`, `POST /`, `GET /:id`, `DELETE /:id`.
- ADDED: `registerDataProvider(providers, provider)` — providers-map helper.
- ADDED: `EXPORT_CODES` + `ExportError` extending `SuaveplanError` — replaces the raw `new Error(...)` throws from upstream `export.service.ts`.
- ADDED: `ExportInstruments` + `getExportInstruments()` + `resetExportInstruments()` — OTel `Counter` + `Histogram` bundle.
- ADDED: Zod schemas `ExportFormatSchema`, `ExportStatusSchema`, `ColumnDefinitionSchema`, `CreateExportJobSchema`, `ExportJobSchema`, `ExportJobListQuerySchema`, `ExportPluginOptionsSchema`.
- ADDED: `ServiceRegistry` slots `exportProviders` and `exportJobs` via `declare module "@suaveplan/api-plugin/service-registry"`.
## Out of scope
This change ships the upstream **§2 “Current-state assessment”** surface only. The upstream `enterprise/api-plugin-export.md` §3–§4 enhancements (streaming exporters, S3-backed blob store, auth/permissions/rate-limit/idempotency middleware, OOXML/ZIP writer, scheduled exports, signed download URLs, Parquet/Markdown formats, benchmark harness) are explicitly deferred — they are listed as “feature-rich enhancements” in the source spec, not as part of the current functional surface, and each requires a downstream plugin that has not yet landed on genesis (e.g. `@suaveplan/data-masking`, `@suaveplan/storage-s3`, `@suaveplan/api-plugin-rate-limit` consumer wiring, `@suaveplan/queue`).
### Excluded from this migration
| Upstream symbol / behaviour | Justification |
|---|---|
| `ctx.app.route(basePath, routes)` mount in `onReady` | Genesis `PluginContext` has no `app` field; routes are exposed via `ctx.getService("app")` in `onReady` (same pattern as `@suaveplan/api-plugin-audit`). Public route surface is preserved. |
| `ctx.eventBus.emit("export:initialized" / "export:ready" / "export:started" / "export:completed")` | Genesis `EventBusLike` is structural and does not declare these event keys in `ServiceRegistry`. Replaced with structured logger calls + OTel counter; the `onExportStarted` / `onExportCompleted` callback hooks remain on `createExportRoutes` deps so consumers can wire any bus they want. |
| `declare module "@suaveplan/api" { interface EventMap { ... } }` | Same reason as above — no `EventMap` interface on genesis `@suaveplan/api`. |
| `GET /:id/download` streaming endpoint | Upstream `enterprise/api-plugin-export.md` §2 "Smells" notes the response uses `downloadUrl: "/{jobId}/download"` but **no `/:id/download` handler exists** in `routes/exports.ts`. The endpoint is part of §3 "Required enterprise-quality improvements" and §4 "Streaming exporters" — deferred to follow-up. The `downloadUrl` field is retained for forward compatibility. |
| §3 "Job persistence" via SurrealDB | Upstream §2 ships the in-memory `Map<string, ExportJob>`. SurrealDB persistence is §3 work and requires `surql/` migrations + integration tests against `@suaveplan/api-plugin-database`. Deferred. |
| §4 streaming / S3 / OOXML / auth / rate-limit / idempotency / audit / signed URLs / Parquet / Markdown / scheduled exports / benchmarks | All listed as “feature-rich enhancements” in upstream §4. Deferred per Out-of-scope above. |
## Source mapping
This change derives from `../suaveplan/enterprise/api-plugin-export.md` and the upstream source under `../suaveplan/packages/server/api-plugin-export/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| Plugin name `"export"` v1.0.0, `optionalDependencies: ["database", "events"]`, `tags: ["export","data","csv","json","xlsx"]` | `enterprise/api-plugin-export.md` L18 + `src/index.ts:44-50` | verbatim |
| Config schema `basePath` / `maxRows` (1–1 000 000) / `maxFileSizeBytes` (≥ 1 KiB, default 50 MiB) / `defaultFormat` (`csv\|json\|xlsx`) | `enterprise/api-plugin-export.md` L18 + `src/index.ts:21-30` | verbatim |
| Honour factory-time config — merge caller-supplied `options` with `getOwnConfig()` | `enterprise/api-plugin-export.md` L34 + L43 | upstream bug fixed at migration time |
| Three exporter classes: `CsvExporter`, `JsonExporter`, `XlsxExporter` implementing `Exporter` | `enterprise/api-plugin-export.md` L17 + `src/exporters/{csv,json,xlsx}/*.ts` | verbatim |
| RFC 4180 CSV: configurable delimiter, optional headers, quote-escaping, ISO-8601 dates, `text/csv` MIME | `enterprise/api-plugin-export.md` L26 + `src/exporters/csv/csv.exporter.ts` | verbatim |
| JSON: column projection + optional pretty-print, `application/json` MIME | `enterprise/api-plugin-export.md` L27 + `src/exporters/json/json.exporter.ts` | verbatim |
| XLSX (SpreadsheetML XML): configurable sheet name, header row, OOXML MIME, A→Z→AA column letters | `enterprise/api-plugin-export.md` L28 + `src/exporters/xlsx/xlsx.exporter.ts` | verbatim |
| Service helpers: `getExporter`, `inferColumns`, `runExport`, `DEFAULT_EXPORT_CONFIG` with row/size guards | `enterprise/api-plugin-export.md` L24 + `src/services/export/export.service.ts` | verbatim, with `ExportError` replacing raw `Error` |
| Routes: `GET /`, `POST /`, `GET /:id`, `DELETE /:id` on `OpenAPIHono` | `enterprise/api-plugin-export.md` L25 + `src/routes/exports/exports.ts` | verbatim (download endpoint excluded — see above) |
| In-memory `Map<string, ExportJob>` job store with `pending\|processing\|completed\|failed` FSM | `enterprise/api-plugin-export.md` L25 + L31 + `src/routes/exports/exports.ts:140-213` | verbatim |
| Zod boundaries on every public option bundle (`CreateExportJobSchema`, `ExportJobListQuerySchema`, `ExportJobSchema`) | `enterprise/api-plugin-export.md` L23 + `src/types.ts:32-87` | verbatim |
| `registerDataProvider(providers, provider)` helper | `src/index.ts:109-114` | verbatim |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `EXPORT_CODES` + `ExportError extends SuaveplanError` | Genesis-wide policy (`CLAUDE.md` §“Error hierarchy”). Upstream throws raw `Error`. |
| `ExportInstruments` + `getExportInstruments` + `resetExportInstruments` with `export.jobs_total{outcome}`, `export.rows_total`, `export.bytes_total`, `export.duration_ms` | Genesis-wide OTel policy. Upstream has no OTel. |
| `ServiceRegistry` slots `exportProviders` / `exportJobs` via `declare module` | Required because genesis `PluginContext` exposes services only via the augmented registry — there is no upstream equivalent because the upstream `PluginContext` is structurally different (has `ctx.app`, `ctx.eventBus.emit(K, payload)`, etc.). |
| 100 % istanbul coverage; sibling `.md` ≥ 200 words per impl file | Genesis-wide policy. |
| `spec-conformance.test.ts` pinning every `EXPORT_*` code to ≥ 1 production throw site | Genesis policy (§13). |
## Verification approach
- `src/error-codes/error-codes.test.ts` — every `EXPORT_*` code constructs an `ExportError` with the right narrowed code.
- `src/exporters/csv/csv.test.ts` — RFC 4180 quoting / delimiter / header / ISO-8601 date round-trip.
- `src/exporters/json/json.test.ts` — projection, pretty-print, transform application.
- `src/exporters/xlsx/xlsx.test.ts` — sheet name, header row, column letter math A→Z→AA, escaping.
- `src/services/export/export-service.test.ts` — `getExporter`, `inferColumns`, `runExport` row + size guards (throw `ExportError`).
- `src/routes/exports/exports.test.ts` — `GET /` filter+pagination, `POST /` happy + 404 + 400 (row cap) + 400 (size cap) + 400 (provider error), `GET /:id` + 404, `DELETE /:id` + 404.
- `src/plugin/plugin.test.ts` — Zod validation rejects bad options; `onInit` registers `exportProviders` + `exportJobs`; `onReady` honours caller-supplied options and mounts routes via `ctx.getService("app")`; `onClose` clears both maps.
- `src/otel/instruments.test.ts` — emits all four instruments through a real in-memory `MeterProvider`.
- `src/api-plugin-export.spec-conformance.test.ts` — every `EXPORT_*` code is thrown from a non-test file; side-effect imports of `error-codes` and `registry-augmentation` are present in `index.ts`.
## Migration impact
None — additive only. No genesis package currently consumes `@suaveplan/api-plugin-export`.

View File

@@ -0,0 +1,193 @@
# 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<string, DataProvider>` and `exportJobs: Map<string, ExportJob>` 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<string, unknown>` 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 `<c><v>…</v></c>`, booleans `<c t="b"><v>0|1</v></c>`, everything else `<c t="inlineStr"><is><t>…</t></is></c>` 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: <name>" }`
#### 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<ExportErrorCode>` 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"`

View File

@@ -0,0 +1,67 @@
# Tasks — Ship `@suaveplan/api-plugin-export`
## 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 template
- [x] 1.5 Fill `## Source mapping` table — every requirement cites a line range or appears in the no-source-line table
- [x] 1.6 Draft `specs/api-plugin-export/spec.md` from template with `**Verified by:**` lines on every requirement
## 2. Implementation
- [x] 2.1 `package.json` v0.1.0, `sideEffects: false`, subpath exports, `workspace:^` peers, istanbul, vite build
- [x] 2.2 `tsconfig.json` + `tsconfig.lib.json` extending `tsconfig.base.json`
- [x] 2.3 `vite.config.ts` (dts + preserveModules, externals)
- [x] 2.4 `vitest.config.ts` via `createTestConfig` style — istanbul, forks, 100/100/100/100
- [x] 2.5 `tests/setup/polyfills.ts` — real `MeterProvider`
- [x] 2.6 `src/error-codes/error-codes.ts` — `defineCodes("EXPORT", [...])` + `ExportError`
- [x] 2.7 `src/types/types.ts` + Zod schemas (`ExportFormatSchema` etc.)
- [x] 2.8 `src/exporters/csv/csv.ts` — `CsvExporter` + `escapeCsvField` + `formatCellValue` + `convertToCsv`
- [x] 2.9 `src/exporters/json/json.ts` — `JsonExporter` + `projectRow` + `convertToJson`
- [x] 2.10 `src/exporters/xlsx/xlsx.ts` — `XlsxExporter` + `escapeXml` + `columnIndexToLetter` + `formatXlsxCell` + `buildSpreadsheetXml`
- [x] 2.11 `src/services/export/export-service.ts` — `getExporter` / `inferColumns` / `runExport` / `DEFAULT_EXPORT_CONFIG`
- [x] 2.12 `src/routes/exports/exports.ts` — `createExportRoutes` with `GET /`, `POST /`, `GET /:id`, `DELETE /:id`
- [x] 2.13 `src/otel/instruments.ts` — `ExportInstruments` + `getExportInstruments` + `resetExportInstruments`
- [x] 2.14 `src/services/registry-augmentation.ts` — `exportProviders` + `exportJobs` slots
- [x] 2.15 `src/plugin/plugin.ts` — `createExportPlugin` honouring caller-supplied options, mounting routes via `ctx.getService("app")`
- [x] 2.16 `src/index.ts` — explicit named re-exports, side-effect import of error-codes + registry-augmentation
## 3. Tests
- [x] 3.1 Unit tests per feature module
- [x] 3.2 Spec-conformance test pinning every `EXPORT_*` code to a production throw site + side-effect imports in `index.ts`
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥ 200 words, basic + advanced examples, full API reference)
- [x] 4.2 `README.md` at package root only
- [x] 4.3 No CLAUDE.md changes (out of scope — forbidden path)
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome clean
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-export` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-export` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-export` — 0 errors
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-export` — green
## 6. Pre-archive 9-row checklist (AGENTS.md §13 + skill §7)
- [x] 6.1 Generics threaded
- [x] 6.2 Zod schemas wired at every declared boundary (`CreateExportJobSchema`, `ExportPluginOptionsSchema`, `ExportJobListQuerySchema`)
- [x] 6.3 Every error code thrown from ≥ 1 production path (asserted by spec-conformance test)
- [x] 6.4 Strict-mode behaviours tested (row cap + size cap throws `ExportError`; provider missing → 404; bad options → `ExportError(INVALID_CONFIG)`)
- [x] 6.5 OTel instruments emitted from production paths, asserted with real in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup — `onClose` clears both maps; `onInit` registers services exactly once
- [x] 6.7 No retroactive tick — every box reflects work in this change
- [x] 6.8 No `export *` in any feature-module barrel (no feature barrels exist; root barrel uses named exports)
- [x] 6.9 No direct `vitest` / `bun:test` imports — all tests import from `@suaveplan/testing/runner`
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-export/spec.md`
- [x] 7.2 Move `openspec/changes/add-api-plugin-export/` → `openspec/archive/2026-05-19-add-api-plugin-export/`
- [x] 7.3 No MANIFEST.md refresh (forbidden path per task brief)