Files
suaveplan/openspec/archive/2026-05-19-add-api-plugin-export/specs/api-plugin-export/spec.md
2026-05-27 23:29:53 +01:00

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