27 KiB
Capability Spec: api-plugin-import
Package: @suaveplan/api-plugin-import
Status: SHIPPED
Every requirement below carries a
**Verified by:**line pointing at theit(...)titles in the package's test suite.
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"
Requirement: SurrealImportStore — durable ImportStoreAdapter
The package SHALL ship SurrealImportStore implements ImportStoreAdapter at src/store/surreal/surreal-import-store.ts, providing the same create / get / update / delete / list surface as MemoryImportStore against a SurrealAdapter backend from @suaveplan/surrealdb. All SurrealQL placeholders SHALL be positional ($p0, $p1, …) per the canonical pattern in @suaveplan/api-plugin-events. The constructor SHALL accept a SurrealImportStoreConfig validated through Zod at the boundary; an invalid config SHALL throw ImportError with code === IMPORT_CODES.INVALID_OPTIONS and cause instanceof ZodError. The store SHALL accept a custom table name (default "import_job"). create SHALL upsert the record by stringified id; update on a missing id SHALL throw an error whose message contains Job "<id>" not found (parity with MemoryImportStore). Adapter-layer failures SHALL be wrapped as ImportError(IMPORT_CODES.STORAGE_ERROR) with the underlying error as cause. The store SHALL increment import.store.writes_total{outcome:"success"|"error"} on every create / update / delete, and import.store.reads_total{outcome:"success"|"error"} on every get / list.
Scenario: create emits a CREATE SET statement with positional params
- WHEN
surrealStore.create(record)runs withrecord.id = "j1" - THEN the issued SurQL begins with
CREATE import_job:j1 SETand the parameter array contains the record'sownerId,status, numeric counters, and timestamp strings in the order they appear in theSETclause
Scenario: get returns undefined when no row exists
- WHEN
surrealStore.get("missing")runs against an empty backend - THEN the call resolves to
undefinedandimport.store.reads_total{outcome:"success"}is incremented (a miss is still a successful read)
Scenario: update on missing id throws Job "" not found
- WHEN
surrealStore.update("missing", { status: "completed" })runs on a store with no record"missing" - THEN the call rejects with an
Errorwhose message containsJob "missing" not found
Scenario: list filters by status, ownerId, limit, offset
- WHEN
surrealStore.list({ status: "completed", ownerId: "alice", limit: 10, offset: 5 })runs - THEN the SurQL contains
WHERE status = $p0 AND owner_id = $p1,LIMIT $p2,START $p3and the parameter array is["completed", "alice", 10, 5]
Scenario: adapter failure surfaces as STORAGE_ERROR
- WHEN the underlying
SurrealAdapter.querythrows - THEN the store's call rejects with an
ImportErrorwhosecode === IMPORT_CODES.STORAGE_ERRORandcauseis the original throwable
Scenario: invalid config throws INVALID_OPTIONS
- WHEN
new SurrealImportStore({ db: adapter, table: "" })runs - THEN it throws
ImportErrorwithcode === IMPORT_CODES.INVALID_OPTIONSandcause instanceof ZodError
Verified by: src/store/surreal/surreal-import-store.test.ts::"create issues a CREATE SET statement with positional params" AND src/store/surreal/surreal-import-store.test.ts::"get returns undefined when no row exists" AND src/store/surreal/surreal-import-store.test.ts::"update on missing id throws Job not found" AND src/store/surreal/surreal-import-store.test.ts::"list filters by status, ownerId, limit, offset" AND src/store/surreal/surreal-import-store.test.ts::"adapter failure surfaces as STORAGE_ERROR on read" AND src/store/surreal/surreal-import-store.test.ts::"invalid config throws INVALID_OPTIONS"
Requirement: SurrealDB schema migration
The package SHALL ship surql/tables/import_job.surql defining the import_job table SCHEMAFULL with fields owner_id (string), status (string) constrained to the ImportJobStatus enum, total_rows (int), processed_rows (int), success_count (int), error_count (int), errors (array), metadata (option<object>), created_at (datetime), updated_at (datetime), completed_at (option<datetime>), plus indexes on owner_id, status, and created_at. The migration SHALL use the canonical DEFINE TABLE OVERWRITE / DEFINE FIELD OVERWRITE form mirroring packages/api/api-plugin-events/surql/tables/event.surql. Hosts MAY apply the migration via @suaveplan/api-plugin-database's migration runner or directly via adapter.query(readFileSync(...)).
Scenario: migration declares the canonical schema
- WHEN the integration test reads and applies
surql/tables/import_job.surql - THEN the SurrealDB instance subsequently accepts a
CREATE import_job:j1 SET owner_id = 'alice', status = 'created', …statement and rejects astatus = 'bogus'write
Verified by: tests/integration/surreal-import-store.test.ts::"migration enables canonical writes and rejects invalid status"
Requirement: Integration round-trip against a real SurrealDB instance
The package SHALL ship tests/integration/surreal-import-store.test.ts using SurrealDBFixture from @suaveplan/testing-integration-surrealdb. The test SHALL apply the .surql migration once in beforeAll, then create a record, get it back, update it, list with a status filter, and delete it — all against the real container. The integration test SHALL also assert that update("missing", …) rejects with the Job "missing" not found message and that the errors array round-trips intact.
Scenario: full round-trip against testcontainer
- WHEN the integration test runs against a started
SurrealDBFixture - THEN each store operation completes successfully and the final
getafterdeletereturnsundefined
Verified by: tests/integration/surreal-import-store.test.ts::"round-trips a record through the real SurrealDB instance" AND tests/integration/surreal-import-store.test.ts::"update on missing id surfaces the canonical message against a real instance"
Requirement: OTel store counters
The package SHALL extend getImportInstruments() to expose two additional counters — import.store.writes_total and import.store.reads_total — both labelled by outcome. The counter pair SHALL be cached on the same bundle as the existing four instruments and SHALL rebind through resetImportInstruments(). SurrealImportStore SHALL emit on every adapter call; MemoryImportStore is out of scope for this requirement (it is an in-process test/dev adapter without observable backend health).
Scenario: bundle exposes the two new counters
- WHEN a caller invokes
getImportInstruments() - THEN the returned object exposes
storeWritesTotalandstoreReadsTotalCounter instruments in addition to the existing four
Verified by: src/otel/instruments.test.ts::"bundle exposes the store write and read counters"
Requirement: Subpath export for the SurrealDB store
The package SHALL declare the subpath export ./store/surreal/surreal-import-store in package.json#exports so consumers can tree-shake the SurrealDB store without pulling in the in-memory implementation. The src/store/surreal/index.ts barrel SHALL use explicit named re-exports (no export *).
Scenario: subpath export exists and the barrel uses named exports
- WHEN the package's
exportsmap is enumerated and the new barrel is read - THEN the map contains
./store/surreal/surreal-import-storeand the barrel contains noexport *lines
Verified by: src/plugin/plugin.spec-conformance.test.ts::"package exposes a subpath export for the SurrealDB store"
Requirement: SSE progress push — GET /imports/:id/progress
The package SHALL expose GET /imports/:id/progress returning
Content-Type: text/event-stream; charset=utf-8, mounted under the existing
requireAuth() middleware. Frames SHALL be driven directly off the existing
ImportService.bus events (import.progress, import.completed, import.failed);
NO polling loop over the store is permitted. Every frame SHALL carry the canonical
ImportProgress JSON payload. Three SSE event kinds SHALL be emitted: progress
(every import.progress tick), complete (terminal, on import.completed),
and failed (terminal, on import.failed). The stream SHALL close cleanly after
a terminal event. The handler SHALL respect the request's AbortSignal (client
disconnect) by detaching the bus listener and incrementing
import.sse.disconnected{reason: "client"}. Foreign-owner or missing-job lookups
SHALL return HTTP 404 RFC 7807 problem+json with code === IMPORT.NOT_FOUND
(anti-enumeration parity with GET /:id). Anonymous requests SHALL return HTTP
401 problem+json via the existing auth middleware.
The package SHALL extend getImportInstruments() with three additional counters:
import.sse.connected (Counter), import.sse.events_sent (Counter, labelled by
kind ∈ "progress" | "complete" | "failed"), import.sse.disconnected
(Counter, labelled by reason ∈ "complete" | "failed" | "client"). The counters
SHALL be cached on the same bundle as the existing six instruments and SHALL
rebind through resetImportInstruments().
Scenario: opens a text/event-stream and emits an initial progress frame
- WHEN an authenticated caller
GET /imports/:id/progressfor a job they own - THEN the response is HTTP 200 with
content-type: text/event-stream; charset=utf-8and the first frame isevent: progress\ndata: <ImportProgress JSON>\n\n
Scenario: emits a progress frame for every bus progress event
- WHEN the service emits
import.progresswhile the client is connected - THEN the stream writes a frame whose first non-id line is
event: progressand whosedata:line decodes to anImportProgressobject with the matchingprocessedRows
Scenario: emits a complete frame and closes on import.completed
- WHEN the service emits
import.completed - THEN the stream writes a frame whose first non-id line is
event: completeand then closes; subsequent reads from the stream returndone: true
Scenario: emits a failed frame and closes on import.failed
- WHEN the service emits
import.failed - THEN the stream writes a frame whose first non-id line is
event: failedand then closes
Scenario: returns 404 problem+json for a foreign-owner job
- WHEN alice owns job
j1and bob requestsGET /imports/j1/progress - THEN the response is HTTP 404 with
content-type: application/problem+jsonand a body whosecode === "IMPORT.NOT_FOUND"
Scenario: returns 401 problem+json when the caller is anonymous
- WHEN an anonymous caller requests
GET /imports/:id/progress - THEN the response is HTTP 401 with
content-type: application/problem+json
Scenario: detaches the listener and increments disconnect counter on client abort
- WHEN the client aborts the request before a terminal event
- THEN the handler unsubscribes from the bus and increments
import.sse.disconnected{reason:"client"}
Scenario: increments import.sse counters on connect, send, and disconnect
- WHEN a connection opens, two progress events fire, and a terminal
completeevent closes the stream - THEN
import.sse.connectedincrements by 1,import.sse.events_sent{kind}increments by 3 (2×progress+ 1×complete), andimport.sse.disconnected{reason:"complete"}increments by 1
Verified by:
src/routes/progress-sse/progress-sse.test.ts::"opens a text/event-stream and emits an initial progress frame"
AND src/routes/progress-sse/progress-sse.test.ts::"emits a progress frame for every bus progress event"
AND src/routes/progress-sse/progress-sse.test.ts::"emits a complete frame and closes on import.completed"
AND src/routes/progress-sse/progress-sse.test.ts::"emits a failed frame and closes on import.failed"
AND src/routes/progress-sse/progress-sse.test.ts::"returns 404 problem+json for a foreign-owner job"
AND src/routes/progress-sse/progress-sse.test.ts::"returns 401 problem+json when the caller is anonymous"
AND src/routes/progress-sse/progress-sse.test.ts::"detaches the listener and increments disconnect counter on client abort"
AND src/routes/progress-sse/progress-sse.test.ts::"increments import.sse counters on connect, send, and disconnect"
AND src/otel/instruments.test.ts::"bundle exposes the SSE counters"