# 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`. 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."` #### 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[]` 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 "" 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` 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` 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//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"`