Initial Commit
This commit is contained in:
@@ -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"`
|
||||
Reference in New Issue
Block a user