12 KiB
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
ExportErroris thrown withcode === EXPORT.INVALID_CONFIGand theZodErroris attached ascause
Scenario: caller-supplied options flow through to onReady
- WHEN the registry calls
onReady(ctx)aftercreateExportPlugin({ basePath: "/v1/exports", maxRows: 100 }) - THEN the routes are mounted at
/v1/exportsandrunExportagainst a 101-row provider rejects withEXPORT.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
AAletter
Scenario: numeric vs. inline-string cells
- WHEN a row contains a number
42and a string"forty-two" - THEN the numeric cell has no
t="…"attribute and the string cell hast="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.fetchresolves with 51 rows andconfig.maxRows === 50 - THEN
runExportrejects withEXPORT.ROW_LIMIT_EXCEEDED
Scenario: file-size guard
- WHEN the exporter result
byteSizeexceedsconfig.maxFileSizeBytes - THEN
runExportrejects withEXPORT.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 applystatus/format/resourcefilters fromExportJobListQuerySchema, thenoffset/limitwindow the in-memoryjobsmap.POST /SHALL look updeps.providers.get(body.resource), return404 { error }on miss, record a freshExportJob, firedeps.onExportStarted, run the export throughprovider.fetch→inferColumns→getExporter→exporter.export, enforce row + size caps (400 { error }on breach), update the job tocompleted, firedeps.onExportCompleted, and respond200 { job, downloadUrl }.GET /:idSHALL return the job or404 { error: "Export job not found" }.DELETE /:idSHALL remove the job (200 { success: true }) or404 { 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 aresourcenot indeps.providers - THEN the response is
404with{ 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
400and the job ends instatus: "failed"with a row-limit error message
Scenario: GET list filter + pagination
- WHEN three jobs exist (two
completed, onefailed) and the query is?status=completed&limit=1 - THEN the response carries
total: 2anditems.length === 1
Scenario: DELETE missing job
- WHEN
DELETE /missing-idis 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"anderror.exportCodenarrows 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 ofexport.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.tsis read - THEN the file contains both
./error-codes/error-codes.jsand./services/registry-augmentation.jsimport 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"