Files
2026-05-27 23:29:53 +01:00

28 KiB

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

Package: @suaveplan/api-plugin-export Status: SHIPPED

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). The barrel SHALL additionally re-export ExportJobStore (type), MemoryExportJobStore, SurrealExportStore, SurrealExportStoreConfig (type), and the JOB_PERSISTENCE_FAILED member of EXPORT_CODES. 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, ./store/types/types, ./store/memory/memory-store, ./store/surreal/surreal-store. The files array SHALL include "surql" so the migration ships to npm.

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

Scenario: store subpath exports resolve

  • WHEN a consumer imports from @suaveplan/api-plugin-export/store/surreal/surreal-store
  • THEN the import resolves to a module exporting SurrealExportStore

Verified by: src/api-plugin-export.spec-conformance.test.ts::"side-effect imports of error-codes and registry-augmentation are present in index.ts" AND src/api-plugin-export.spec-conformance.test.ts::"store subpath exports resolve from package.json"


Requirement: Export job store contract (ExportJobStore)

The package SHALL expose an ExportJobStore interface defining the persistence contract for export jobs. The interface SHALL declare async methods get(id), set(id, job), delete(id), list(query?), has(id), and clear(). list(query?) SHALL accept an ExportJobStoreListQuery carrying optional status, format, resource filters and required limit, offset pagination, returning { items: ExportJob[]; total: number }. The interface SHALL be runtime-free (a type-only export) so consumers can implement it without pulling in any backend dependency.

Scenario: interface members

  • WHEN ExportJobStore is consumed as a TypeScript type
  • THEN it declares the six async methods above with the documented signatures

Verified by: src/store/memory/memory-store.test.ts::"MemoryExportJobStore satisfies the ExportJobStore contract"


Requirement: In-memory backend (MemoryExportJobStore)

MemoryExportJobStore SHALL implement ExportJobStore over an internal Map<string, ExportJob>. Every method SHALL resolve synchronously-derived results through a Promise. list({ status, format, resource, limit, offset }) SHALL apply each provided filter as an AND-conjunction over the in-memory map values, then window with offset/limit, returning total as the pre-window match count.

Scenario: round-trip a job

  • WHEN set("a", job) is called and then get("a")
  • THEN the resolved job deep-equals the input

Scenario: list filter conjunction

  • WHEN three jobs differ on status / format and the query is { status: "completed", format: "csv", limit: 10, offset: 0 }
  • THEN only the matching job appears in items and total === 1

Scenario: pagination

  • WHEN five jobs are stored and the query is { limit: 2, offset: 2 }
  • THEN items.length === 2 and total === 5

Scenario: clear drains the store

  • WHEN clear() is called after jobs are stored
  • THEN list() resolves with { items: [], total: 0 }

Verified by: src/store/memory/memory-store.test.ts::"set + get round-trips a job" AND src/store/memory/memory-store.test.ts::"list applies AND-conjunction filters" AND src/store/memory/memory-store.test.ts::"list paginates with offset and limit" AND src/store/memory/memory-store.test.ts::"clear empties the store"


Requirement: SurrealDB backend (SurrealExportStore)

SurrealExportStore SHALL implement ExportJobStore over a SurrealDB export_job table reached through @suaveplan/surrealdb SurrealAdapter. All SurQL SHALL use positional $p0…$pN placeholders (genesis-canonical pattern; the adapter rewrites positional arrays into named bindings). The constructor SHALL accept a Zod-validated SurrealExportStoreConfig carrying db: SurrealAdapter and an optional tableName: string defaulting to "export_job". Every method SHALL wrap underlying driver / network / parse failures in ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED) with the original error chained as cause. set(id, job) SHALL upsert the row via UPSERT type::record($p0, $p1) CONTENT $p2 RETURN AFTER. delete(id) SHALL be idempotent — deleting a missing row SHALL resolve without throwing. list({ status, format, resource, limit, offset }) SHALL emit a SELECT * FROM <tableName> with an AND-conjunction WHERE clause and a parallel SELECT count() AS count FROM <tableName> GROUP ALL (also filtered) to derive total in a single round-trip latency via Promise.all.

Scenario: round-trip a job through SurrealDB

  • WHEN set("abc", job) resolves and then get("abc") resolves
  • THEN the resolved job carries the same format, resource, status, createdAt as the input

Scenario: list with filters

  • WHEN three rows are seeded (two completed, one failed) and list({ status: "completed", limit: 10, offset: 0 }) is called
  • THEN items.length === 2 and total === 2

Scenario: delete idempotency

  • WHEN delete("missing-id") is called against an empty table
  • THEN the call resolves with no error

Scenario: driver failure surfaces JOB_PERSISTENCE_FAILED

  • WHEN the underlying adapter query(...) rejects (e.g. connection drop, schema mismatch)
  • THEN the call rejects with ExportError whose code === "EXPORT.JOB_PERSISTENCE_FAILED" and whose cause is the underlying error

Scenario: invalid config rejects at construction time

  • WHEN new SurrealExportStore({}) is called without a db adapter
  • THEN ExportError is thrown with code === "EXPORT.INVALID_CONFIG" and the ZodError is attached as cause

Verified by: tests/integration/surreal-store.test.ts::"set + get round-trips a job through SurrealDB" AND tests/integration/surreal-store.test.ts::"list applies status filter and pagination through SurrealDB" AND tests/integration/surreal-store.test.ts::"delete on a missing row is idempotent" AND src/store/surreal/surreal-store.test.ts::"driver failure surfaces JOB_PERSISTENCE_FAILED" AND src/store/surreal/surreal-store.test.ts::"invalid config rejects with INVALID_CONFIG"


Requirement: JOB_PERSISTENCE_FAILED error code

EXPORT_CODES SHALL include the additional code JOB_PERSISTENCE_FAILED. The code SHALL be thrown from at least one production path — the SurrealExportStore driver-failure branch — and SHALL be pinned by api-plugin-export.spec-conformance.test.ts.

Scenario: registry membership

  • WHEN Object.values(EXPORT_CODES) is enumerated
  • THEN the result contains "EXPORT.JOB_PERSISTENCE_FAILED"

Scenario: conformance pin

  • WHEN the spec-conformance suite walks the registry against src/**/*.ts non-test files
  • THEN JOB_PERSISTENCE_FAILED is present in src/store/surreal/surreal-store.ts

Verified by: src/error-codes/error-codes.test.ts::"JOB_PERSISTENCE_FAILED narrows via the public type union" AND src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"


Requirement: SurrealDB migration (export_job.surql)

The package SHALL ship surql/tables/export_job.surql defining the export_job table as SCHEMAFULL with one DEFINE FIELD OVERWRITE per ExportJobSchema property (format, resource, status, filters, filename, totalRows, exportedRows, fileSize, error, createdAt, completedAt) and indexes on (status), (format), (resource), (createdAt). The migration SHALL be idempotent (DEFINE TABLE OVERWRITE, DEFINE FIELD OVERWRITE, DEFINE INDEX OVERWRITE) so consecutive boots succeed.

Scenario: migration applies cleanly

  • WHEN the migration is loaded twice against a fresh SurrealDB testcontainer
  • THEN both applies succeed without error

Verified by: tests/integration/surreal-store.test.ts::"export_job migration is idempotent"


Requirement: GET /:id/download endpoint

createExportRoutes(deps) SHALL register an additional route GET /{id}/download that serves the rendered file body for a previously completed export job. The handler SHALL look up the job via deps.jobStore?.get(id) when an ExportJobStore is wired and otherwise via deps.jobs.get(id) against the in-memory map. When the job is missing the handler SHALL respond 404 application/problem+json with code === "EXPORT.JOB_NOT_FOUND". When the job is present but status !== "completed" the handler SHALL respond 409 application/problem+json with code === "EXPORT.JOB_NOT_READY". When the job is present but the originally registered DataProvider has been removed from deps.providers, the handler SHALL respond 404 application/problem+json with code === "EXPORT.PROVIDER_NOT_FOUND". On any throw from provider.fetch(...) or the exporter the handler SHALL respond 502 application/problem+json with code === "EXPORT.PROVIDER_FETCH_FAILED". On success the handler SHALL respond 200 with Content-Type set to the writer's mimeType (text/csv / application/json / application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) and Content-Disposition: attachment; filename="<job.filename>".

Scenario: happy path streams CSV body

  • WHEN a completed job for the users resource exists, the provider returns one row, and a GET /{id}/download request is issued
  • THEN the response is 200, the Content-Type is text/csv, the Content-Disposition carries attachment; filename="…", and the body parses as RFC 4180 CSV

Scenario: missing job returns 404 problem-details

  • WHEN GET /missing/download is issued and no job is stored under missing
  • THEN the response is 404, the Content-Type is application/problem+json, and the body carries { code: "EXPORT.JOB_NOT_FOUND" }

Scenario: not-ready job returns 409 problem-details

  • WHEN GET /pending-job/download is issued and the stored job is in status: "processing"
  • THEN the response is 409, the Content-Type is application/problem+json, and the body carries { code: "EXPORT.JOB_NOT_READY" }

Scenario: missing provider returns 404 problem-details

  • WHEN GET /{id}/download is issued for a job whose resource has no entry in deps.providers
  • THEN the response is 404 with { code: "EXPORT.PROVIDER_NOT_FOUND" }

Scenario: provider failure returns 502 problem-details

  • WHEN provider.fetch rejects during a GET /{id}/download request
  • THEN the response is 502 with { code: "EXPORT.PROVIDER_FETCH_FAILED" }

Verified by: src/routes/download/download.test.ts::"streams a completed CSV job as application/csv with attachment disposition" AND src/routes/download/download.test.ts::"returns 404 problem-details when the job is missing" AND src/routes/download/download.test.ts::"returns 409 problem-details when the job is not yet completed" AND src/routes/download/download.test.ts::"returns 404 problem-details when the provider was deregistered" AND src/routes/download/download.test.ts::"returns 502 problem-details when provider.fetch rejects" AND src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"


Requirement: JOB_NOT_READY error code

EXPORT_CODES SHALL include the additional code JOB_NOT_READY. The code SHALL be thrown from at least one production path — the runDownloadExport not-ready branch — and SHALL be pinned by api-plugin-export.spec-conformance.test.ts. ExportError SHALL narrow error.exportCode through the public type union.

Scenario: registry membership

  • WHEN Object.values(EXPORT_CODES) is enumerated
  • THEN the result contains "EXPORT.JOB_NOT_READY"

Scenario: code narrowing

  • WHEN throw new ExportError(EXPORT_CODES.JOB_NOT_READY, "…") is caught
  • THEN error.code === "EXPORT.JOB_NOT_READY" and error.exportCode narrows to the union

Verified by: src/error-codes/error-codes.test.ts::"JOB_NOT_READY narrows via the public type union" AND src/api-plugin-export.spec-conformance.test.ts::"every EXPORT_* code is thrown from a production path"


Requirement: Per-package RFC 7807 problem-details renderer

The package SHALL ship a per-package RFC 7807 renderer at src/routes/problem-json/problem-json.ts exporting PROBLEM_JSON_CONTENT_TYPE = "application/problem+json", a ProblemDetails shape, renderProblem(err) (pure mapping), and respondProblemJson(c, err) (writes the Content-Type header and JSON body to a Hono context). SuaveplanError instances SHALL surface their code and read context.statusCode for the HTTP status; non-numeric or sub-400 values SHALL coerce to 500. Plain Error instances SHALL render as { type: "urn:suaveplan:error:internal", status: 500 }. Unknown throwables SHALL render as { type: "urn:suaveplan:error:internal", title: "InternalError", status: 500 }.

Scenario: SuaveplanError maps to typed problem-details

  • WHEN renderProblem(new ExportError(EXPORT_CODES.JOB_NOT_READY, "...", { context: { statusCode: 409 } })) runs
  • THEN the returned object has status: 409, code: "EXPORT.JOB_NOT_READY", and type: "urn:suaveplan:error:export.job_not_ready"

Scenario: plain Error falls through to internal 500

  • WHEN renderProblem(new Error("boom")) runs
  • THEN the returned object has status: 500, title: "Error", and no code field

Scenario: unknown throwable falls through to internal 500

  • WHEN renderProblem("raw-string") runs
  • THEN the returned object has status: 500, title: "InternalError", and detail: "Internal Server Error"

Verified by: src/routes/problem-json/problem-json.test.ts::"maps SuaveplanError to typed problem-details with status from context" AND src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for plain Error" AND src/routes/problem-json/problem-json.test.ts::"falls through to internal 500 for unknown throwables"


Requirement: Download OTel instruments

getExportInstruments() SHALL additionally expose downloadServed: Counter (registered as export.download.served, labelled { format }) and downloadFailed: Counter (registered as export.download.failed, labelled { reason }). The download handler SHALL emit exactly one counter increment per request — downloadServed{format} on the ok branch and downloadFailed{reason} on every failure branch with reason ∈ { "not-found", "not-ready", "provider-not-found", "failed" }.

Scenario: served counter increments on happy path

  • WHEN a 200 download response is produced for a csv job
  • THEN an in-memory MeterProvider's exporter sees one increment of export.download.served{format=csv}

Scenario: failed counter increments on not-ready

  • WHEN a 409 download response is produced because the job is processing
  • THEN the exporter sees one increment of export.download.failed{reason=not-ready}

Verified by: src/otel/instruments.test.ts::"emits export.download.served on a successful CSV download" AND src/otel/instruments.test.ts::"emits export.download.failed{reason=not-ready} when the job is not completed"


Requirement: Plugin wires exportJobStore slot into routes

createExportPlugin(...) onReady SHALL look up ctx.getService("exportJobStore"). When the slot resolves to an ExportJobStore, the plugin SHALL pass it through to createExportRoutes({ …, jobStore }) so the download handler reads job state from the persistent backend rather than the in-memory Map. When the slot is undefined, the route deps SHALL omit jobStore and the download handler SHALL fall back to the inline deps.jobs adapter — preserving the existing in-memory route surface for host apps that opt out of persistence.

Scenario: wired SurrealExportStore is honoured

  • WHEN an app registers MemoryExportJobStore (interface-compatible with SurrealExportStore) under ctx.registerService("exportJobStore", store) and the plugin's onReady runs
  • THEN the download handler resolves the job through store.get(id)

Scenario: absent slot keeps in-memory fallback

  • WHEN no exportJobStore slot is registered
  • THEN the download handler still resolves jobs through deps.jobs.get(id)

Verified by: src/plugin/plugin.test.ts::"download route uses exportJobStore service when wired" AND src/routes/exports/exports.test.ts::"GET /{id}/download streams the rendered body end-to-end"