Files
2026-05-27 23:29:53 +01:00

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", 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 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 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"