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

15 KiB

Capability Spec Delta: api-plugin-import (add-api-plugin-import)

Package: @suaveplan/api-plugin-import Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time.

ADDED Requirements

Requirement: Stable IMPORT.* error registry

The package SHALL register exactly nine stable error codes under the "IMPORT" namespace via defineCodes("IMPORT", […]), exporting IMPORT_CODES and ImportError extends SuaveplanError<ImportErrorCode>. Every code SHALL be reachable from a real production code path; deletion to satisfy a coverage gate is forbidden.

Scenario: registry declares the nine canonical codes

  • WHEN a caller imports IMPORT_CODES
  • THEN the literal keys equal {NOT_FOUND, FORBIDDEN, VALIDATION, FILE_TOO_LARGE, UNSUPPORTED_FORMAT, PROCESSING_FAILED, STORAGE_ERROR, TIMEOUT, INVALID_OPTIONS} and every value is namespaced "IMPORT.<KEY>"

Scenario: every code is thrown from a production path

  • WHEN the test suite drives the full feature surface
  • THEN every key in IMPORT_CODES appears as error.code on at least one captured throw

Verified by: src/error-codes/error-codes.test.ts::"declares all nine IMPORT codes" AND src/error-codes/error-codes.test.ts::"every IMPORT code is reachable from a production path"


Requirement: CSV parsing — quoted, custom-delimiter, header-aware

The package SHALL parse CSV input into Record<string, string>[] rows respecting "-quoted fields (including escaped "" quotes and embedded newlines), custom delimiters, optional headers (defaulting to column_N placeholders when absent), and SHALL expose a streaming variant (streamParseCsv) that surfaces per-row validation errors without aborting the run.

Scenario: header-first CSV with quoted comma

  • WHEN input is name,city\n"Doe, Jane",NYC
  • THEN the result is [{ name: "Doe, Jane", city: "NYC" }]

Scenario: streaming parser collects validation errors

  • WHEN streamParseCsv is invoked with validate returning "bad" on row 1
  • THEN the result reports errors.length === 1 with row: 1, message: "bad" and validRows excludes that row

Verified by: src/parsers/csv.test.ts::"parses quoted fields with embedded commas" AND src/parsers/csv.test.ts::"streamParseCsv surfaces per-row validate errors"


Requirement: JSON parser rejects non-arrays

The package SHALL accept a JSON-array input string and return its elements as rows; non-array input SHALL throw with a message starting JSON data must be an array.

Scenario: valid JSON array

  • WHEN input is [{"a":1}]
  • THEN parseJson(input) returns [{ a: 1 }]

Scenario: invalid non-array input

  • WHEN input is {"a":1}
  • THEN parseJson(input) throws an Error whose message contains JSON data must be an array

Verified by: src/parsers/json.test.ts::"returns array elements as rows" AND src/parsers/json.test.ts::"throws when input is not a JSON array"


Requirement: XLSX (TSV-style) parser

The package SHALL provide parseXlsx that treats \t-separated lines as rows with optional header inference; binary .xlsx decoding is explicitly out of scope and SHALL be documented as a limitation.

Scenario: TSV input with header row

  • WHEN input is a\tb\n1\t2
  • THEN parseXlsx(input) returns { headers: ["a","b"], rows: [{a:"1",b:"2"}], totalRows: 1 }

Verified by: src/parsers/xlsx.test.ts::"parses TSV with headers" AND src/parsers/xlsx.test.ts::"synthesises column_N headers when hasHeaders is false"


Requirement: Transform pipeline — coerce, validate, compute

The package SHALL provide a declarative transformRow / transformRows pipeline that applies TransformMapping[] (source→target with optional type coercion, default value, required flag, transform fn), ComputedField[] (target derived from named source columns), and FieldValidation[] (required / min / max / pattern / custom). coerceType SHALL succeed/fail explicitly for number / boolean / date / pass-through for string. Failed transforms SHALL push a TransformError and skip the field, never throw.

Scenario: required field missing produces TransformError

  • WHEN transformRow({}, 0, { mappings: [], validations: [{ field: "x", rule: "required" }] }) runs against an empty result
  • THEN the result is { success: false, errors: [{ row: 0, field: "x", message: '"x" is required', value: undefined }] }

Scenario: type coercion failure marks row failed

  • WHEN transformRow({ x: "abc" }, 0, { mappings: [{ sourceColumn: "x", targetField: "x", type: "number" }] })
  • THEN errors contains one entry with message: "Cannot coerce to number" and result.x is absent

Scenario: computed field aggregates source columns

  • WHEN transformRow({ a: 1, b: 2 }, 0, { mappings: [], computedFields: [{ targetField: "sum", sourceColumns: ["a","b"], compute: v => Number(v.a) + Number(v.b) }] })
  • THEN row.sum === 3

Verified by: src/transform/pipeline.test.ts::"validateField required produces error for missing value" AND src/transform/pipeline.test.ts::"coerceType number returns ok:false for non-numeric input" AND src/transform/pipeline.test.ts::"computed field is evaluated from named source columns"


Requirement: ImportProgressTracker — rows/sec, ETA, percent

The package SHALL ship an ImportProgressTracker that records single + batch successes/failures, computes percentComplete (rounded int), rowsPerSecond (2dp), and estimatedRemainingMs, fans snapshots to registered listeners, and SHALL expose an isComplete() predicate that returns true iff processedRows >= totalRows.

Scenario: batch success updates totals and notifies listeners

  • WHEN tracker.recordBatchSuccess(5) runs after a listener is registered
  • THEN the listener receives a snapshot with successCount: 5 and processedRows: 5

Scenario: zero totalRows yields zero percent

  • WHEN new ImportProgressTracker("j", 0).snapshot() runs
  • THEN percentComplete === 0 and rowsPerSecond is finite (0 or a small positive number)

Verified by: src/progress/tracker.test.ts::"recordBatchSuccess fans a snapshot with the cumulative counts" AND src/progress/tracker.test.ts::"snapshot guards against zero totalRows"


Requirement: Async ImportStoreAdapter + MemoryImportStore

The package SHALL declare an async ImportStoreAdapter interface with create / get / update / delete / list and SHALL ship a MemoryImportStore implementing it. update on a missing id SHALL throw with Job "<id>" not found. list SHALL filter by status and respect limit / offset.

Scenario: round-trip create + get

  • WHEN a record is created and then fetched by id
  • THEN the fetched value equals the created one

Scenario: update on missing id throws

  • WHEN update("missing", {...}) runs on an empty store
  • THEN it rejects with an error whose message matches Job "missing" not found

Verified by: src/store/memory.test.ts::"round-trips a record via create and get" AND src/store/memory.test.ts::"throws when updating a missing id"


Requirement: ImportService — preview, startImport, owner-scoped jobs

The package SHALL ship a canonical ImportService extends EventEmitter<ImportEventMap> with preview(format, data), startImport(config, { ownerId }), getJob(id, ownerId), getResults(id, ownerId), listJobs(ownerId), deleteJob(id, ownerId). Foreign-owner reads SHALL throw IMPORT.NOT_FOUND (anti-enumeration; never IMPORT.FORBIDDEN on lookup misses). Format validation SHALL throw IMPORT.UNSUPPORTED_FORMAT; parse failures SHALL throw IMPORT.PROCESSING_FAILED; missing mappings SHALL throw IMPORT.VALIDATION. The service SHALL emit import.started, import.progress, import.completed, import.failed, import.row_error, import:row:processed per the documented ImportEventMap. Successful runs SHALL increment import.jobs_total{outcome:"success"} and import.rows_total OTel counters.

Scenario: preview returns column auto-mapping

  • WHEN preview("csv", "a,b\n1,2") runs
  • THEN the returned mappings has sourceColumn === targetField for every detected column

Scenario: dry-run does not persist results

  • WHEN startImport({ format, data, mappings, dryRun: true }, { ownerId }) succeeds
  • THEN getResults(jobId, ownerId) returns []

Scenario: foreign-owner lookup returns 404 not 403

  • WHEN getJob(jobId, "other-user") runs on a job owned by "alice"
  • THEN it throws ImportError with code === IMPORT_CODES.NOT_FOUND

Scenario: parse failure surfaces as IMPORT.PROCESSING_FAILED

  • WHEN startImport({ format: "json", data: "not-json", mappings: [{ sourceColumn: "x", targetField: "x" }] }) runs
  • THEN it throws ImportError with code === IMPORT_CODES.PROCESSING_FAILED

Verified by: src/service/service.test.ts::"preview returns identity column mappings" AND src/service/service.test.ts::"dryRun does not persist imported rows" AND src/service/service.test.ts::"foreign owner lookup returns NOT_FOUND" AND src/service/service.test.ts::"parse failure throws PROCESSING_FAILED"


Requirement: 5 Hono routes, auth-gated, owner-scoped

The package SHALL ship createImportRoutes({ service, instruments }) returning an OpenAPIHono with five routes — POST /preview, POST /, GET /, GET /{id}, DELETE /{id}. Every route SHALL be wrapped in requireAuth() from @suaveplan/api-plugin-auth. Job-scoped routes SHALL read c.get("user").id as ownerId. Lookup misses on jobs owned by a different user SHALL return HTTP 404 with RFC 7807 problem+json (anti-enumeration).

Scenario: anonymous request to POST / returns 401

  • WHEN a request reaches POST / with c.get("user") === null
  • THEN the response is HTTP 401 with content-type: application/problem+json

Scenario: foreign-owner GET /{id} returns 404

  • WHEN alice owns job j1 and bob calls GET /j1
  • THEN the response is HTTP 404 with problem+json

Verified by: src/routes/build-routes.test.ts::"POST / returns 401 when caller is anonymous" AND src/routes/build-routes.test.ts::"GET /{id} returns 404 when the job is owned by a different user"


Requirement: RFC 7807 problem+json mapping

The package SHALL provide respondProblemJson(c, err) that renders any SuaveplanError as RFC 7807 application/problem+json, reading error.context.statusCode when present and defaulting to 500. Unknown throwables SHALL render as a generic 500.

Scenario: SuaveplanError with statusCode 404

  • WHEN the error carries context.statusCode = 404
  • THEN the response status is 404 and the body is JSON with the code and message fields

Scenario: unknown throwable yields 500

  • WHEN a plain string is thrown
  • THEN the response status is 500 with content-type: application/problem+json

Verified by: src/routes/problem-json.test.ts::"renders SuaveplanError with the context statusCode" AND src/routes/problem-json.test.ts::"renders unknown throwables as a generic 500"


Requirement: OTel instruments bundle

The package SHALL export getImportInstruments() returning a cached bundle of four OpenTelemetry instruments — import.jobs_total (Counter, labelled by outcome), import.rows_total (Counter, labelled by outcome), import.row_errors_total (Counter), import.duration_ms (Histogram). resetImportInstruments() SHALL drop the cache so subsequent calls rebind against the current global MeterProvider. Production code paths SHALL emit on every startImport.

Scenario: cached bundle returns same reference

  • WHEN getImportInstruments() is called twice without reset
  • THEN the second call returns the same object

Scenario: emit after install collects metrics

  • WHEN a fresh MeterProvider is installed, resetImportInstruments() runs, and a counter is incremented
  • THEN the in-memory exporter contains the metric

Verified by: src/otel/instruments.test.ts::"getImportInstruments returns a cached bundle" AND src/otel/instruments.test.ts::"resetImportInstruments rebinds against a fresh MeterProvider"


Requirement: createImportPlugin descriptor

The package SHALL export createImportPlugin(options?) returning a typed PluginDescriptor<ImportPluginConfig> whose name: "import", version: "0.1.0", optionalDependencies: ["auth", "events", "database"], and tags: ["import", "csv", "json", "xlsx"]. Invalid options SHALL throw ImportError(IMPORT.INVALID_OPTIONS) with the ZodError as cause. onInit SHALL register the ImportService instance on ServiceRegistry["importService"]; onReady SHALL mount routes under the configured routePrefix; onClose SHALL clear all listeners on the service.

Scenario: bad options throws INVALID_OPTIONS

  • WHEN createImportPlugin({ routePrefix: "" }) runs
  • THEN it throws ImportError with code === IMPORT_CODES.INVALID_OPTIONS and cause instanceof ZodError

Scenario: onInit registers the service slot

  • WHEN descriptor.onInit(ctx) runs
  • THEN ctx.getService("importService") is defined and is an ImportService instance

Scenario: onReady mounts routes under routePrefix

  • WHEN descriptor.onReady(ctx) runs with routePrefix: "/imports"
  • THEN the host app.routes contains an entry whose path starts with /imports/

Verified by: src/plugin/plugin.test.ts::"throws INVALID_OPTIONS on bad config" AND src/plugin/plugin.test.ts::"onInit registers the importService slot" AND src/plugin/plugin.test.ts::"onReady mounts routes under the configured routePrefix" AND src/plugin/plugin.spec-conformance.test.ts::"descriptor carries the canonical metadata"


Requirement: Subpath exports per feature module

The package SHALL declare a subpath export in package.json#exports for every feature module so consumers can tree-shake. The package root SHALL re-export public symbols via explicit named re-exports; feature-module barrels SHALL NOT use export *.

Scenario: subpath export count matches feature count

  • WHEN the exports map is enumerated
  • THEN it contains entries for ./error-codes/error-codes, ./parsers/csv, ./parsers/json, ./parsers/xlsx, ./transform/pipeline, ./progress/tracker, ./store/memory, ./service/service, ./routes/problem-json, ./routes/build-routes, ./otel/instruments, ./schemas/options, ./schemas/schemas, ./plugin/plugin, ./types/types

Scenario: feature barrels use explicit named re-exports

  • WHEN the file tree is scanned for export * in src/<feature>/index.ts
  • THEN no matches exist (the conformance script enforces this)

Verified by: src/plugin/plugin.spec-conformance.test.ts::"package exposes a subpath export for every feature module" AND src/plugin/plugin.spec-conformance.test.ts::"every feature barrel uses explicit named re-exports"