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_CODESappears aserror.codeon 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
streamParseCsvis invoked withvalidatereturning"bad"on row 1 - THEN the result reports
errors.length === 1withrow: 1, message: "bad"andvalidRowsexcludes 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 anErrorwhose message containsJSON 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 emptyresult - 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
errorscontains one entry withmessage: "Cannot coerce to number"andresult.xis 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: 5andprocessedRows: 5
Scenario: zero totalRows yields zero percent
- WHEN
new ImportProgressTracker("j", 0).snapshot()runs - THEN
percentComplete === 0androwsPerSecondis finite (0or 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
mappingshassourceColumn === targetFieldfor 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
ImportErrorwithcode === 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
ImportErrorwithcode === 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 /withc.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
j1and bob callsGET /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
codeandmessagefields
Scenario: unknown throwable yields 500
- WHEN a plain
stringis 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
MeterProvideris 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
ImportErrorwithcode === IMPORT_CODES.INVALID_OPTIONSandcause instanceof ZodError
Scenario: onInit registers the service slot
- WHEN
descriptor.onInit(ctx)runs - THEN
ctx.getService("importService")is defined and is anImportServiceinstance
Scenario: onReady mounts routes under routePrefix
- WHEN
descriptor.onReady(ctx)runs withroutePrefix: "/imports" - THEN the host
app.routescontains 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
exportsmap 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 *insrc/<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"