20 KiB
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",tagscontains"csv"/"json"/"xlsx", andoptionalDependenciescontains"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"ANDdescriptor.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
onInitis invoked with a realPluginContext - THEN
ctx.getService("exportProviders")returns aMap,ctx.getService("exportJobs")returns aMap, 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
onReadyruns against aPluginContextwhoseServiceRegistrycarries anOpenAPIHonoatappand the config'sbasePath === "/exports" - THEN
app.routehas been invoked with"/exports"and anOpenAPIHonoinstance, AND"export:ready"has been emitted
Scenario: missing app slot fails fast
- WHEN
onReadyruns against a context with noappservice registered - THEN the hook throws an
Error(orExportError) 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
onCloseis invoked afteronInitpopulated state - THEN the same map instances passed to
onInitare.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
resourceis registered twice with differentDataProvidervalues - 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:"" })andExportJobListQuerySchema.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), andcolumnIndexToLetter(26)run - THEN the return values are
"A","Z","AA"
Scenario: XML escaping
- WHEN
escapeXml('<a & "b">')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
ExportErrorwhosecode === EXPORT_CODES.ROW_LIMIT_EXCEEDEDANDcontext.actual === 10ANDcontext.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
ExportErrorwhosecode === EXPORT_CODES.SIZE_LIMIT_EXCEEDED
Scenario: unsupported format raises ExportError
- WHEN
getExporter("pdf" as ExportFormat)is called - THEN the call throws
ExportErrorwhosecode === 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 viaCreateExportJobSchema, 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'sfetchrejects, persist the job indeps.jobsregardless of outcome, and call the optionalonExportStarted/onExportCompleted` callbacks.
Scenario: 200 happy path with CSV
- WHEN
POST /is called with{ format:"csv", resource:"users" }and theusersprovider returns two rows - THEN the response status is 200,
body.job.status === "completed",body.job.exportedRows === 2,body.downloadUrlends with/download
Scenario: 404 on unknown resource
- WHEN
POST /is called withresource:"unknown" - THEN the response status is 404 AND
body.errorcontains"No data provider registered"
Scenario: 400 on row-limit overflow
- WHEN
POST /is called against amaxRows: 10config 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
fetchthrows - THEN the response status is 400 AND
body.errorcontains 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)runExportwith row-overflow, (c)runExportwith size-overflow, (d) aPOST /to an unknown resource, (e) aPOST /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
runExportfollowed by a failingrunExport(row-overflow) is executed against a globalMeterProviderbacked byInMemoryMetricExporter - THEN the collected metrics contain
export.exports_totalwith bothoutcome=successandoutcome=failuredata points,export.export_rows_totalwith the success row count, andexport.export_duration_mswith ≥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"]>()andexpectTypeOf<ServiceRegistry["exportJobs"]>()are asserted - THEN the first equals
Map<string, DataProvider>AND the second equalsMap<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.tsis 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"