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
ExportErroris thrown withcode === EXPORT.INVALID_CONFIGand theZodErroris attached ascause
Scenario: caller-supplied options flow through to onReady
- WHEN the registry calls
onReady(ctx)aftercreateExportPlugin({ basePath: "/v1/exports", maxRows: 100 }) - THEN the routes are mounted at
/v1/exportsandrunExportagainst a 101-row provider rejects withEXPORT.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
AAletter
Scenario: numeric vs. inline-string cells
- WHEN a row contains a number
42and a string"forty-two" - THEN the numeric cell has no
t="…"attribute and the string cell hast="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.fetchresolves with 51 rows andconfig.maxRows === 50 - THEN
runExportrejects withEXPORT.ROW_LIMIT_EXCEEDED
Scenario: file-size guard
- WHEN the exporter result
byteSizeexceedsconfig.maxFileSizeBytes - THEN
runExportrejects withEXPORT.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 applystatus/format/resourcefilters fromExportJobListQuerySchema, thenoffset/limitwindow the in-memoryjobsmap.POST /SHALL look updeps.providers.get(body.resource), return404 { error }on miss, record a freshExportJob, firedeps.onExportStarted, run the export throughprovider.fetch→inferColumns→getExporter→exporter.export, enforce row + size caps (400 { error }on breach), update the job tocompleted, firedeps.onExportCompleted, and respond200 { job, downloadUrl }.GET /:idSHALL return the job or404 { error: "Export job not found" }.DELETE /:idSHALL remove the job (200 { success: true }) or404 { 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 aresourcenot indeps.providers - THEN the response is
404with{ 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
400and the job ends instatus: "failed"with a row-limit error message
Scenario: GET list filter + pagination
- WHEN three jobs exist (two
completed, onefailed) and the query is?status=completed&limit=1 - THEN the response carries
total: 2anditems.length === 1
Scenario: DELETE missing job
- WHEN
DELETE /missing-idis 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"anderror.exportCodenarrows 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 ofexport.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.tsis read - THEN the file contains both
./error-codes/error-codes.jsand./services/registry-augmentation.jsimport 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
ExportJobStoreis 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 thenget("a") - THEN the resolved job deep-equals the input
Scenario: list filter conjunction
- WHEN three jobs differ on
status/formatand the query is{ status: "completed", format: "csv", limit: 10, offset: 0 } - THEN only the matching job appears in
itemsandtotal === 1
Scenario: pagination
- WHEN five jobs are stored and the query is
{ limit: 2, offset: 2 } - THEN
items.length === 2andtotal === 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 thenget("abc")resolves - THEN the resolved job carries the same
format,resource,status,createdAtas the input
Scenario: list with filters
- WHEN three rows are seeded (two
completed, onefailed) andlist({ status: "completed", limit: 10, offset: 0 })is called - THEN
items.length === 2andtotal === 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
ExportErrorwhosecode === "EXPORT.JOB_PERSISTENCE_FAILED"and whosecauseis the underlying error
Scenario: invalid config rejects at construction time
- WHEN
new SurrealExportStore({})is called without adbadapter - THEN
ExportErroris thrown withcode === "EXPORT.INVALID_CONFIG"and theZodErroris attached ascause
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/**/*.tsnon-test files - THEN
JOB_PERSISTENCE_FAILEDis present insrc/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
completedjob for theusersresource exists, the provider returns one row, and aGET /{id}/downloadrequest is issued - THEN the response is
200, theContent-Typeistext/csv, theContent-Dispositioncarriesattachment; filename="…", and the body parses as RFC 4180 CSV
Scenario: missing job returns 404 problem-details
- WHEN
GET /missing/downloadis issued and no job is stored undermissing - THEN the response is
404, theContent-Typeisapplication/problem+json, and the body carries{ code: "EXPORT.JOB_NOT_FOUND" }
Scenario: not-ready job returns 409 problem-details
- WHEN
GET /pending-job/downloadis issued and the stored job is instatus: "processing" - THEN the response is
409, theContent-Typeisapplication/problem+json, and the body carries{ code: "EXPORT.JOB_NOT_READY" }
Scenario: missing provider returns 404 problem-details
- WHEN
GET /{id}/downloadis issued for a job whoseresourcehas no entry indeps.providers - THEN the response is
404with{ code: "EXPORT.PROVIDER_NOT_FOUND" }
Scenario: provider failure returns 502 problem-details
- WHEN
provider.fetchrejects during aGET /{id}/downloadrequest - THEN the response is
502with{ 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"anderror.exportCodenarrows 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", andtype: "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 nocodefield
Scenario: unknown throwable falls through to internal 500
- WHEN
renderProblem("raw-string")runs - THEN the returned object has
status: 500,title: "InternalError", anddetail: "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
200download response is produced for acsvjob - THEN an in-memory
MeterProvider's exporter sees one increment ofexport.download.served{format=csv}
Scenario: failed counter increments on not-ready
- WHEN a
409download response is produced because the job isprocessing - 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 withSurrealExportStore) underctx.registerService("exportJobStore", store)and the plugin'sonReadyruns - THEN the download handler resolves the job through
store.get(id)
Scenario: absent slot keeps in-memory fallback
- WHEN no
exportJobStoreslot 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"