Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
# @suaveplan/api-plugin-export - Data-export plugin for @suaveplan/api: CSV (RFC 4180), JSON, and XLSX (SpreadsheetML XML) exporters, in-memory job store, OpenAPIHono route group at /exports, ExportError hierarchy, OTel instruments.
Data-export plugin for `@suaveplan/api`. Turns any registered resource into a CSV (RFC 4180), JSON, or XLSX (SpreadsheetML XML) blob through three self-contained exporter classes, a `DataProvider` registry, an in-memory job-lifecycle store, and an `OpenAPIHono` route group at the configured `basePath` (default `/exports`).
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-plugin-export`. Every production code path that rai…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/api-plugin-export`. Every production code path that rai…
- [csv](./src/exporters/csv/csv.md) — RFC 4180 CSV writer. This module ships three pure helpers — `escapeCsvField`, `formatCellValue`, `convertToCsv` — plus…
- [csv](./src/exporters/csv/csv.md) — RFC 4180 CSV writer. This module ships three pure helpers — `escapeCsvField`, `formatCellValue`, `convertToCsv` — plus…
- [json](./src/exporters/json/json.md) — JSON exporter. Projects every row to a record keyed by the column `header` (not `key`), optionally applying `column.tra…
- [json](./src/exporters/json/json.md) — JSON exporter. Projects every row to a record keyed by the column `header` (not `key`), optionally applying `column.tra…
- [xlsx](./src/exporters/xlsx/xlsx.md) — SpreadsheetML (XML) writer for the `xlsx` format. This is the dependency-free path inherited from the upstream package…
- [xlsx](./src/exporters/xlsx/xlsx.md) — SpreadsheetML (XML) writer for the `xlsx` format. This is the dependency-free path inherited from the upstream package…
- [otel](./src/otel/instruments.md) — OpenTelemetry instrument factory for `@suaveplan/api-plugin-export`. Builds a cached bundle of six instruments — five c…
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrument factory for `@suaveplan/api-plugin-export`. Builds a cached bundle of six instruments — five c…
- [plugin](./src/plugin/plugin.md) — The plugin factory for `@suaveplan/api-plugin-export`. Construction-time it validates options through `ExportPluginOpti…
- [plugin](./src/plugin/plugin.md) — The plugin factory for `@suaveplan/api-plugin-export`. Construction-time it validates options through `ExportPluginOpti…
- [download](./src/routes/download/download.md) — Implements the `GET /:id/download` route on `@suaveplan/api-plugin-export`. The handler serves the rendered file body f…
- [download](./src/routes/download/download.md) — Implements the `GET /:id/download` route on `@suaveplan/api-plugin-export`. The handler serves the rendered file body f…
- [exports](./src/routes/exports/exports.md) — OpenAPIHono route group exposed at the configured `basePath` (default `/exports`). Wires four endpoints — `GET /`, `POS…
- [exports](./src/routes/exports/exports.md) — OpenAPIHono route group exposed at the configured `basePath` (default `/exports`). Wires four endpoints — `GET /`, `POS…
- [post-handler](./src/routes/exports/post-handler.md) — Implementation of the `POST /exports` handler, extracted from `exports.ts` so the route-registration file stays under t…
- [problem-json](./src/routes/problem-json/problem-json.md) — `@suaveplan/api-plugin-export` returns RFC 7807 `application/problem+json` bodies for every failure surface on its rout…
- [problem-json](./src/routes/problem-json/problem-json.md) — `@suaveplan/api-plugin-export` returns RFC 7807 `application/problem+json` bodies for every failure surface on its rout…
- [services](./src/services/registry-augmentation.md) — This file is a type-only `declare module "@suaveplan/api-plugin/service-registry"` block. It adds two slots to the shar…
- [export](./src/services/export/export-service.md) — Service-level orchestration for the export plugin. This module wires the three exporter classes (`CsvExporter`, `JsonEx…
- [export-service](./src/services/export/export-service.md) — Service-level orchestration for the export plugin. This module wires the three exporter classes (`CsvExporter`, `JsonEx…
- [registry-augmentation](./src/services/registry-augmentation.md) — This file is a type-only `declare module "@suaveplan/api-plugin/service-registry"` block. It adds two slots to the shar…
- [memory](./src/store/memory/memory-store.md) — This module ships `MemoryExportJobStore`, the in-memory implementation of the `ExportJobStore` contract. It wraps a `Ma…
- [memory-store](./src/store/memory/memory-store.md) — This module ships `MemoryExportJobStore`, the in-memory implementation of the `ExportJobStore` contract. It wraps a `Ma…
- [surreal](./src/store/surreal/surreal-store.md) — This module ships `SurrealExportStore`, the SurrealDB-backed implementation of `ExportJobStore`. It satisfies the same…
- [surreal-store](./src/store/surreal/surreal-store.md) — This module ships `SurrealExportStore`, the SurrealDB-backed implementation of `ExportJobStore`. It satisfies the same…
- [types](./src/store/types/types.md) — This module declares the `ExportJobStore` interface — the formal persistence contract for export jobs. Before this modu…
- [types](./src/store/types/types.md) — This module declares the `ExportJobStore` interface — the formal persistence contract for export jobs. Before this modu…
- [types](./src/types/types.md) — This module holds the Zod-fronted public type surface for `@suaveplan/api-plugin-export`: the `ExportFormat` / `ExportS…
- [types](./src/types/types.md) — This module holds the Zod-fronted public type surface for `@suaveplan/api-plugin-export`: the `ExportFormat` / `ExportS…
<!-- end:auto-docs -->
## Quick start
```ts
import { createPluginRegistry } from "@suaveplan/api-plugin";
import {
createExportPlugin,
type DataProvider,
} from "@suaveplan/api-plugin-export";
const registry = createPluginRegistry();
registry.register(createExportPlugin({ basePath: "/v1/exports" }));
await registry.init();
await registry.ready();
const providers = registry.getService("exportProviders");
if (providers) {
const userProvider: DataProvider = {
resource: "users",
fetch: async () => db.select("user").get(),
};
providers.set(userProvider.resource, userProvider);
}
```
```http
POST /v1/exports/ → { format: "csv", resource: "users" }
GET /v1/exports/ → { items: ExportJob[], total }
GET /v1/exports/{id} → ExportJob
DELETE /v1/exports/{id} → { success: true }
```
## Modules
- [`plugin/plugin`](src/plugin/plugin.md) — plugin factory + lifecycle hooks.
- [`routes/exports/exports`](src/routes/exports/exports.md) — OpenAPIHono route group.
- [`routes/exports/post-handler`](src/routes/exports/post-handler.md) — `POST /` handler.
- [`services/export/export-service`](src/services/export/export-service.md) — `getExporter`, `inferColumns`, `runExport`.
- [`exporters/csv/csv`](src/exporters/csv/csv.md) — RFC 4180 CSV writer.
- [`exporters/json/json`](src/exporters/json/json.md) — JSON writer.
- [`exporters/xlsx/xlsx`](src/exporters/xlsx/xlsx.md) — SpreadsheetML XML writer.
- [`error-codes/error-codes`](src/error-codes/error-codes.md) — `EXPORT_CODES` + `ExportError`.
- [`otel/instruments`](src/otel/instruments.md) — `ExportInstruments` bundle.
- [`services/registry-augmentation`](src/services/registry-augmentation.md) — `ServiceRegistry` slot augmentation.
- [`types/types`](src/types/types.md) — Zod schemas + public types.
## Scope notes
This package ships the upstream §2 functional surface (CSV / JSON / SpreadsheetML XML exporters + in-memory job store + four-route Hono group). The upstream §3–§4 enhancements (streaming exporters, S3-backed blob store, auth/permissions/rate-limit/idempotency middleware, OOXML/ZIP writer, scheduled exports, signed download URLs, Parquet/Markdown formats) are deferred to follow-up changes — they require downstream plugins that have not yet landed .

View File

@@ -0,0 +1,144 @@
{
"name": "@suaveplan/api-plugin-export",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Data-export plugin for @suaveplan/api: CSV (RFC 4180), JSON, and XLSX (SpreadsheetML XML) exporters, in-memory job store, OpenAPIHono route group at /exports, ExportError hierarchy, OTel instruments.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./routes/exports/exports": {
"import": {
"types": "./dist/routes/exports/exports.d.ts",
"default": "./dist/routes/exports/exports.js"
}
},
"./routes/download/download": {
"import": {
"types": "./dist/routes/download/download.d.ts",
"default": "./dist/routes/download/download.js"
}
},
"./routes/problem-json/problem-json": {
"import": {
"types": "./dist/routes/problem-json/problem-json.d.ts",
"default": "./dist/routes/problem-json/problem-json.js"
}
},
"./services/export/export-service": {
"import": {
"types": "./dist/services/export/export-service.d.ts",
"default": "./dist/services/export/export-service.js"
}
},
"./exporters/csv/csv": {
"import": {
"types": "./dist/exporters/csv/csv.d.ts",
"default": "./dist/exporters/csv/csv.js"
}
},
"./exporters/json/json": {
"import": {
"types": "./dist/exporters/json/json.d.ts",
"default": "./dist/exporters/json/json.js"
}
},
"./exporters/xlsx/xlsx": {
"import": {
"types": "./dist/exporters/xlsx/xlsx.d.ts",
"default": "./dist/exporters/xlsx/xlsx.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
},
"./store/types/types": {
"import": {
"types": "./dist/store/types/types.d.ts",
"default": "./dist/store/types/types.js"
}
},
"./store/memory/memory-store": {
"import": {
"types": "./dist/store/memory/memory-store.d.ts",
"default": "./dist/store/memory/memory-store.js"
}
},
"./store/surreal/surreal-store": {
"import": {
"types": "./dist/store/surreal/surreal-store.d.ts",
"default": "./dist/store/surreal/surreal-store.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "surql", "README.md"]
}

View File

@@ -0,0 +1,91 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EXPORT_CODES,
type ExportErrorCode,
} from "./error-codes/error-codes.js";
function walk(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stats = statSync(full);
if (stats.isDirectory()) {
walk(full, acc);
} else if (
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".types.ts")
) {
acc.push(full);
}
}
return acc;
}
describe("api-plugin-export spec conformance", () => {
it("every EXPORT_* code is thrown from a production path", {
timeout: 5000,
}, () => {
const srcDir = join(import.meta.dirname, ".");
const files = walk(srcDir);
const thrown = new Set<ExportErrorCode>();
for (const file of files) {
const content = readFileSync(file, "utf8");
for (const value of Object.values(EXPORT_CODES)) {
const key = value.split(".")[1] as string;
const ref = `EXPORT_CODES.${key}`;
if (
content.includes("new ExportError(") &&
content.includes(ref)
) {
thrown.add(value);
}
}
}
const missing = Object.values(EXPORT_CODES).filter(
(c) => !thrown.has(c)
);
expect(missing).toEqual([]);
});
it("side-effect imports of error-codes and registry-augmentation are present in index.ts", {
timeout: 5000,
}, () => {
const content = readFileSync(
join(import.meta.dirname, "index.ts"),
"utf8"
);
expect(content).toContain("./error-codes/error-codes.js");
expect(content).toContain("./services/registry-augmentation.js");
});
it("no export * appears in any feature-module barrel", {
timeout: 5000,
}, () => {
const srcDir = join(import.meta.dirname, ".");
const files = walk(srcDir).filter(
(f) => f.endsWith("/index.ts") && !f.endsWith("/src/index.ts")
);
for (const file of files) {
const content = readFileSync(file, "utf8");
expect(content).not.toMatch(/^\s*export\s+(type\s+)?\*\s+from/m);
}
});
it("store subpath exports resolve from package.json", {
timeout: 5000,
}, () => {
const pkgPath = join(import.meta.dirname, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
exports: Record<string, unknown>;
};
expect(pkg.exports).toHaveProperty("./store/types/types");
expect(pkg.exports).toHaveProperty("./store/memory/memory-store");
expect(pkg.exports).toHaveProperty("./store/surreal/surreal-store");
expect(pkg.exports).toHaveProperty("./routes/download/download");
expect(pkg.exports).toHaveProperty(
"./routes/problem-json/problem-json"
);
});
});

View File

@@ -0,0 +1,62 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable error-code registry for `@suaveplan/api-plugin-export`. Every production code path that raises an error throws an `ExportError` carrying one of the seven `EXPORT.*` codes defined here. The registry is built with `defineCodes("EXPORT", […])` from `@suaveplan/error`, so each code resolves to a stable, prefixed string (e.g. `"EXPORT.ROW_LIMIT_EXCEEDED"`) at compile time. Downstream consumers branch on the string value rather than on a fragile message — refactors to wording are safe; refactors to codes are breaking and caught by the `api-plugin-export.spec-conformance.test.ts` audit.
## Features
- Eight `EXPORT.*` codes covering plugin construction, route handling, service-level guards, exporter dispatch, job lookup, and job persistence.
- A concrete `ExportError` class extending `SuaveplanError<ExportErrorCode>` so `instanceof ExportError` narrows `error.code` to the literal union.
- `cause`-preserving constructor — wrap `ZodError` (config validation), provider rejections, and downstream throws without losing the original stack.
## Usage: Basic
```ts
import { EXPORT_CODES, ExportError } from "@suaveplan/api-plugin-export";
throw new ExportError(
EXPORT_CODES.ROW_LIMIT_EXCEEDED,
"Export exceeds maximum row limit of 50000. Got 75123 rows.",
{ context: { maxRows: 50000, got: 75123 } }
);
```
## Usage: Advanced
```ts
import { EXPORT_CODES, ExportError } from "@suaveplan/api-plugin-export";
try {
await runExport(provider, "csv", undefined, filters, undefined, config);
} catch (err) {
if (err instanceof ExportError) {
switch (err.code) {
case EXPORT_CODES.ROW_LIMIT_EXCEEDED:
case EXPORT_CODES.FILE_SIZE_EXCEEDED:
return c.json({ error: err.message }, 400);
case EXPORT_CODES.PROVIDER_FETCH_FAILED:
return c.json({ error: err.message }, 502);
default:
throw err;
}
}
throw err;
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `EXPORT_CODES` | const | Registry built with `defineCodes("EXPORT", […])`. Each key is also the value's suffix. |
| `ExportErrorCode` | type | Union of the eight literal code strings. |
| `ExportError` | class | Extends `SuaveplanError<ExportErrorCode>`; exposes `error.exportCode` as a narrowed convenience field. |
## Implementation Notes
The class redeclares `exportCode` separately from `error.code` so `instanceof ExportError` flow analysis can narrow without a `as const` cast at the call site — `error.code` is typed by `SuaveplanError<C>`'s `C` parameter and the duplicate field gives downstream `switch` blocks an additional narrowed slot. Codes are deliberately exhaustive: `INVALID_CONFIG` covers plugin-construction validation, `PROVIDER_NOT_FOUND` and `JOB_NOT_FOUND` cover the two not-found surfaces emitted by the route group, `ROW_LIMIT_EXCEEDED` and `FILE_SIZE_EXCEEDED` cover the two service-level guards, `UNSUPPORTED_FORMAT` covers exporter dispatch, `PROVIDER_FETCH_FAILED` wraps third-party data-provider rejections so consumers can branch on the network/data error rather than on the exporter contract, and `JOB_PERSISTENCE_FAILED` wraps every `ExportJobStore` backend failure (driver crash, schema mismatch, parse error) so callers can branch on a persistence-layer signal independent of the exporter pipeline.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EXPORT_CODES, ExportError } from "./error-codes.js";
describe("EXPORT_CODES", () => {
it("declares the nine stable codes", { timeout: 5000 }, () => {
expect(Object.values(EXPORT_CODES).sort()).toEqual(
[
"EXPORT.FILE_SIZE_EXCEEDED",
"EXPORT.INVALID_CONFIG",
"EXPORT.JOB_NOT_FOUND",
"EXPORT.JOB_NOT_READY",
"EXPORT.JOB_PERSISTENCE_FAILED",
"EXPORT.PROVIDER_FETCH_FAILED",
"EXPORT.PROVIDER_NOT_FOUND",
"EXPORT.ROW_LIMIT_EXCEEDED",
"EXPORT.UNSUPPORTED_FORMAT",
].sort()
);
});
it("JOB_NOT_READY narrows via the public type union", {
timeout: 5000,
}, () => {
const err = new ExportError(
EXPORT_CODES.JOB_NOT_READY,
"job is still processing",
{ context: { id: "x", status: "processing", statusCode: 409 } }
);
expect(err.code).toBe("EXPORT.JOB_NOT_READY");
expect(err.exportCode).toBe("EXPORT.JOB_NOT_READY");
expect((err.context as { statusCode?: number }).statusCode).toBe(409);
});
it("JOB_PERSISTENCE_FAILED narrows via the public type union", {
timeout: 5000,
}, () => {
const root = new Error("driver crashed");
const err = new ExportError(
EXPORT_CODES.JOB_PERSISTENCE_FAILED,
"store failed",
{ cause: root, context: { table: "export_job" } }
);
expect(err.code).toBe("EXPORT.JOB_PERSISTENCE_FAILED");
expect(err.exportCode).toBe("EXPORT.JOB_PERSISTENCE_FAILED");
expect(err.cause).toBe(root);
});
it("ExportError narrows code via the public type union", {
timeout: 5000,
}, () => {
const err = new ExportError(
EXPORT_CODES.ROW_LIMIT_EXCEEDED,
"too many rows",
{ context: { rows: 100 } }
);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ExportError);
expect(err.code).toBe("EXPORT.ROW_LIMIT_EXCEEDED");
expect(err.exportCode).toBe("EXPORT.ROW_LIMIT_EXCEEDED");
expect(err.message).toBe("too many rows");
});
it("preserves the cause chain", { timeout: 5000 }, () => {
const root = new Error("boom");
const err = new ExportError(
EXPORT_CODES.PROVIDER_FETCH_FAILED,
"fetch failed",
{ cause: root }
);
expect(err.cause).toBe(root);
});
});

View File

@@ -0,0 +1,63 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-export`.
*
* Every failure path raises a {@link ExportError} carrying one of these
* seven `EXPORT.*` codes. Consumers branch on the string value:
* `error.code === EXPORT_CODES.ROW_LIMIT_EXCEEDED`.
*
* - `INVALID_CONFIG` — `createExportPlugin(options)` received options
* failing `ExportPluginOptionsSchema`. `cause` is the `ZodError`.
* - `PROVIDER_NOT_FOUND` — `POST /exports` referenced a resource not
* present in the provider registry; surfaces as a `404` response.
* - `ROW_LIMIT_EXCEEDED` — provider returned more rows than
* `maxRows`; thrown from `runExport` and surfaced as `400` from the
* POST route.
* - `FILE_SIZE_EXCEEDED` — encoded byte size exceeded
* `maxFileSizeBytes`.
* - `UNSUPPORTED_FORMAT` — `getExporter` saw a value outside
* `csv | json | xlsx`.
* - `PROVIDER_FETCH_FAILED` — `provider.fetch` rejected; the cause is
* the underlying error.
* - `JOB_NOT_FOUND` — `GET /:id` / `DELETE /:id` / `GET /:id/download`
* referenced an absent job id; surfaced as `404`.
* - `JOB_NOT_READY` — `GET /:id/download` referenced a job whose
* `status` is not `"completed"` (still `pending` / `processing` /
* `failed`). Surfaced as `409 Conflict`.
* - `JOB_PERSISTENCE_FAILED` — the `SurrealExportStore` (or any other
* `ExportJobStore` backend) failed to persist / read a job row.
* `cause` carries the underlying driver / network / parse error.
*/
export const EXPORT_CODES = defineCodes("EXPORT", [
"INVALID_CONFIG",
"PROVIDER_NOT_FOUND",
"ROW_LIMIT_EXCEEDED",
"FILE_SIZE_EXCEEDED",
"UNSUPPORTED_FORMAT",
"PROVIDER_FETCH_FAILED",
"JOB_NOT_FOUND",
"JOB_NOT_READY",
"JOB_PERSISTENCE_FAILED",
] as const);
export type ExportErrorCode = (typeof EXPORT_CODES)[keyof typeof EXPORT_CODES];
/**
* Concrete error class for the api-plugin-export package. Extends
* {@link SuaveplanError} so `instanceof ExportError` narrows
* `error.code` to {@link ExportErrorCode}.
*/
export class ExportError extends SuaveplanError<ExportErrorCode> {
public readonly exportCode: ExportErrorCode;
public constructor(
code: ExportErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.exportCode = code;
}
}

View File

@@ -0,0 +1,60 @@
# `csv.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 4180 CSV writer. This module ships three pure helpers — `escapeCsvField`, `formatCellValue`, `convertToCsv` — plus the `CsvExporter` class implementing the `Exporter` interface. The exporter is deliberately dependency-free: no `csv-stringify`, no `papaparse`. The quoting rules are encoded directly so behaviour is predictable across runtimes and easy to audit against §2 of RFC 4180.
## Features
- Configurable delimiter (defaults to `,`).
- Configurable header inclusion (defaults to including the header row).
- Embedded `"`, `\r`, `\n`, and delimiter characters trigger field quoting; embedded `"` are doubled.
- `Date` values serialise via `toISOString()`; `null` / `undefined` become empty strings; plain objects are JSON-stringified.
- Per-column `transform` function runs before formatting so callers can normalise values without pre-processing the dataset.
## Usage: Basic
```ts
import { CsvExporter } from "@suaveplan/api-plugin-export";
const result = new CsvExporter().export(
[{ id: 1, name: "Ada" }],
[{ key: "id", header: "ID" }, { key: "name", header: "Name" }]
);
// result.content === 'ID,Name\n1,Ada'
// result.mimeType === 'text/csv'
```
## Usage: Advanced
```ts
import { CsvExporter, type ColumnDefinition } from "@suaveplan/api-plugin-export";
const columns: ColumnDefinition[] = [
{ key: "joinedAt", header: "Joined", transform: (v) => new Date(v as string).toUTCString() },
{ key: "tags", header: "Tags", transform: (v) => (v as string[]).join(";") },
];
const result = new CsvExporter().export(
[{ joinedAt: "2026-01-01T00:00:00Z", tags: ["alpha", "beta"] }],
columns,
{ delimiter: ";", includeHeaders: false }
);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `escapeCsvField(value, delimiter)` | function | Pure — wraps quotes/CR/LF/delimiter fields. |
| `formatCellValue(value)` | function | Pure — coerces unknowns to CSV cell strings. |
| `convertToCsv(data, columns, options?)` | function | Pure — emits the full CSV body. |
| `CsvExporter` | class | Implements `Exporter` with `format: "csv"`, MIME `text/csv`. |
## Implementation Notes
The exporter holds no state — every call to `export(...)` constructs a fresh result. Byte size is computed with `new TextEncoder()` so CJK and emoji-bearing payloads return the same byte count Hono will write to the wire (UTF-8). The filename is derived from `Date.now()` so multiple exports in flight do not collide; callers wanting a deterministic filename pass `body.filename` on the route layer and the route uses that string for the response `downloadUrl`. The `transform` cast (`as ((v: unknown) => unknown) | undefined`) is local to one line because `ColumnDefinition.transform` is typed `z.any()` to keep the Zod schema portable; the runtime contract is that it is callable. Errors thrown from `transform` propagate to the caller — the exporter does not catch them so the route layer can surface them as `400` with the original message.

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CsvExporter,
convertToCsv,
escapeCsvField,
formatCellValue,
} from "./csv.js";
describe("escapeCsvField", () => {
it("leaves plain values untouched", { timeout: 5000 }, () => {
expect(escapeCsvField("hello", ",")).toBe("hello");
});
it("escapes quote / delimiter / newline per RFC 4180", {
timeout: 5000,
}, () => {
expect(escapeCsvField('a"b', ",")).toBe('"a""b"');
expect(escapeCsvField("a,b", ",")).toBe('"a,b"');
expect(escapeCsvField("a\nb", ",")).toBe('"a\nb"');
expect(escapeCsvField("a\rb", ",")).toBe('"a\rb"');
expect(escapeCsvField("a;b", ";")).toBe('"a;b"');
});
});
describe("formatCellValue", () => {
it("renders null and undefined as empty", { timeout: 5000 }, () => {
expect(formatCellValue(null)).toBe("");
expect(formatCellValue(undefined)).toBe("");
});
it("renders Date via toISOString", { timeout: 5000 }, () => {
expect(formatCellValue(new Date("2026-05-19T00:00:00Z"))).toBe(
"2026-05-19T00:00:00.000Z"
);
});
it("renders objects with JSON.stringify", { timeout: 5000 }, () => {
expect(formatCellValue({ a: 1 })).toBe('{"a":1}');
});
it("renders scalars via String()", { timeout: 5000 }, () => {
expect(formatCellValue(42)).toBe("42");
expect(formatCellValue(true)).toBe("true");
expect(formatCellValue("hello")).toBe("hello");
});
});
describe("convertToCsv", () => {
it("emits header + rows by default", { timeout: 5000 }, () => {
const out = convertToCsv(
[{ id: 1, name: "Ada" }],
[
{ key: "id", header: "ID" },
{ key: "name", header: "Name" },
]
);
expect(out).toBe("ID,Name\n1,Ada");
});
it("omits the header row when includeHeaders is false", {
timeout: 5000,
}, () => {
const out = convertToCsv([{ id: 1 }], [{ key: "id", header: "ID" }], {
includeHeaders: false,
});
expect(out).toBe("1");
});
it("respects custom delimiter and applies column.transform", {
timeout: 5000,
}, () => {
const out = convertToCsv(
[{ price: 100 }],
[
{
key: "price",
header: "Price",
transform: (v) => `$${v as number}`,
},
],
{ delimiter: ";" }
);
expect(out).toBe("Price\n$100");
});
});
describe("CsvExporter", () => {
it("returns a complete ExportResult", { timeout: 5000 }, () => {
const exporter = new CsvExporter();
const result = exporter.export(
[{ id: 1 }],
[{ key: "id", header: "ID" }]
);
expect(result.mimeType).toBe("text/csv");
expect(result.rowCount).toBe(1);
expect(result.byteSize).toBe("ID\n1".length);
expect(result.filename).toMatch(/^export-\d+\.csv$/);
expect(result.content).toBe("ID\n1");
expect(exporter.format).toBe("csv");
});
});

View File

@@ -0,0 +1,102 @@
import type {
ColumnDefinition,
Exporter,
ExportOptions,
ExportResult,
} from "../../types/types.js";
const DEFAULT_DELIMITER = ",";
/**
* Escape a CSV field value following RFC 4180. Fields containing the
* configured delimiter, double-quote, CR, or LF are wrapped in double
* quotes; embedded double-quotes are doubled.
*/
export function escapeCsvField(value: string, delimiter: string): string {
const needsQuoting =
value.includes(delimiter) ||
value.includes('"') ||
value.includes("\n") ||
value.includes("\r");
if (needsQuoting) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/**
* Format an arbitrary JS value as a CSV cell string. Dates become ISO
* 8601, `null` / `undefined` become the empty string, and plain objects
* are JSON-stringified.
*/
export function formatCellValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
/**
* Convert data rows to a single CSV string. Header row is included
* unless `options.includeHeaders === false`. Lines are joined with a
* single `\n` (LF) per RFC 4180-compatible practice.
*/
export function convertToCsv(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): string {
const delimiter = options?.delimiter ?? DEFAULT_DELIMITER;
const includeHeaders = options?.includeHeaders !== false;
const lines: string[] = [];
if (includeHeaders) {
const headerRow = columns
.map((col) => escapeCsvField(col.header, delimiter))
.join(delimiter);
lines.push(headerRow);
}
for (const row of data) {
const values = columns.map((col) => {
const rawValue = row[col.key];
const transform = col.transform as
| ((v: unknown) => unknown)
| undefined;
const value = transform ? transform(rawValue) : rawValue;
return escapeCsvField(formatCellValue(value), delimiter);
});
lines.push(values.join(delimiter));
}
return lines.join("\n");
}
export class CsvExporter implements Exporter {
readonly format = "csv" as const;
public export(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): ExportResult {
const csv = convertToCsv(data, columns, options);
const byteSize = new TextEncoder().encode(csv).byteLength;
const filename = `export-${Date.now()}.csv`;
return {
content: csv,
mimeType: "text/csv",
filename,
rowCount: data.length,
byteSize,
};
}
}

View File

@@ -0,0 +1,57 @@
# `json.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
JSON exporter. Projects every row to a record keyed by the column `header` (not `key`), optionally applying `column.transform`, then serialises via `JSON.stringify`. The exporter is intentionally minimal: there is no streaming JSON or NDJSON writer here — both are upstream §4 enhancements deferred to a follow-up package. This module ships the two pure helpers `projectRow` and `convertToJson` plus the `JsonExporter` class.
## Features
- Output is a single JSON array — one element per row, keyed by column header.
- Optional `options.pretty` triggers `JSON.stringify(value, null, 2)`.
- Per-column `transform` runs before serialisation.
- Byte size derived from `TextEncoder` so multi-byte UTF-8 characters report the correct wire size.
## Usage: Basic
```ts
import { JsonExporter } from "@suaveplan/api-plugin-export";
const result = new JsonExporter().export(
[{ id: 1, name: "Ada" }],
[{ key: "id", header: "ID" }, { key: "name", header: "Name" }]
);
// result.content === '[{"ID":1,"Name":"Ada"}]'
// result.mimeType === 'application/json'
```
## Usage: Advanced
```ts
import { JsonExporter, type ColumnDefinition } from "@suaveplan/api-plugin-export";
const cols: ColumnDefinition[] = [
{ key: "joinedAt", header: "Joined", transform: (v) => new Date(v as string).toISOString() },
];
const result = new JsonExporter().export(
[{ joinedAt: "2026-01-01T00:00:00Z" }],
cols,
{ pretty: true }
);
// result.content is multi-line indented JSON
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `projectRow(row, columns)` | function | Pure — builds the projected record. |
| `convertToJson(data, columns, options?)` | function | Pure — emits the full JSON string. |
| `JsonExporter` | class | Implements `Exporter` with `format: "json"`, MIME `application/json`. |
## Implementation Notes
The projection uses `column.header` as the output key, not `column.key`, so callers can rename fields without mutating the underlying row data — e.g. mapping a database column `user_id` to the export header `User ID`. When two columns declare the same header, the later one wins; this matches `Object.assign` semantics and is documented behaviour rather than a bug — callers building dynamic column sets should de-dupe themselves. `options.delimiter`, `options.sheetName`, `options.dateFormat`, and `options.includeHeaders` are ignored by the JSON exporter — they exist on the shared `ExportOptions` type because they belong to other formats; the alternative (per-format option types) would force the route layer to fan out to N typed paths.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertToJson, JsonExporter, projectRow } from "./json.js";
describe("projectRow", () => {
it("keys output by header, not column.key", { timeout: 5000 }, () => {
const out = projectRow({ user_id: 7, name: "Ada" }, [
{ key: "user_id", header: "User ID" },
{ key: "name", header: "Name" },
]);
expect(out).toEqual({ "User ID": 7, Name: "Ada" });
});
it("applies column.transform", { timeout: 5000 }, () => {
const out = projectRow({ price: 100 }, [
{
key: "price",
header: "Price",
transform: (v) => `$${v as number}`,
},
]);
expect(out).toEqual({ Price: "$100" });
});
});
describe("convertToJson", () => {
it("emits a JSON array", { timeout: 5000 }, () => {
const out = convertToJson(
[{ id: 1 }, { id: 2 }],
[{ key: "id", header: "ID" }]
);
expect(JSON.parse(out)).toEqual([{ ID: 1 }, { ID: 2 }]);
});
it("pretty-prints when options.pretty is true", { timeout: 5000 }, () => {
const out = convertToJson([{ id: 1 }], [{ key: "id", header: "ID" }], {
pretty: true,
});
expect(out).toContain("\n");
expect(out).toContain(" ");
});
});
describe("JsonExporter", () => {
it("returns a complete ExportResult", { timeout: 5000 }, () => {
const exporter = new JsonExporter();
const result = exporter.export(
[{ id: 1 }],
[{ key: "id", header: "ID" }]
);
expect(result.mimeType).toBe("application/json");
expect(result.rowCount).toBe(1);
expect(JSON.parse(result.content)).toEqual([{ ID: 1 }]);
expect(result.byteSize).toBe(
new TextEncoder().encode(result.content).byteLength
);
expect(result.filename).toMatch(/^export-\d+\.json$/);
expect(exporter.format).toBe("json");
});
});

View File

@@ -0,0 +1,60 @@
import type {
ColumnDefinition,
Exporter,
ExportOptions,
ExportResult,
} from "../../types/types.js";
/**
* Project a single row to a record keyed by the column `header`, applying
* `column.transform` when present.
*/
export function projectRow(
row: Record<string, unknown>,
columns: ColumnDefinition[]
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const col of columns) {
const rawValue = row[col.key];
const transform = col.transform as
| ((v: unknown) => unknown)
| undefined;
result[col.header] = transform ? transform(rawValue) : rawValue;
}
return result;
}
/**
* Convert data rows to a JSON string. When `options.pretty === true`,
* `JSON.stringify` indents with 2 spaces.
*/
export function convertToJson(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): string {
const projected = data.map((row) => projectRow(row, columns));
const indent = options?.pretty ? 2 : undefined;
return JSON.stringify(projected, null, indent);
}
export class JsonExporter implements Exporter {
readonly format = "json" as const;
public export(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): ExportResult {
const json = convertToJson(data, columns, options);
const byteSize = new TextEncoder().encode(json).byteLength;
const filename = `export-${Date.now()}.json`;
return {
content: json,
mimeType: "application/json",
filename,
rowCount: data.length,
byteSize,
};
}
}

View File

@@ -0,0 +1,58 @@
# `xlsx.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SpreadsheetML (XML) writer for the `xlsx` format. This is the dependency-free path inherited from the upstream package — it produces the bare worksheet payload, not a full OOXML/ZIP package. The output opens cleanly in LibreOffice / Google Sheets / Excel (with the older-format prompt) and is sufficient for the §2 upstream surface; the binary OOXML/ZIP writer is upstream §4 future work tracked outside this change. The module exports four pure helpers (`escapeXml`, `columnIndexToLetter`, `formatXlsxCell`, `buildSpreadsheetXml`) plus the `XlsxExporter` class.
## Features
- Excel-style column letter math: `0 → A`, `25 → Z`, `26 → AA`, `701 → ZZ`, `702 → AAA`.
- Three cell shapes — numeric (`<c><v>n</v></c>`), boolean (`<c t="b"><v>0|1</v></c>`), and inline-string (`<c t="inlineStr"><is><t>…</t></is></c>`).
- XML escaping for `&`, `<`, `>`, `"`, `'` on inline-string content and the sheet name attribute.
- Dates serialise via `toISOString()`, matching the CSV/JSON exporters for cross-format consistency.
## Usage: Basic
```ts
import { XlsxExporter } from "@suaveplan/api-plugin-export";
const result = new XlsxExporter().export(
[{ id: 1, name: "Ada" }],
[{ key: "id", header: "ID" }, { key: "name", header: "Name" }]
);
// result.content is a SpreadsheetML XML string
// result.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
```
## Usage: Advanced
```ts
import { XlsxExporter, columnIndexToLetter } from "@suaveplan/api-plugin-export";
const cols = Array.from({ length: 30 }).map((_, i) => ({
key: `col${i}`,
header: columnIndexToLetter(i),
}));
const rows = [Object.fromEntries(cols.map((c, i) => [c.key, i]))];
const result = new XlsxExporter().export(rows, cols, { sheetName: "WideExport" });
// result.content references column letters up to AD on row 2
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `escapeXml(value)` | function | Pure XML escaper for the five XML entities. |
| `columnIndexToLetter(index)` | function | Pure Excel-letter math. |
| `formatXlsxCell(value, colLetter, rowNum)` | function | Pure — emits one `<c>…</c>`. |
| `buildSpreadsheetXml(data, columns, options?)` | function | Emits the full `<worksheet>` document. |
| `XlsxExporter` | class | Implements `Exporter` with `format: "xlsx"`. |
## Implementation Notes
The output is *SpreadsheetML 2006* — a worksheet XML payload, not the ZIP package most consumers think of when they hear "xlsx". This is a deliberate carry-over from the upstream package which chose XML over the `xlsx` npm dependency. The MIME type is set to the OOXML sheet content-type so HTTP consumers can still dispatch by `Content-Type`; the filename suffix is `.xlsx.xml` to signal the format honestly. The sheet name is escaped into the `<worksheet sheetName="…">` attribute even though the bare-worksheet form is not strictly required to carry one — keeping the attribute lets downstream upgrades wrap this document in an OOXML/ZIP package without re-emitting the worksheet. The `columnIndexToLetter` loop uses `Math.floor(n / 26) - 1` so the multi-letter overflow is exactly Excel-compatible: `26 → "AA"`, `51 → "AZ"`, `52 → "BA"`, `676 → "ZA"`, `701 → "ZZ"`, `702 → "AAA"`. The boolean cell uses `1`/`0` (not `true`/`false`) because OOXML requires the integer form.

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildSpreadsheetXml,
columnIndexToLetter,
escapeXml,
formatXlsxCell,
XlsxExporter,
} from "./xlsx.js";
describe("escapeXml", () => {
it("escapes the five XML entities", { timeout: 5000 }, () => {
expect(escapeXml("&<>\"'")).toBe("&amp;&lt;&gt;&quot;&apos;");
});
});
describe("columnIndexToLetter", () => {
it("maps 0..25 to A..Z", { timeout: 5000 }, () => {
expect(columnIndexToLetter(0)).toBe("A");
expect(columnIndexToLetter(25)).toBe("Z");
});
it("emits AA for column index 26", { timeout: 5000 }, () => {
expect(columnIndexToLetter(26)).toBe("AA");
});
it("handles deep overflow", { timeout: 5000 }, () => {
expect(columnIndexToLetter(701)).toBe("ZZ");
expect(columnIndexToLetter(702)).toBe("AAA");
});
});
describe("formatXlsxCell", () => {
it("distinguishes numeric from inline-string cells", {
timeout: 5000,
}, () => {
const num = formatXlsxCell(42, "A", 1);
const str = formatXlsxCell("forty-two", "B", 1);
expect(num).toBe('<c r="A1"><v>42</v></c>');
expect(str).toContain('t="inlineStr"');
expect(str).toContain("forty-two");
});
it("emits boolean as 0/1", { timeout: 5000 }, () => {
expect(formatXlsxCell(true, "A", 1)).toBe(
'<c r="A1" t="b"><v>1</v></c>'
);
expect(formatXlsxCell(false, "A", 1)).toBe(
'<c r="A1" t="b"><v>0</v></c>'
);
});
it("null and undefined emit empty inline strings", {
timeout: 5000,
}, () => {
expect(formatXlsxCell(null, "A", 1)).toBe(
'<c r="A1" t="inlineStr"><is><t></t></is></c>'
);
expect(formatXlsxCell(undefined, "A", 1)).toBe(
'<c r="A1" t="inlineStr"><is><t></t></is></c>'
);
});
it("Date renders via toISOString", { timeout: 5000 }, () => {
const out = formatXlsxCell(new Date("2026-05-19T00:00:00Z"), "A", 1);
expect(out).toContain("2026-05-19T00:00:00.000Z");
});
it("NaN falls through to inline-string", { timeout: 5000 }, () => {
const out = formatXlsxCell(Number.NaN, "A", 1);
expect(out).toContain('t="inlineStr"');
});
});
describe("buildSpreadsheetXml", () => {
it("emits header + data rows with the configured sheet name", {
timeout: 5000,
}, () => {
const out = buildSpreadsheetXml(
[{ id: 1, name: "Ada" }],
[
{ key: "id", header: "ID" },
{ key: "name", header: "Name" },
],
{ sheetName: "MySheet" }
);
expect(out).toContain('sheetName="MySheet"');
expect(out).toContain('<row r="1">');
expect(out).toContain('<row r="2">');
expect(out).toContain("<v>1</v>");
expect(out).toContain("Ada");
});
it("respects includeHeaders=false and applies transform", {
timeout: 5000,
}, () => {
const out = buildSpreadsheetXml(
[{ price: 100 }],
[
{
key: "price",
header: "Price",
transform: (v) => `$${v as number}`,
},
],
{ includeHeaders: false }
);
expect(out).not.toContain("Price");
expect(out).toContain("$100");
expect(out).toContain('<row r="1">');
});
it("uses default sheet name when none provided", { timeout: 5000 }, () => {
const out = buildSpreadsheetXml(
[{ id: 1 }],
[{ key: "id", header: "ID" }]
);
expect(out).toContain('sheetName="Export"');
});
});
describe("XlsxExporter", () => {
it("returns a complete ExportResult", { timeout: 5000 }, () => {
const exporter = new XlsxExporter();
const result = exporter.export(
[{ id: 1 }],
[{ key: "id", header: "ID" }]
);
expect(result.mimeType).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
expect(result.rowCount).toBe(1);
expect(result.byteSize).toBe(
new TextEncoder().encode(result.content).byteLength
);
expect(result.filename).toMatch(/^export-\d+\.xlsx\.xml$/);
expect(exporter.format).toBe("xlsx");
});
});

View File

@@ -0,0 +1,131 @@
import type {
ColumnDefinition,
Exporter,
ExportOptions,
ExportResult,
} from "../../types/types.js";
const DEFAULT_SHEET_NAME = "Export";
/**
* Escape XML special characters for inline-string cells / attributes.
*/
export function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Convert a 0-based column index to the Excel column letter
* (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA").
*/
export function columnIndexToLetter(index: number): string {
let result = "";
let n = index;
while (n >= 0) {
result = String.fromCharCode((n % 26) + 65) + result;
n = Math.floor(n / 26) - 1;
}
return result;
}
/**
* Format a single value as a SpreadsheetML `<c>` cell at the given
* column letter + row number. Numbers use no `t=` attribute; booleans
* use `t="b"` with 1/0; everything else becomes an inline string.
*/
export function formatXlsxCell(
value: unknown,
colLetter: string,
rowNum: number
): string {
const ref = `${colLetter}${rowNum}`;
if (value === null || value === undefined) {
return `<c r="${ref}" t="inlineStr"><is><t></t></is></c>`;
}
if (typeof value === "number" && !Number.isNaN(value)) {
return `<c r="${ref}"><v>${value}</v></c>`;
}
if (typeof value === "boolean") {
return `<c r="${ref}" t="b"><v>${value ? 1 : 0}</v></c>`;
}
const strValue =
value instanceof Date ? value.toISOString() : String(value);
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(strValue)}</t></is></c>`;
}
/**
* Build a minimal SpreadsheetML XML document representing one
* worksheet. The sheet name in `options.sheetName` is recorded in the
* generated string but the XML emitted is the bare worksheet payload —
* a full OOXML/ZIP package is upstream §4 future work.
*/
export function buildSpreadsheetXml(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): string {
const sheetName = escapeXml(options?.sheetName ?? DEFAULT_SHEET_NAME);
const includeHeaders = options?.includeHeaders !== false;
const rows: string[] = [];
let rowNum = 1;
if (includeHeaders) {
const headerCells = columns.map((col, i) => {
const letter = columnIndexToLetter(i);
return `<c r="${letter}${rowNum}" t="inlineStr"><is><t>${escapeXml(col.header)}</t></is></c>`;
});
rows.push(`<row r="${rowNum}">${headerCells.join("")}</row>`);
rowNum++;
}
for (const row of data) {
const cells = columns.map((col, i) => {
const letter = columnIndexToLetter(i);
const rawValue = row[col.key];
const transform = col.transform as
| ((v: unknown) => unknown)
| undefined;
const value = transform ? transform(rawValue) : rawValue;
return formatXlsxCell(value, letter, rowNum);
});
rows.push(`<row r="${rowNum}">${cells.join("")}</row>`);
rowNum++;
}
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" sheetName="${sheetName}">
<sheetData>${rows.join("")}</sheetData>
</worksheet>`;
}
export class XlsxExporter implements Exporter {
readonly format = "xlsx" as const;
public export(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): ExportResult {
const xml = buildSpreadsheetXml(data, columns, options);
const byteSize = new TextEncoder().encode(xml).byteLength;
const filename = `export-${Date.now()}.xlsx.xml`;
return {
content: xml,
mimeType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename,
rowCount: data.length,
byteSize,
};
}
}

View File

@@ -0,0 +1,119 @@
/**
* @suaveplan/api-plugin-export
*
* Data-export plugin for `@suaveplan/api` — CSV (RFC 4180), JSON, and
* XLSX (SpreadsheetML XML) exporters, in-memory job store, OpenAPIHono
* route group at `/exports`, `ExportError` hierarchy, and OTel
* instruments.
*/
// Side-effect imports — surface the error-code registry and the
// ServiceRegistry slot augmentation through the package barrel.
import "./error-codes/error-codes.js";
import "./services/registry-augmentation.js";
// Error codes
export {
EXPORT_CODES,
ExportError,
type ExportErrorCode,
} from "./error-codes/error-codes.js";
// Exporters
export {
CsvExporter,
convertToCsv,
escapeCsvField,
formatCellValue,
} from "./exporters/csv/csv.js";
export {
convertToJson,
JsonExporter,
projectRow,
} from "./exporters/json/json.js";
export {
buildSpreadsheetXml,
columnIndexToLetter,
escapeXml,
formatXlsxCell,
XlsxExporter,
} from "./exporters/xlsx/xlsx.js";
// OTel
export {
type ExportInstruments,
getExportInstruments,
resetExportInstruments,
} from "./otel/instruments.js";
// Plugin
export {
createExportPlugin,
registerDataProvider,
} from "./plugin/plugin.js";
// Routes
export {
buildContentDisposition,
type DownloadDeps,
type DownloadOutcome,
runDownloadExport,
} from "./routes/download/download.js";
export {
createExportRoutes,
type ExportRoutesDeps,
} from "./routes/exports/exports.js";
export {
type PostExportOutcome,
runPostExport,
} from "./routes/exports/post-handler.js";
export {
type HonoLike,
PROBLEM_JSON_CONTENT_TYPE,
type ProblemDetails,
renderProblem,
respondProblemJson,
} from "./routes/problem-json/problem-json.js";
// Service
export {
DEFAULT_EXPORT_CONFIG,
type ExportServiceConfig,
getExporter,
inferColumns,
runExport,
} from "./services/export/export-service.js";
// Stores (job persistence — `ExportJobStore` contract)
export { MemoryExportJobStore } from "./store/memory/memory-store.js";
export { SurrealExportStore } from "./store/surreal/surreal-store.js";
export type { SurrealExportStoreConfig } from "./store/surreal/surreal-store.types.js";
export type {
ExportJobStore,
ExportJobStoreListQuery,
ExportJobStoreListResult,
} from "./store/types/types.js";
export type {
ColumnDefinition,
CreateExportJob,
DataProvider,
Exporter,
ExportJob,
ExportJobListQuery,
ExportOptions,
ExportPluginOptions,
ExportPluginOptionsInput,
ExportResult,
} from "./types/types.js";
// Types
export {
ColumnDefinitionSchema,
CreateExportJobSchema,
ExportFormat,
ExportFormatSchema,
ExportJobListQuerySchema,
ExportJobSchema,
ExportPluginOptionsSchema,
ExportStatus,
ExportStatusSchema,
} from "./types/types.js";

View File

@@ -0,0 +1,56 @@
# `instruments.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrument factory for `@suaveplan/api-plugin-export`. Builds a cached bundle of six instruments — five counters and one histogram — bound to the meter named `@suaveplan/api-plugin-export`. The cache lets the hot path (`POST /exports` + `GET /:id/download`) resolve instruments once per process instead of paying the meter-provider lookup cost on every job, while `resetExportInstruments()` gives tests a hook to rebind against an in-memory `MeterProvider` installed at suite startup.
## Features
- Lazy resolution against the global `MeterProvider`, cached after the first call.
- `export.jobs_total` counter (with `outcome` attribute) for every job, completed or failed.
- `export.rows_total` and `export.bytes_total` counters that track the cumulative size of successful exports.
- `export.duration_ms` histogram so dashboards / SLOs can report p50 / p99 latency.
- `export.download.served` counter (with `format` attribute) for every successful download.
- `export.download.failed` counter (with `reason` attribute) for every failed download.
- `resetExportInstruments()` for tests installing fresh providers.
## Usage: Basic
```ts
import { getExportInstruments } from "@suaveplan/api-plugin-export";
const inst = getExportInstruments();
inst.jobsTotal.add(1, { outcome: "completed" });
inst.rowsTotal.add(rowCount);
inst.bytesTotal.add(byteSize);
inst.durationMs.record(elapsedMs);
```
## Usage: Advanced
```ts
// In test setup, after installing a fresh MeterProvider:
import {
getExportInstruments,
resetExportInstruments,
} from "@suaveplan/api-plugin-export";
resetExportInstruments();
const inst = getExportInstruments(); // rebinds against the new provider
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ExportInstruments` | interface | `{ jobsTotal, rowsTotal, bytesTotal, durationMs, downloadServed, downloadFailed }`. |
| `getExportInstruments()` | function | Lazy + cached. |
| `resetExportInstruments()` | function | Drops the cache. |
## Implementation Notes
The bundle is held in a module-scope `let cached: ExportInstruments | undefined` rather than in a class instance, because the function is the only documented entry point and there is no per-plugin state to keep — every plugin instance in a process shares the same meter. Counters use `add(value, attributes?)`; the `outcome` attribute is set by the call site rather than declared on the instrument because OTel attributes are runtime keys, not instrument metadata. The histogram unit is set to `"ms"` (lower-case) per OTel semantic conventions for latency. Resetting between tests is mandatory: meter handles cache internally inside `@opentelemetry/api`, so without `resetExportInstruments()` a test would emit to whichever provider was installed when the *first* `getExportInstruments()` call ran — usually the global default provider, which loses every metric.

View File

@@ -0,0 +1,236 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { runDownloadExport } from "../routes/download/download.js";
import { createExportRoutes } from "../routes/exports/exports.js";
import { DEFAULT_EXPORT_CONFIG } from "../services/export/export-service.js";
import { MemoryExportJobStore } from "../store/memory/memory-store.js";
import type { DataProvider, ExportJob } from "../types/types.js";
import { getExportInstruments, resetExportInstruments } from "./instruments.js";
describe("getExportInstruments", () => {
it("returns a cached bundle of six instruments", { timeout: 5000 }, () => {
resetExportInstruments();
const a = getExportInstruments();
const b = getExportInstruments();
expect(a).toBe(b);
expect(a.jobsTotal).toBeDefined();
expect(a.rowsTotal).toBeDefined();
expect(a.bytesTotal).toBeDefined();
expect(a.durationMs).toBeDefined();
expect(a.downloadServed).toBeDefined();
expect(a.downloadFailed).toBeDefined();
});
it("resetExportInstruments drops the cache", { timeout: 5000 }, () => {
const a = getExportInstruments();
resetExportInstruments();
const b = getExportInstruments();
expect(a).not.toBe(b);
});
});
function findMetric(
collected: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
) {
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name === name) return m;
}
}
}
return undefined;
}
async function runOneExport(
rows: Record<string, unknown>[],
config = DEFAULT_EXPORT_CONFIG
): Promise<void> {
const provider: DataProvider = {
resource: "users",
fetch: async () => rows,
};
const app = createExportRoutes({
providers: new Map([["users", provider]]),
jobs: new Map(),
exportConfig: config,
jobIdFactory: () => "metric-test",
});
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "csv", resource: "users" }),
});
// Drain body to settle Hono response handling.
await res.text();
}
describe("export OTel instruments via in-memory MeterProvider", () => {
it("emits completed-job metrics through an in-memory MeterProvider", {
timeout: 5000,
}, async () => {
resetExportInstruments();
// Flush any prior accumulated state.
await flushTestMetrics();
await runOneExport([{ id: 1 }, { id: 2 }]);
const collected = await flushTestMetrics();
const jobs = findMetric(collected, "export.jobs_total");
const rows = findMetric(collected, "export.rows_total");
const bytes = findMetric(collected, "export.bytes_total");
const duration = findMetric(collected, "export.duration_ms");
expect(jobs).toBeDefined();
expect(rows).toBeDefined();
expect(bytes).toBeDefined();
expect(duration).toBeDefined();
// A completed-outcome data point must exist on jobs_total.
const hasCompleted = jobs?.dataPoints.some(
(dp) =>
(dp.attributes as { outcome?: string }).outcome === "completed"
);
expect(hasCompleted).toBe(true);
});
it("emits failed-job metric on row-limit breach", {
timeout: 5000,
}, async () => {
resetExportInstruments();
await flushTestMetrics();
await runOneExport([{ id: 1 }, { id: 2 }, { id: 3 }], {
maxRows: 2,
maxFileSizeBytes: 1_000_000,
defaultFormat: "csv",
});
const collected = await flushTestMetrics();
const jobs = findMetric(collected, "export.jobs_total");
const hasFailed = jobs?.dataPoints.some(
(dp) => (dp.attributes as { outcome?: string }).outcome === "failed"
);
expect(hasFailed).toBe(true);
});
it("emits failed-job metric on file-size breach", {
timeout: 5000,
}, async () => {
resetExportInstruments();
await flushTestMetrics();
await runOneExport([{ name: "lengthy-name" }], {
maxRows: 1_000,
maxFileSizeBytes: 4,
defaultFormat: "csv",
});
const collected = await flushTestMetrics();
const jobs = findMetric(collected, "export.jobs_total");
const hasFailed = jobs?.dataPoints.some(
(dp) => (dp.attributes as { outcome?: string }).outcome === "failed"
);
expect(hasFailed).toBe(true);
});
it("emits export.download.served on a successful CSV download", {
timeout: 5000,
}, async () => {
resetExportInstruments();
await flushTestMetrics();
const job: ExportJob = {
id: "dl-ok",
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: "users.csv",
totalRows: 1,
exportedRows: 1,
fileSize: 10,
error: null,
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
const collected = await flushTestMetrics();
const served = findMetric(collected, "export.download.served");
expect(served).toBeDefined();
const hasCsv = served?.dataPoints.some(
(dp) => (dp.attributes as { format?: string }).format === "csv"
);
expect(hasCsv).toBe(true);
});
it("emits export.download.failed{reason=not-ready} when the job is not completed", {
timeout: 5000,
}, async () => {
resetExportInstruments();
await flushTestMetrics();
const job: ExportJob = {
id: "dl-pending",
format: "csv",
resource: "users",
status: "processing",
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: new Date().toISOString(),
completedAt: null,
};
await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map(),
},
job.id
);
const collected = await flushTestMetrics();
const failed = findMetric(collected, "export.download.failed");
expect(failed).toBeDefined();
const hasNotReady = failed?.dataPoints.some(
(dp) =>
(dp.attributes as { reason?: string }).reason === "not-ready"
);
expect(hasNotReady).toBe(true);
});
it("emits failed-job metric when provider.fetch rejects", {
timeout: 5000,
}, async () => {
resetExportInstruments();
await flushTestMetrics();
const provider: DataProvider = {
resource: "users",
fetch: async () => {
throw new Error("boom");
},
};
const app = createExportRoutes({
providers: new Map([["users", provider]]),
jobs: new Map(),
exportConfig: DEFAULT_EXPORT_CONFIG,
});
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "csv", resource: "users" }),
});
await res.text();
const collected = await flushTestMetrics();
const jobs = findMetric(collected, "export.jobs_total");
const hasFailed = jobs?.dataPoints.some(
(dp) => (dp.attributes as { outcome?: string }).outcome === "failed"
);
expect(hasFailed).toBe(true);
});
});

View File

@@ -0,0 +1,69 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the export plugin.
*
* - `jobsTotal` — incremented once per job outcome, labelled
* `{ outcome: "completed" | "failed" }`.
* - `rowsTotal` — total rows emitted across successful jobs.
* - `bytesTotal` — total bytes emitted across successful jobs.
* - `durationMs` — histogram of wall-clock duration of a single
* export, in milliseconds.
* - `downloadServed` — incremented once per successful download
* response, labelled `{ format: "csv" | "json" | "xlsx" }`.
* - `downloadFailed` — incremented once per failed download
* response, labelled `{ reason: "not-found" | "not-ready" |
* "provider-not-found" | "failed" }`.
*/
export interface ExportInstruments {
readonly jobsTotal: Counter;
readonly rowsTotal: Counter;
readonly bytesTotal: Counter;
readonly durationMs: Histogram;
readonly downloadServed: Counter;
readonly downloadFailed: Counter;
}
let cached: ExportInstruments | undefined;
/**
* Lazily build (and cache) the export instruments bundle against the
* current global `MeterProvider`. Tests call {@link resetExportInstruments}
* in setup to force a re-bind against a freshly-installed provider.
*/
export function getExportInstruments(): ExportInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-export");
cached = {
jobsTotal: meter.createCounter("export.jobs_total", {
description: "Total export jobs by outcome.",
}),
rowsTotal: meter.createCounter("export.rows_total", {
description: "Total rows successfully exported.",
}),
bytesTotal: meter.createCounter("export.bytes_total", {
description: "Total bytes successfully exported.",
}),
durationMs: meter.createHistogram("export.duration_ms", {
description: "Wall-clock duration of a single export, in ms.",
unit: "ms",
}),
downloadServed: meter.createCounter("export.download.served", {
description: "Total download responses served by format.",
}),
downloadFailed: meter.createCounter("export.download.failed", {
description: "Total download responses that failed by reason.",
}),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getExportInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetExportInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,59 @@
# `plugin.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The plugin factory for `@suaveplan/api-plugin-export`. Construction-time it validates options through `ExportPluginOptionsSchema` (rejecting with `ExportError(EXPORT.INVALID_CONFIG)` on failure) and returns a `PluginDescriptor` with three lifecycle hooks. `onInit` registers the in-memory `providers` + `jobs` maps on the augmented `ServiceRegistry`. `onReady` resolves the host `app` (`OpenAPIHono`) and mounts the export routes at the configured `basePath`. `onClose` clears both maps so a re-registration of the plugin starts clean.
## Features
- Caller-supplied options are honoured end-to-end — the upstream `_config`-discard bug is fixed at migration time.
- Side-effect import of `registry-augmentation` makes `ctx.getService("exportProviders")` / `ctx.getService("exportJobs")` type-narrow correctly at every call site that imports this module.
- `registerDataProvider(providers, provider)` is a convenience the upstream package shipped — re-exposed here so downstream code does not need to construct the providers map by hand.
- `onReady` mounts routes via `ctx.getService("app")` (the suaveplanent of upstream's `ctx.app.route(...)` call) so the plugin participates in the rest of the plugin ecosystem cleanly.
## Usage: Basic
```ts
import { createExportPlugin } from "@suaveplan/api-plugin-export";
const registry = createPluginRegistry();
registry.register(createExportPlugin({ basePath: "/v1/exports", maxRows: 10_000 }));
await registry.init();
await registry.ready();
```
## Usage: Advanced
```ts
import {
createExportPlugin,
registerDataProvider,
type DataProvider,
} from "@suaveplan/api-plugin-export";
const plugin = createExportPlugin({ defaultFormat: "json" });
// later, after onInit completes:
const providers = ctx.getService("exportProviders");
if (providers) {
const userProvider: DataProvider = {
resource: "users",
fetch: async () => db.select("user").get(),
};
registerDataProvider(providers, userProvider);
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `createExportPlugin(options?)` | function | Returns `PluginDescriptor<ExportPluginOptions>`. Throws `EXPORT.INVALID_CONFIG` on bad input. |
| `registerDataProvider(providers, provider)` | function | `providers.set(provider.resource, provider)` helper. |
## Implementation Notes
The descriptor uses `defaults: config` so the `PluginContext`-level `getOwnConfig()` returns the merged, fully-defaulted options shape — downstream plugins that read the plugin config (or that the host orchestrator inspects) see the same values the route handler uses. The `app` lookup in `onReady` returns `OpenAPIHono | undefined` (per the `ServiceRegistry` augmentation in `@suaveplan/api`), so the missing case is checked explicitly with an `ExportError(INVALID_CONFIG)` — this is a misconfiguration of the host, not of the plugin options, but the same code is correct because the host failed to seed the `app` slot before plugin registration. `runOnInit` is async even though it does no `await` because the lifecycle hook signature requires it; making it async eliminates a subtle TypeScript variance gotcha when the registry awaits the return value. The runtime maps are held on a `PluginRuntime` interface so the three hook helpers can pass state without resorting to module-scope `let` (which would conflict with multiple plugin instances co-existing in one process).

View File

@@ -0,0 +1,218 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EXPORT_CODES, ExportError } from "../error-codes/error-codes.js";
import { MemoryExportJobStore } from "../store/memory/memory-store.js";
import type {
DataProvider,
ExportJob,
ExportPluginOptions,
} from "../types/types.js";
import { createExportPlugin, registerDataProvider } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const noopEventBus = {
emit: () => undefined,
on: () => () => undefined,
off: () => undefined,
once: () => () => undefined,
};
function buildContext(
config: ExportPluginOptions,
seed?: Record<string, unknown>
) {
const services = new Map<string, unknown>(Object.entries(seed ?? {}));
return createPluginContext<ExportPluginOptions>({
logger: noopLogger,
eventBus: noopEventBus,
signal: new AbortController().signal,
config,
services,
});
}
describe("createExportPlugin", () => {
it("createExportPlugin rejects invalid options", { timeout: 5000 }, () => {
try {
createExportPlugin({ maxRows: 0 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ExportError);
expect((err as ExportError).code).toBe(EXPORT_CODES.INVALID_CONFIG);
expect((err as ExportError).cause).toBeDefined();
}
});
it("descriptor exposes the canonical name/version/hooks", {
timeout: 5000,
}, () => {
const plugin = createExportPlugin();
expect(plugin.name).toBe("export");
expect(plugin.version).toBe("0.1.0");
expect(plugin.optionalDependencies).toEqual(["database", "events"]);
expect(plugin.tags).toEqual(["export", "data", "csv", "json", "xlsx"]);
expect(typeof plugin.onInit).toBe("function");
expect(typeof plugin.onReady).toBe("function");
expect(typeof plugin.onClose).toBe("function");
expect(plugin.defaults?.basePath).toBe("/exports");
expect(plugin.defaults?.maxRows).toBe(50_000);
});
it("onInit registers exportProviders and exportJobs", {
timeout: 5000,
}, async () => {
const plugin = createExportPlugin();
const ctx = buildContext(plugin.defaults as ExportPluginOptions);
await plugin.onInit?.(ctx);
expect(ctx.getService("exportProviders")).toBeInstanceOf(Map);
expect(ctx.getService("exportJobs")).toBeInstanceOf(Map);
});
it("createExportPlugin honours caller-supplied options through onReady", {
timeout: 5000,
}, async () => {
const plugin = createExportPlugin({
basePath: "/v1/exports",
maxRows: 2,
});
const app = new OpenAPIHono();
const ctx = buildContext(plugin.defaults as ExportPluginOptions, {
app,
});
await plugin.onInit?.(ctx);
const providers = ctx.getService("exportProviders");
if (!providers) throw new Error("providers slot empty");
providers.set("users", {
resource: "users",
fetch: async () => [{ id: 1 }, { id: 2 }, { id: 3 }],
});
await plugin.onReady?.(ctx);
const tryPost = async (path: string) =>
app.request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
format: "csv",
resource: "users",
}),
});
let res = await tryPost("/v1/exports/");
if (res.status === 404) {
res = await tryPost("/v1/exports");
}
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("row limit");
});
it("onReady throws INVALID_CONFIG when the app service is missing", {
timeout: 5000,
}, async () => {
const plugin = createExportPlugin();
const ctx = buildContext(plugin.defaults as ExportPluginOptions);
await plugin.onInit?.(ctx);
try {
await plugin.onReady?.(ctx);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(ExportError);
expect((err as ExportError).code).toBe(EXPORT_CODES.INVALID_CONFIG);
}
});
it("download route uses exportJobStore service when wired", {
timeout: 5000,
}, async () => {
const plugin = createExportPlugin();
const app = new OpenAPIHono();
const job: ExportJob = {
id: "wired",
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: "wired.csv",
totalRows: 1,
exportedRows: 1,
fileSize: 10,
error: null,
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const store = new MemoryExportJobStore([[job.id, job]]);
const ctx = buildContext(plugin.defaults as ExportPluginOptions, {
app,
exportJobStore: store,
});
await plugin.onInit?.(ctx);
const providers = ctx.getService("exportProviders");
if (!providers) throw new Error("providers slot empty");
providers.set("users", {
resource: "users",
fetch: async () => [{ id: 1, name: "Ada" }],
});
await plugin.onReady?.(ctx);
const tryGet = async (path: string) => app.request(path);
let res = await tryGet(`/exports/${job.id}/download`);
if (res.status === 404) {
// Some Hono versions tolerate trailing slashes differently.
res = await tryGet(`/exports/${job.id}/download/`);
}
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("Ada");
// The in-memory exportJobs Map was NOT populated — the only
// route capable of resolving the job is through the wired store.
expect(
(ctx.getService("exportJobs") as Map<string, ExportJob>).size
).toBe(0);
});
it("onClose drains providers and jobs", { timeout: 5000 }, async () => {
const plugin = createExportPlugin();
const ctx = buildContext(plugin.defaults as ExportPluginOptions);
await plugin.onInit?.(ctx);
const providers = ctx.getService("exportProviders") as Map<
string,
DataProvider
>;
const jobs = ctx.getService("exportJobs") as Map<string, ExportJob>;
providers.set("x", { resource: "x", fetch: async () => [] });
jobs.set("y", {
id: "y",
format: "csv",
resource: "x",
status: "completed",
filters: undefined,
filename: undefined,
totalRows: 0,
exportedRows: 0,
fileSize: 0,
error: null,
createdAt: new Date().toISOString(),
completedAt: null,
});
await plugin.onClose?.(ctx);
expect(providers.size).toBe(0);
expect(jobs.size).toBe(0);
});
});
describe("registerDataProvider", () => {
it("keys the providers map by provider.resource", { timeout: 5000 }, () => {
const providers = new Map<string, DataProvider>();
const provider: DataProvider = {
resource: "users",
fetch: async () => [],
};
registerDataProvider(providers, provider);
expect(providers.get("users")).toBe(provider);
});
});

View File

@@ -0,0 +1,118 @@
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import { EXPORT_CODES, ExportError } from "../error-codes/error-codes.js";
import { createExportRoutes } from "../routes/exports/exports.js";
import "../services/registry-augmentation.js";
import {
type DataProvider,
type ExportJob,
type ExportPluginOptions,
type ExportPluginOptionsInput,
ExportPluginOptionsSchema,
} from "../types/types.js";
interface PluginRuntime {
providers: Map<string, DataProvider>;
jobs: Map<string, ExportJob>;
}
/**
* Construct the `@suaveplan/api-plugin-export` plugin descriptor.
*
* Lifecycle:
*
* 1. **Construction-time** Zod-validation of `options`. Invalid input
* surfaces `ExportError(EXPORT_CODES.INVALID_CONFIG)` with the
* `ZodError` as `cause`.
* 2. **`onInit`** — build the in-memory `providers` and `jobs` maps and
* register them on the `ServiceRegistry`.
* 3. **`onReady`** — look up the host `app` from `ctx.getService("app")`,
* build the export routes via `createExportRoutes(...)`, and mount
* them at `options.basePath`.
* 4. **`onClose`** — clear both maps so a subsequent `onInit` starts
* from a clean slate.
*/
export function createExportPlugin(
options: ExportPluginOptionsInput = {}
): PluginDescriptor<ExportPluginOptions> {
const parsed = ExportPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ExportError(
EXPORT_CODES.INVALID_CONFIG,
"createExportPlugin received invalid options",
{ cause: parsed.error, context: { issues: parsed.error.issues } }
);
}
const config = parsed.data;
const runtime: PluginRuntime = {
providers: new Map<string, DataProvider>(),
jobs: new Map<string, ExportJob>(),
};
return definePlugin<ExportPluginOptions>({
name: "export",
version: "0.1.0",
description:
"Data-export plugin for @suaveplan/api: CSV / JSON / XLSX exporters, in-memory job store, OpenAPIHono route group at /exports.",
defaults: config,
optionalDependencies: ["database", "events"],
tags: ["export", "data", "csv", "json", "xlsx"],
onInit: async (ctx) => runOnInit(ctx, runtime),
onReady: async (ctx) => runOnReady(ctx, config, runtime),
onClose: async () => runOnClose(runtime),
});
}
/**
* Helper for downstream code to register a `DataProvider` on the
* providers map. The map is keyed by `provider.resource`.
*/
export function registerDataProvider(
providers: Map<string, DataProvider>,
provider: DataProvider
): void {
providers.set(provider.resource, provider);
}
async function runOnInit(
ctx: PluginContext<ExportPluginOptions>,
runtime: PluginRuntime
): Promise<void> {
ctx.registerService("exportProviders", runtime.providers);
ctx.registerService("exportJobs", runtime.jobs);
}
async function runOnReady(
ctx: PluginContext<ExportPluginOptions>,
config: ExportPluginOptions,
runtime: PluginRuntime
): Promise<void> {
const app = ctx.getService("app");
if (app === undefined) {
throw new ExportError(
EXPORT_CODES.INVALID_CONFIG,
"export plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"] } }
);
}
const jobStore = ctx.getService("exportJobStore");
const routes = createExportRoutes({
providers: runtime.providers,
jobs: runtime.jobs,
jobStore,
exportConfig: {
maxRows: config.maxRows,
maxFileSizeBytes: config.maxFileSizeBytes,
defaultFormat: config.defaultFormat,
},
});
app.route(config.basePath, routes);
}
async function runOnClose(runtime: PluginRuntime): Promise<void> {
runtime.providers.clear();
runtime.jobs.clear();
}

View File

@@ -0,0 +1,120 @@
# `routes/download` — `GET /:id/download` handler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `GET /:id/download` route on `@suaveplan/api-plugin-export`. The handler
serves the rendered file body for a previously completed export job. The endpoint closes
the deferral noted in `archive/2026-05-19-add-api-plugin-export/proposal.md` §"Out of
scope" — the `POST /exports` response already returned `downloadUrl: "/{jobId}/download"`
for forward compatibility, and this module finally resolves that URL to a real response.
The handler is decoupled from Hono via the discriminated `DownloadOutcome` union: the
route registration call site in `routes/exports/exports.ts` calls `runDownloadExport(...)`,
branches on `outcome.kind`, and either streams the body or passes the carried `ExportError`
to the per-package RFC 7807 renderer. Every failure branch raises one of the existing
`EXPORT_*` codes so the spec-conformance pin (`api-plugin-export.spec-conformance.test.ts`)
keeps the registry fully exercised from a production path.
## Features
- Reads the job from the persistent `ExportJobStore` slot when wired
(`SurrealExportStore` / `MemoryExportJobStore`) or falls back to the in-memory
`Map<string, ExportJob>` shipped on `ExportRoutesDeps.jobs`.
- Validates the job is in `status: "completed"` — any other status raises
`EXPORT.JOB_NOT_READY` (HTTP `409`).
- Re-fetches the source rows through the registered `DataProvider`, passing `job.filters`
exactly as supplied to the original `POST /exports` call so the export is idempotent
against the current provider state.
- Re-renders through the existing `getExporter(format)` writer — no new format work, no
body caching. The `Content-Type` is taken from the writer's `mimeType` field.
- Emits `Content-Disposition: attachment; filename="<job.filename>"` with the filename
sanitised per RFC 6266 §4.1 (quotes / backslashes escaped, control characters replaced
with `_`).
- OTel: increments `export.download.served{format}` on success and
`export.download.failed{reason}` (`not-found` / `not-ready` / `provider-not-found` /
`failed`) on every failure branch.
## Usage: Basic
The function is consumed by the route registration in `routes/exports/exports.ts`:
```ts
import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download";
app.get("/:id/download", async (c) => {
const { id } = c.req.param();
const outcome = await runDownloadExport(deps, id);
if (outcome.kind === "ok") {
c.header("content-type", outcome.contentType);
c.header(
"content-disposition",
buildContentDisposition(outcome.filename)
);
return c.body(outcome.body, 200);
}
return respondProblemJson(c, outcome.error);
});
```
## Usage: Advanced
The handler can be driven directly outside Hono — e.g. from a CLI or worker entrypoint —
because the `DownloadDeps` interface only requires three plain fields:
```ts
import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download";
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[jobId, job]]),
jobs: new Map(),
providers: new Map([
[
"users",
{
resource: "users",
fetch: async () => [{ id: 1 }],
},
],
]),
},
jobId
);
if (out.kind === "ok") {
await Bun.write("out.csv", out.body);
}
```
When `jobStore` is omitted, the handler reads the job from the in-memory `deps.jobs` map
— the existing slot wired on `ExportRoutesDeps` since the initial plugin ship.
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `DownloadDeps` | `interface` | `{ jobStore?; jobs; providers }` |
| `DownloadOutcome` | discriminated union | `ok` / `not-found` / `not-ready` / `provider-not-found` / `failed` |
| `runDownloadExport(deps, id)` | async function | Returns `Promise<DownloadOutcome>` |
| `buildContentDisposition(filename)` | function | RFC 6266 §4.1-compliant string |
## Implementation Notes
- The handler never throws — every failure becomes an `ExportError` carried on the
`DownloadOutcome.error` field. This decouples the response shape from the discriminated
outcome and keeps the route layer free of `try { … } catch { … }` blocks.
- `context.statusCode` is set on every `ExportError` so the per-package problem-details
renderer can map directly to the right HTTP code without a separate code-to-status table.
- `inferColumns(data)` is used unconditionally on download. The original column projection
is not persisted on the `ExportJob` shape — re-inferring keeps the download
deterministic against the current provider response without bloating the persisted job.
- The handler is intentionally idempotent against the current provider state: a fresh
download after the source rows change reflects the change. This matches RFC 7231 §4.2.2
"idempotent methods" for `GET` and avoids the cache-invalidation problem a body-caching
scheme would introduce.
- `buildContentDisposition` strips `[0x00-0x1f]` plus `0x7f` rather than emitting them
literally — these are forbidden in HTTP header values per RFC 7230 §3.2.6.

View File

@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { resetExportInstruments } from "../../otel/instruments.js";
import { MemoryExportJobStore } from "../../store/memory/memory-store.js";
import type { DataProvider, ExportJob } from "../../types/types.js";
import {
buildContentDisposition,
type DownloadDeps,
runDownloadExport,
} from "./download.js";
function makeJob(overrides: Partial<ExportJob> = {}): ExportJob {
return {
id: "job-1",
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: "users.csv",
totalRows: 1,
exportedRows: 1,
fileSize: 10,
error: null,
createdAt: new Date("2026-05-20T00:00:00Z").toISOString(),
completedAt: new Date("2026-05-20T00:00:01Z").toISOString(),
...overrides,
};
}
beforeEach(() => {
resetExportInstruments();
});
describe("runDownloadExport — happy path", () => {
it("streams a completed CSV job as application/csv with attachment disposition", {
timeout: 5000,
}, async () => {
const job = makeJob();
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1, name: "Ada" }],
};
const deps: DownloadDeps = {
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
};
const out = await runDownloadExport(deps, job.id);
expect(out.kind).toBe("ok");
if (out.kind !== "ok") return;
expect(out.contentType).toBe("text/csv");
expect(out.filename).toBe("users.csv");
expect(out.format).toBe("csv");
expect(out.body).toContain("id,name");
expect(out.body).toContain("Ada");
});
it("streams JSON when the job format is json", {
timeout: 5000,
}, async () => {
const job = makeJob({ format: "json", filename: "users.json" });
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
expect(out.kind).toBe("ok");
if (out.kind !== "ok") return;
expect(out.contentType).toBe("application/json");
expect(JSON.parse(out.body)).toEqual([{ id: 1 }]);
});
it("streams XLSX SpreadsheetML when the job format is xlsx", {
timeout: 5000,
}, async () => {
const job = makeJob({ format: "xlsx", filename: "users.xlsx" });
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 42 }],
};
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
expect(out.kind).toBe("ok");
if (out.kind !== "ok") return;
expect(out.contentType).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
expect(out.body).toContain("<worksheet");
});
it("falls back to the in-memory jobs Map when jobStore is omitted", {
timeout: 5000,
}, async () => {
const job = makeJob();
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const deps: DownloadDeps = {
jobs: new Map([[job.id, job]]),
providers: new Map([["users", provider]]),
};
const out = await runDownloadExport(deps, job.id);
expect(out.kind).toBe("ok");
});
it("threads job.filters into provider.fetch", {
timeout: 5000,
}, async () => {
const captured: unknown[] = [];
const job = makeJob({ filters: { tenant: "acme" } });
const provider: DataProvider = {
resource: "users",
fetch: async (filters) => {
captured.push(filters);
return [{ tenant: "acme" }];
},
};
await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
expect(captured).toEqual([{ tenant: "acme" }]);
});
it("uses the exporter-derived filename when job.filename is undefined", {
timeout: 5000,
}, async () => {
const job = makeJob({ filename: undefined });
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
expect(out.kind).toBe("ok");
if (out.kind !== "ok") return;
expect(out.filename).toMatch(/\.csv$/);
});
});
describe("runDownloadExport — failure branches", () => {
it("returns 404 problem-details when the job is missing", {
timeout: 5000,
}, async () => {
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore(),
jobs: new Map(),
providers: new Map(),
},
"missing"
);
expect(out.kind).toBe("not-found");
if (out.kind !== "not-found") return;
expect(out.error.code).toBe("EXPORT.JOB_NOT_FOUND");
expect((out.error.context as { statusCode?: number }).statusCode).toBe(
404
);
});
it("returns 409 problem-details when the job is not yet completed", {
timeout: 5000,
}, async () => {
const job = makeJob({ status: "processing" });
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map(),
},
job.id
);
expect(out.kind).toBe("not-ready");
if (out.kind !== "not-ready") return;
expect(out.error.code).toBe("EXPORT.JOB_NOT_READY");
expect((out.error.context as { statusCode?: number }).statusCode).toBe(
409
);
});
it("returns 404 problem-details when the provider was deregistered", {
timeout: 5000,
}, async () => {
const job = makeJob({ resource: "ghost" });
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map(),
},
job.id
);
expect(out.kind).toBe("provider-not-found");
if (out.kind !== "provider-not-found") return;
expect(out.error.code).toBe("EXPORT.PROVIDER_NOT_FOUND");
expect((out.error.context as { statusCode?: number }).statusCode).toBe(
404
);
});
it("returns 502 problem-details when provider.fetch rejects", {
timeout: 5000,
}, async () => {
const job = makeJob();
const provider: DataProvider = {
resource: "users",
fetch: async () => {
throw new Error("db unreachable");
},
};
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
expect(out.kind).toBe("failed");
if (out.kind !== "failed") return;
expect(out.error.code).toBe("EXPORT.PROVIDER_FETCH_FAILED");
expect((out.error.context as { statusCode?: number }).statusCode).toBe(
502
);
expect(out.error.cause).toBeInstanceOf(Error);
});
it("captures non-Error throws via String(err)", {
timeout: 5000,
}, async () => {
const job = makeJob();
const provider: DataProvider = {
resource: "users",
// eslint-disable-next-line @typescript-eslint/only-throw-error
fetch: async () => {
throw "raw-string";
},
};
const out = await runDownloadExport(
{
jobStore: new MemoryExportJobStore([[job.id, job]]),
jobs: new Map(),
providers: new Map([["users", provider]]),
},
job.id
);
expect(out.kind).toBe("failed");
if (out.kind !== "failed") return;
expect(out.error.message).toContain("raw-string");
});
});
describe("buildContentDisposition", () => {
it("wraps the filename in an attachment header", { timeout: 5000 }, () => {
expect(buildContentDisposition("users.csv")).toBe(
'attachment; filename="users.csv"'
);
});
it("escapes embedded quotes and backslashes per RFC 6266", {
timeout: 5000,
}, () => {
expect(buildContentDisposition('a"b\\c.csv')).toBe(
'attachment; filename="a\\"b\\\\c.csv"'
);
});
it("strips control characters", { timeout: 5000 }, () => {
const filename = "users.csv";
expect(buildContentDisposition(filename)).toBe(
'attachment; filename="users_.csv"'
);
});
});

View File

@@ -0,0 +1,168 @@
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import { getExportInstruments } from "../../otel/instruments.js";
import {
getExporter,
inferColumns,
} from "../../services/export/export-service.js";
import type { ExportJobStore } from "../../store/types/types.js";
import type {
DataProvider,
ExportJob,
ExportOptions,
} from "../../types/types.js";
/**
* Dependencies for {@link runDownloadExport}. Mirrors the relevant slice
* of `ExportRoutesDeps` so the download handler can be unit-tested in
* isolation from the rest of the route group.
*/
export interface DownloadDeps {
/** Persistent store (preferred). When present, the handler reads
* the job through `jobStore.get(id)`. */
jobStore?: ExportJobStore | undefined;
/** In-memory fallback used when `jobStore` is omitted — the existing
* `Map<string, ExportJob>` slot shipped with the initial plugin. */
jobs: Map<string, ExportJob>;
/** Registered providers keyed by `resource`. */
providers: Map<string, DataProvider>;
}
/**
* Discriminated outcome of {@link runDownloadExport}. The route caller
* branches on `kind` and either streams `body` on the `ok` branch or
* passes the carried `ExportError` to the problem-details renderer.
*/
export type DownloadOutcome =
| {
kind: "ok";
body: string;
contentType: string;
filename: string;
format: ExportJob["format"];
}
| { kind: "not-found"; error: ExportError }
| { kind: "not-ready"; error: ExportError }
| { kind: "provider-not-found"; error: ExportError }
| { kind: "failed"; error: ExportError };
/**
* Resolve a job from either the persistent `jobStore` (preferred) or
* the in-memory `jobs` map fallback.
*/
async function resolveJob(
deps: DownloadDeps,
id: string
): Promise<ExportJob | null> {
if (deps.jobStore !== undefined) {
return deps.jobStore.get(id);
}
return deps.jobs.get(id) ?? null;
}
/**
* Implementation of `GET /:id/download`. Looks up the job, validates
* it is `completed`, re-fetches the source rows through the registered
* `DataProvider`, re-renders the body via the existing format writer,
* and returns a discriminated outcome. Every failure branch instantiates
* an `ExportError` so the OpenSpec error-code registry stays exercised
* from a production path.
*/
export async function runDownloadExport(
deps: DownloadDeps,
id: string
): Promise<DownloadOutcome> {
const instruments = getExportInstruments();
const job = await resolveJob(deps, id);
if (job === null) {
const err = new ExportError(
EXPORT_CODES.JOB_NOT_FOUND,
`Export job not found: ${id}`,
{ context: { id, statusCode: 404 } }
);
instruments.downloadFailed.add(1, { reason: "not-found" });
return { kind: "not-found", error: err };
}
if (job.status !== "completed") {
const err = new ExportError(
EXPORT_CODES.JOB_NOT_READY,
`Export job ${id} is not ready for download (status: ${job.status})`,
{ context: { id, status: job.status, statusCode: 409 } }
);
instruments.downloadFailed.add(1, { reason: "not-ready" });
return { kind: "not-ready", error: err };
}
const provider = deps.providers.get(job.resource);
if (provider === undefined) {
const err = new ExportError(
EXPORT_CODES.PROVIDER_NOT_FOUND,
`No data provider registered for resource: ${job.resource}`,
{
context: {
id,
resource: job.resource,
statusCode: 404,
},
}
);
instruments.downloadFailed.add(1, { reason: "provider-not-found" });
return { kind: "provider-not-found", error: err };
}
try {
const data = await provider.fetch(job.filters);
const columns = inferColumns(data);
const exporter = getExporter(job.format);
const options: ExportOptions = {};
const result = exporter.export(data, columns, options);
const filename = job.filename ?? result.filename;
instruments.downloadServed.add(1, { format: job.format });
return {
kind: "ok",
body: result.content,
contentType: result.mimeType,
filename,
format: job.format,
};
} catch (cause) {
const detail = cause instanceof Error ? cause.message : String(cause);
const err = new ExportError(
EXPORT_CODES.PROVIDER_FETCH_FAILED,
`Download for job ${id} failed: ${detail}`,
{
cause,
context: {
id,
resource: job.resource,
statusCode: 502,
},
}
);
instruments.downloadFailed.add(1, { reason: "failed" });
return { kind: "failed", error: err };
}
}
/**
* Build the value of the `Content-Disposition` header for a download
* response. The filename is escaped per RFC 6266 §4.1 — every `"` is
* doubled, every `\` is doubled, and non-ASCII / control characters
* are replaced with `_`.
*/
export function buildContentDisposition(filename: string): string {
let out = "";
for (const ch of filename) {
// `ch` is a non-empty code-point, so `codePointAt(0)` is defined.
const code = ch.codePointAt(0) as number;
if (code < 0x20 || code === 0x7f) {
out += "_";
} else if (ch === '"' || ch === "\\") {
out += `\\${ch}`;
} else {
out += ch;
}
}
return `attachment; filename="${out}"`;
}

View File

@@ -0,0 +1,59 @@
# `exports.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenAPIHono route group exposed at the configured `basePath` (default `/exports`). Wires four endpoints — `GET /`, `POST /`, `GET /:id`, `DELETE /:id` — against the in-memory `providers` and `jobs` maps that the plugin builds at `onInit`. Every input passes through a Zod schema (`ExportJobListQuerySchema`, `CreateExportJobSchema`, `z.object({ id })`), every error response is an `{ error: string }` body, and every successful POST returns `{ job, downloadUrl }`. The actual POST work is in `post-handler.ts` so this file stays under the 300-line file-size budget.
## Features
- `GET /` filters by `status` / `format` / `resource` and windows by `limit`/`offset`.
- `POST /` resolves the provider, persists a `processing` job, fetches data, enforces row + size caps, runs the exporter, records OTel metrics, and returns the completed job + a `downloadUrl` referencing the (future) `/:id/download` endpoint.
- `GET /:id` returns the job or `404`.
- `DELETE /:id` deletes the job or `404`.
- Injectable `jobIdFactory` and `now` for deterministic tests.
- `onExportStarted` / `onExportCompleted` callbacks let consumers wire a real event bus on top of the route layer.
## Usage: Basic
```ts
import { createExportRoutes, DEFAULT_EXPORT_CONFIG } from "@suaveplan/api-plugin-export";
const routes = createExportRoutes({
providers: new Map(),
jobs: new Map(),
exportConfig: DEFAULT_EXPORT_CONFIG,
});
app.route("/exports", routes);
```
## Usage: Advanced
```ts
import { createExportRoutes } from "@suaveplan/api-plugin-export";
const routes = createExportRoutes({
providers,
jobs,
exportConfig,
jobIdFactory: () => `exp_${seq++}`,
now: () => new Date("2026-05-19T00:00:00Z"),
onExportStarted: ({ jobId, resource }) => logger.info({ jobId, resource }, "export started"),
onExportCompleted: ({ jobId, rowCount, byteSize }) =>
logger.info({ jobId, rowCount, byteSize }, "export completed"),
});
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ExportRoutesDeps` | interface | Dependency bag accepted by `createExportRoutes`. |
| `createExportRoutes(deps)` | function | Returns an `OpenAPIHono` ready to mount. |
## Implementation Notes
The `runPostExport` helper in `post-handler.ts` owns the bulk of the work — the route definitions stay in this file so that the OpenAPI schema is one click away from the route name. Failure paths set `job.status = "failed"` and persist `job.error` *before* returning, so `GET /:id` immediately afterwards sees the failure record (mirroring upstream behaviour). The `downloadUrl` response is `/${jobId}/download` — the actual streaming endpoint is upstream §3 future work, deferred in this migration. The downstream consumer is expected to combine this `downloadUrl` with the `basePath` themselves (e.g. `${basePath}${downloadUrl}`) — the upstream package made the same choice and the route layer cannot know the full mount path. Every route is also registered against an explicit `createRoute(...)` descriptor so an OpenAPI document generated by `OpenAPIHono.getOpenAPIDocument()` includes the export routes with full Zod-derived schemas.

View File

@@ -0,0 +1,536 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DEFAULT_EXPORT_CONFIG } from "../../services/export/export-service.js";
import { MemoryExportJobStore } from "../../store/memory/memory-store.js";
import type { DataProvider, ExportJob } from "../../types/types.js";
import { createExportRoutes, type ExportRoutesDeps } from "./exports.js";
function makeDeps(overrides: Partial<ExportRoutesDeps> = {}): ExportRoutesDeps {
return {
providers: new Map<string, DataProvider>(),
jobs: new Map<string, ExportJob>(),
exportConfig: DEFAULT_EXPORT_CONFIG,
jobIdFactory: () => "fixed-id",
now: () => new Date("2026-05-19T00:00:00.000Z"),
...overrides,
};
}
function jsonReq(body: unknown): RequestInit {
return {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
};
}
describe("createExportRoutes — POST /", () => {
it("POST /exports returns 404 when no provider is registered", {
timeout: 5000,
}, async () => {
const app = createExportRoutes(makeDeps());
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "missing" })
);
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("missing");
});
it("POST /exports happy path returns 200 + persisted completed job", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1, name: "Ada" }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "users" })
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
job: ExportJob;
downloadUrl: string;
};
expect(body.job.status).toBe("completed");
expect(body.job.totalRows).toBe(1);
expect(body.job.fileSize).toBeGreaterThan(0);
expect(body.downloadUrl).toBe("/fixed-id/download");
expect(deps.jobs.get("fixed-id")?.status).toBe("completed");
});
it("POST /exports fires onExportStarted and onExportCompleted", {
timeout: 5000,
}, async () => {
const events: string[] = [];
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
onExportStarted: ({ jobId }) => events.push(`started:${jobId}`),
onExportCompleted: ({ jobId }) => events.push(`completed:${jobId}`),
});
const app = createExportRoutes(deps);
await app.request("/", jsonReq({ format: "json", resource: "users" }));
expect(events).toEqual(["started:fixed-id", "completed:fixed-id"]);
});
it("POST /exports rejects when row count exceeds the configured maximum", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }, { id: 2 }, { id: 3 }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
exportConfig: {
maxRows: 2,
maxFileSizeBytes: 1_000_000,
defaultFormat: "csv",
},
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "users" })
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("row limit");
expect(deps.jobs.get("fixed-id")?.status).toBe("failed");
});
it("POST /exports rejects when file size exceeds the configured maximum", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ name: "lengthy-name" }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
exportConfig: {
maxRows: 1_000,
maxFileSizeBytes: 4,
defaultFormat: "csv",
},
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "users" })
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("file size");
});
it("POST /exports captures provider.fetch errors as 400", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => {
throw new Error("db unreachable");
},
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "users" })
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("db unreachable");
expect(deps.jobs.get("fixed-id")?.status).toBe("failed");
});
it("POST /exports captures non-Error rejections as String(err)", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
// eslint-disable-next-line @typescript-eslint/only-throw-error
fetch: async () => {
throw "raw-string";
},
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "users" })
);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("raw-string");
});
it("POST /exports respects body.filename when supplied", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({
format: "csv",
resource: "users",
filename: "my-export.csv",
})
);
const body = (await res.json()) as { job: ExportJob };
expect(body.job.filename).toBe("my-export.csv");
});
it("POST /exports threads body.columns through inferColumns bypass", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1, secret: "x" }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
});
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({
format: "csv",
resource: "users",
columns: [{ key: "id", header: "ID" }],
})
);
const body = (await res.json()) as { job: ExportJob };
expect(body.job.status).toBe("completed");
expect(body.job.fileSize).toBeLessThan(20);
});
});
describe("createExportRoutes — GET / list", () => {
it("GET /exports applies status filter and pagination", {
timeout: 5000,
}, async () => {
const now = new Date("2026-05-19T00:00:00Z").toISOString();
const mk = (
id: string,
status: ExportJob["status"],
format: ExportJob["format"] = "csv",
resource = "users"
): ExportJob => ({
id,
format,
resource,
status,
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: now,
completedAt: null,
});
const jobs = new Map<string, ExportJob>([
["a", mk("a", "completed")],
["b", mk("b", "completed")],
["c", mk("c", "failed")],
]);
const app = createExportRoutes(makeDeps({ jobs }));
const res = await app.request("/?status=completed&limit=1");
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: ExportJob[];
total: number;
};
expect(body.total).toBe(2);
expect(body.items).toHaveLength(1);
});
it("GET /exports applies format and resource filters", {
timeout: 5000,
}, async () => {
const now = new Date("2026-05-19T00:00:00Z").toISOString();
const mk = (
id: string,
format: ExportJob["format"],
resource: string
): ExportJob => ({
id,
format,
resource,
status: "completed",
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: now,
completedAt: null,
});
const jobs = new Map<string, ExportJob>([
["a", mk("a", "csv", "users")],
["b", mk("b", "json", "users")],
["c", mk("c", "csv", "orders")],
]);
const app = createExportRoutes(makeDeps({ jobs }));
const res = await app.request("/?format=csv&resource=users");
const body = (await res.json()) as {
items: ExportJob[];
total: number;
};
expect(body.total).toBe(1);
expect(body.items[0]?.id).toBe("a");
});
});
describe("createExportRoutes — GET /:id and DELETE /:id", () => {
it("GET /:id returns the job", { timeout: 5000 }, async () => {
const job: ExportJob = {
id: "abc",
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: undefined,
totalRows: 1,
exportedRows: 1,
fileSize: 10,
error: null,
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
const jobs = new Map<string, ExportJob>([["abc", job]]);
const app = createExportRoutes(makeDeps({ jobs }));
const res = await app.request("/abc");
expect(res.status).toBe(200);
const body = (await res.json()) as ExportJob;
expect(body.id).toBe("abc");
});
it("GET /:id returns 404 when the job is missing", {
timeout: 5000,
}, async () => {
const app = createExportRoutes(makeDeps());
const res = await app.request("/missing");
expect(res.status).toBe(404);
});
it("DELETE /exports/{id} returns 200 on success", {
timeout: 5000,
}, async () => {
const job: ExportJob = {
id: "abc",
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: new Date().toISOString(),
completedAt: null,
};
const jobs = new Map<string, ExportJob>([["abc", job]]);
const deps = makeDeps({ jobs });
const app = createExportRoutes(deps);
const res = await app.request("/abc", { method: "DELETE" });
expect(res.status).toBe(200);
expect(deps.jobs.has("abc")).toBe(false);
});
it("DELETE /exports/{id} returns 404 when the job is missing", {
timeout: 5000,
}, async () => {
const app = createExportRoutes(makeDeps());
const res = await app.request("/missing", { method: "DELETE" });
expect(res.status).toBe(404);
});
});
describe("createExportRoutes — GET /:id/download", () => {
function completedJob(overrides: Partial<ExportJob> = {}): ExportJob {
return {
id: "dl-1",
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: "users.csv",
totalRows: 1,
exportedRows: 1,
fileSize: 10,
error: null,
createdAt: new Date("2026-05-20T00:00:00Z").toISOString(),
completedAt: new Date("2026-05-20T00:00:01Z").toISOString(),
...overrides,
};
}
it("GET /{id}/download streams the rendered body end-to-end", {
timeout: 5000,
}, async () => {
const job = completedJob();
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1, name: "Ada" }],
};
const deps = makeDeps({
providers: new Map([["users", provider]]),
jobStore: new MemoryExportJobStore([[job.id, job]]),
});
const app = createExportRoutes(deps);
const res = await app.request(`/${job.id}/download`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/csv");
expect(res.headers.get("content-disposition")).toBe(
'attachment; filename="users.csv"'
);
const text = await res.text();
expect(text).toContain("id,name");
expect(text).toContain("Ada");
});
it("GET /{id}/download falls back to the in-memory jobs map when no jobStore is wired", {
timeout: 5000,
}, async () => {
const job = completedJob({ id: "in-memory" });
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const app = createExportRoutes(
makeDeps({
providers: new Map([["users", provider]]),
jobs: new Map([[job.id, job]]),
})
);
const res = await app.request(`/${job.id}/download`);
expect(res.status).toBe(200);
});
it("GET /{id}/download returns 404 problem-details when the job is missing", {
timeout: 5000,
}, async () => {
const app = createExportRoutes(makeDeps());
const res = await app.request("/missing/download");
expect(res.status).toBe(404);
expect(res.headers.get("content-type")).toBe(
"application/problem+json"
);
const body = (await res.json()) as {
code: string;
status: number;
};
expect(body.code).toBe("EXPORT.JOB_NOT_FOUND");
expect(body.status).toBe(404);
});
it("GET /{id}/download returns 409 problem-details when the job is not yet completed", {
timeout: 5000,
}, async () => {
const job = completedJob({
id: "still-processing",
status: "processing",
});
const app = createExportRoutes(
makeDeps({
jobs: new Map([[job.id, job]]),
})
);
const res = await app.request(`/${job.id}/download`);
expect(res.status).toBe(409);
expect(res.headers.get("content-type")).toBe(
"application/problem+json"
);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EXPORT.JOB_NOT_READY");
});
it("GET /{id}/download returns 404 problem-details when the provider was deregistered", {
timeout: 5000,
}, async () => {
const job = completedJob({ resource: "ghost" });
const app = createExportRoutes(
makeDeps({
jobs: new Map([[job.id, job]]),
})
);
const res = await app.request(`/${job.id}/download`);
expect(res.status).toBe(404);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EXPORT.PROVIDER_NOT_FOUND");
});
it("GET /{id}/download returns 502 problem-details when provider.fetch rejects", {
timeout: 5000,
}, async () => {
const job = completedJob({ id: "bad-fetch" });
const provider: DataProvider = {
resource: "users",
fetch: async () => {
throw new Error("db unreachable");
},
};
const app = createExportRoutes(
makeDeps({
providers: new Map([["users", provider]]),
jobs: new Map([[job.id, job]]),
})
);
const res = await app.request(`/${job.id}/download`);
expect(res.status).toBe(502);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EXPORT.PROVIDER_FETCH_FAILED");
});
});
describe("createExportRoutes — defaults", () => {
it("uses default jobIdFactory and now when not overridden", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const deps: ExportRoutesDeps = {
providers: new Map([["users", provider]]),
jobs: new Map(),
exportConfig: DEFAULT_EXPORT_CONFIG,
};
const app = createExportRoutes(deps);
const res = await app.request(
"/",
jsonReq({ format: "csv", resource: "users" })
);
const body = (await res.json()) as {
job: ExportJob;
downloadUrl: string;
};
expect(body.job.id).toMatch(/^exp_\d+_[a-z0-9]+$/);
expect(body.downloadUrl).toContain("/download");
expect(typeof body.job.createdAt).toBe("string");
});
});

View File

@@ -0,0 +1,300 @@
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import type { ExportServiceConfig } from "../../services/export/export-service.js";
import type { ExportJobStore } from "../../store/types/types.js";
import {
CreateExportJobSchema,
type DataProvider,
type ExportJob,
ExportJobListQuerySchema,
ExportJobSchema,
} from "../../types/types.js";
import {
buildContentDisposition,
runDownloadExport,
} from "../download/download.js";
import { respondProblemJson } from "../problem-json/problem-json.js";
import { runPostExport } from "./post-handler.js";
export interface ExportRoutesDeps {
providers: Map<string, DataProvider>;
exportConfig: ExportServiceConfig;
jobs: Map<string, ExportJob>;
/**
* Optional persistent job store. When supplied, the `GET /{id}/download`
* route reads from this store; otherwise it falls back to the
* in-memory `jobs` map.
*/
jobStore?: ExportJobStore | undefined;
onExportStarted?: (payload: {
jobId: string;
resource: string;
format: string;
}) => void;
onExportCompleted?: (payload: {
jobId: string;
rowCount: number;
byteSize: number;
}) => void;
jobIdFactory?: () => string;
now?: () => Date;
}
export function createExportRoutes(deps: ExportRoutesDeps): OpenAPIHono {
const app = new OpenAPIHono();
const listRoute = createRoute({
method: "get",
path: "/",
tags: ["exports"],
summary: "List export jobs",
request: { query: ExportJobListQuerySchema },
responses: {
200: {
description: "Export job list",
content: {
"application/json": {
schema: z.object({
items: z.array(ExportJobSchema),
total: z.number(),
}),
},
},
},
},
});
app.openapi(listRoute, (c) => {
const query = c.req.valid("query");
let items = Array.from(deps.jobs.values());
if (query.status)
items = items.filter((j) => j.status === query.status);
if (query.format)
items = items.filter((j) => j.format === query.format);
if (query.resource)
items = items.filter((j) => j.resource === query.resource);
const total = items.length;
items = items.slice(query.offset, query.offset + query.limit);
return c.json({ items, total }, 200);
});
const createExportRoute = createRoute({
method: "post",
path: "/",
tags: ["exports"],
summary: "Create a data export",
request: {
body: {
content: {
"application/json": { schema: CreateExportJobSchema },
},
},
},
responses: {
200: {
description: "Export completed",
content: {
"application/json": {
schema: z.object({
job: ExportJobSchema,
downloadUrl: z.string(),
}),
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: z.object({ error: z.string() }),
},
},
},
404: {
description: "Resource provider not found",
content: {
"application/json": {
schema: z.object({ error: z.string() }),
},
},
},
},
});
app.openapi(createExportRoute, async (c) => {
const outcome = await runPostExport(deps, c.req.valid("json"));
if (outcome.kind === "not-found") {
return c.json({ error: outcome.error }, 404);
}
if (outcome.kind === "bad-request") {
return c.json({ error: outcome.error }, 400);
}
return c.json(
{ job: outcome.job, downloadUrl: outcome.downloadUrl },
200
);
});
const getRoute = createRoute({
method: "get",
path: "/{id}",
tags: ["exports"],
summary: "Get an export job by ID",
request: { params: z.object({ id: z.string() }) },
responses: {
200: {
description: "Export job details",
content: {
"application/json": { schema: ExportJobSchema },
},
},
404: {
description: "Export job not found",
content: {
"application/json": {
schema: z.object({ error: z.string() }),
},
},
},
},
});
app.openapi(getRoute, (c) => {
const { id } = c.req.valid("param");
const job = deps.jobs.get(id);
if (!job) {
const err = new ExportError(
EXPORT_CODES.JOB_NOT_FOUND,
"Export job not found",
{ context: { id } }
);
return c.json({ error: err.message }, 404);
}
return c.json(job, 200);
});
const downloadRoute = createRoute({
method: "get",
path: "/{id}/download",
tags: ["exports"],
summary: "Download a completed export job",
request: { params: z.object({ id: z.string() }) },
responses: {
200: {
description: "Rendered export body",
content: {
"text/csv": { schema: z.string() },
"application/json": { schema: z.string() },
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
{ schema: z.string() },
},
},
404: {
description:
"Export job not found or provider deregistered (RFC 7807 problem-details)",
content: {
"application/problem+json": {
schema: z.object({
type: z.string(),
title: z.string(),
status: z.number(),
detail: z.string(),
code: z.string(),
}),
},
},
},
409: {
description: "Export job is not yet completed",
content: {
"application/problem+json": {
schema: z.object({
type: z.string(),
title: z.string(),
status: z.number(),
detail: z.string(),
code: z.string(),
}),
},
},
},
502: {
description: "Provider fetch / render failure",
content: {
"application/problem+json": {
schema: z.object({
type: z.string(),
title: z.string(),
status: z.number(),
detail: z.string(),
code: z.string(),
}),
},
},
},
},
});
app.openapi(downloadRoute, async (c) => {
const { id } = c.req.valid("param");
const outcome = await runDownloadExport(
{
jobStore: deps.jobStore,
jobs: deps.jobs,
providers: deps.providers,
},
id
);
if (outcome.kind === "ok") {
c.header("content-type", outcome.contentType);
c.header(
"content-disposition",
buildContentDisposition(outcome.filename)
);
return c.body(outcome.body, 200);
}
return respondProblemJson(c, outcome.error);
});
const deleteRoute = createRoute({
method: "delete",
path: "/{id}",
tags: ["exports"],
summary: "Delete an export job",
request: { params: z.object({ id: z.string() }) },
responses: {
200: {
description: "Export job deleted",
content: {
"application/json": {
schema: z.object({ success: z.boolean() }),
},
},
},
404: {
description: "Export job not found",
content: {
"application/json": {
schema: z.object({ error: z.string() }),
},
},
},
},
});
app.openapi(deleteRoute, (c) => {
const { id } = c.req.valid("param");
const existed = deps.jobs.delete(id);
if (!existed) {
const err = new ExportError(
EXPORT_CODES.JOB_NOT_FOUND,
"Export job not found",
{ context: { id } }
);
return c.json({ error: err.message }, 404);
}
return c.json({ success: true }, 200);
});
return app;
}

View File

@@ -0,0 +1,48 @@
# `post-handler.ts`
## Purpose
Implementation of the `POST /exports` handler, extracted from `exports.ts` so the route-registration file stays under the 300-line file-size budget. This is the only non-trivial async path in the route layer: it resolves the provider, persists a fresh `processing` job, fetches data, enforces the row + byte-size caps, runs the exporter, records OpenTelemetry metrics, and returns the completed job + a `downloadUrl`. Every failure mode updates the job record in place before returning, so a follow-up `GET /:id` sees the failure persisted.
## Features
- Provider missing → `404` with `EXPORT.PROVIDER_NOT_FOUND` message.
- Row cap breach → `400`, `status: "failed"`, `jobsTotal{outcome=failed}` incremented.
- Byte cap breach → `400`, `status: "failed"`, `jobsTotal{outcome=failed}` incremented.
- Unhandled error → `400`, `status: "failed"`, `error` is `err.message` or `String(err)`.
- Happy path → `200`, `status: "completed"`, `jobsTotal{outcome=completed}` + `rowsTotal` + `bytesTotal` + `durationMs` recorded.
## Usage: Basic
```ts
// Indirect — `runPostExport` is wired by `createExportRoutes(...)`.
import { createExportRoutes } from "@suaveplan/api-plugin-export";
const routes = createExportRoutes({ providers, jobs, exportConfig });
```
## Usage: Advanced
```ts
import { runPostExport } from "@suaveplan/api-plugin-export/routes/exports/post-handler";
// Direct invocation, e.g. from a custom Hono group that wants the same
// pipeline but a different OpenAPI shape.
app.post("/my-export", async (c) => {
const body = MyBodySchema.parse(await c.req.json());
const outcome = await runPostExport(deps, body);
if (outcome.kind === "not-found") return c.json({ error: outcome.error }, 404);
if (outcome.kind === "bad-request") return c.json({ error: outcome.error }, 400);
return c.json({ job: outcome.job, downloadUrl: outcome.downloadUrl }, 200);
});
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `runPostExport(deps, body)` | async function | Returns a `PostExportOutcome` discriminated union. |
| `PostExportOutcome` | type | `{ kind: "not-found" \| "bad-request"; error } \| { kind: "ok"; job; downloadUrl }`. |
## Implementation Notes
The handler holds a single `const t0 = Date.now()` because all four outcomes need the `durationMs` recording, and threading a `t0` through three try/catch branches would obscure the control flow. The two cap breaches are explicit `return` statements rather than `throw new ExportError(...)` because the route layer maps cap breaches to `400` while keeping the unhandled-error path open for genuine bugs — duplicating the bookkeeping is intentional to keep each return site self-contained. The unhandled-error branch deliberately reports `err.message` rather than wrapping the value in another `ExportError` — the route is the last stop before the wire, and double-wrapping would hide root causes from log aggregators. The `now()` and `jobIdFactory` injectables let tests assert on a stable `createdAt` / `completedAt` and a stable `jobId` without monkey-patching `Date.now`. `instruments.jobsTotal.add(1, { outcome: ... })` matches the conventions in `@suaveplan/api-plugin-database`'s `pool` instrumentation, so dashboards can reuse the same `outcome` axis across plugins.

View File

@@ -0,0 +1,141 @@
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import { getExportInstruments } from "../../otel/instruments.js";
import {
getExporter,
inferColumns,
} from "../../services/export/export-service.js";
import type {
CreateExportJob,
ExportJob,
ExportOptions,
} from "../../types/types.js";
import type { ExportRoutesDeps } from "./exports.js";
function defaultJobId(): string {
return `exp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Outcome of running the POST handler. The caller (route registration
* in `exports.ts`) is responsible for turning this into a Hono response
* with the appropriate status code — keeping the response shape
* decoupled from `c.json` lets `openapi`'s strict typed-response
* signature flow.
*/
export type PostExportOutcome =
| { kind: "not-found"; error: string }
| { kind: "bad-request"; error: string }
| { kind: "ok"; job: ExportJob; downloadUrl: string };
/**
* Implementation of `POST /exports`. Resolves the provider, persists a
* fresh `processing` job, fetches data, enforces row + byte-size caps,
* runs the exporter, records OTel metrics, and returns a discriminated
* outcome.
*/
export async function runPostExport(
deps: ExportRoutesDeps,
body: CreateExportJob
): Promise<PostExportOutcome> {
const instruments = getExportInstruments();
const jobIdFactory = deps.jobIdFactory ?? defaultJobId;
const now = deps.now ?? (() => new Date());
const provider = deps.providers.get(body.resource);
if (!provider) {
const err = new ExportError(
EXPORT_CODES.PROVIDER_NOT_FOUND,
`No data provider registered for resource: ${body.resource}`,
{ context: { resource: body.resource } }
);
return { kind: "not-found", error: err.message };
}
const jobId = jobIdFactory();
const job: ExportJob = {
id: jobId,
format: body.format,
resource: body.resource,
filters: body.filters,
filename: body.filename ?? undefined,
status: "processing",
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: now().toISOString(),
completedAt: null,
};
deps.jobs.set(jobId, job);
deps.onExportStarted?.({
jobId,
resource: body.resource,
format: body.format,
});
const t0 = Date.now();
try {
const data = await provider.fetch(body.filters);
if (data.length > deps.exportConfig.maxRows) {
job.status = "failed";
job.error = `Export exceeds maximum row limit of ${deps.exportConfig.maxRows}`;
deps.jobs.set(jobId, job);
instruments.jobsTotal.add(1, { outcome: "failed" });
instruments.durationMs.record(Date.now() - t0, {
outcome: "failed",
});
return { kind: "bad-request", error: job.error };
}
const columns = body.columns ?? inferColumns(data);
const exporter = getExporter(body.format);
const options: ExportOptions = body.options ?? {};
const result = exporter.export(data, columns, options);
if (result.byteSize > deps.exportConfig.maxFileSizeBytes) {
job.status = "failed";
job.error = `Export exceeds maximum file size of ${deps.exportConfig.maxFileSizeBytes} bytes`;
deps.jobs.set(jobId, job);
instruments.jobsTotal.add(1, { outcome: "failed" });
instruments.durationMs.record(Date.now() - t0, {
outcome: "failed",
});
return { kind: "bad-request", error: job.error };
}
job.status = "completed";
job.totalRows = data.length;
job.exportedRows = result.rowCount;
job.fileSize = result.byteSize;
job.filename = body.filename ?? result.filename;
job.completedAt = now().toISOString();
deps.jobs.set(jobId, job);
deps.onExportCompleted?.({
jobId,
rowCount: result.rowCount,
byteSize: result.byteSize,
});
instruments.jobsTotal.add(1, { outcome: "completed" });
instruments.rowsTotal.add(result.rowCount);
instruments.bytesTotal.add(result.byteSize);
instruments.durationMs.record(Date.now() - t0, {
outcome: "completed",
});
return {
kind: "ok",
job,
downloadUrl: `/${jobId}/download`,
};
} catch (err) {
job.status = "failed";
job.error = err instanceof Error ? err.message : String(err);
deps.jobs.set(jobId, job);
instruments.jobsTotal.add(1, { outcome: "failed" });
instruments.durationMs.record(Date.now() - t0, {
outcome: "failed",
});
return { kind: "bad-request", error: job.error };
}
}

View File

@@ -0,0 +1,127 @@
# `routes/problem-json` — RFC 7807 problem-details renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/api-plugin-export` returns RFC 7807 `application/problem+json` bodies for every
failure surface on its route group — missing job, not-ready job, missing provider,
provider-fetch rejection. This module centralises that mapping so each route handler stays
focused on its happy path while the renderer translates `ExportError` / plain `Error` /
unknown throwable into a stable wire shape.
The renderer is a peer of the sibling renderers shipped by `@suaveplan/api-plugin-import`,
`@suaveplan/api-plugin-database`, and `@suaveplan/api-plugin-cms`. Each api-plugin package
owns its own copy so the package-local error-code namespace flows straight through to the
`type` URI without cross-package coupling.
## Features
- `PROBLEM_JSON_CONTENT_TYPE` constant — exported so consumers can match on the wire
content-type without hard-coding the string.
- `ProblemDetails` type — the canonical RFC 7807 shape with an open `[key: string]: unknown`
bag so callers may extend with custom diagnostic fields.
- `renderProblem(err)` — pure function that takes any throwable and returns a
`ProblemDetails` object. Easy to unit-test outside Hono.
- `respondProblemJson(c, err)` — writes the `Content-Type` header on a Hono-like context
and returns the JSON body with the chosen HTTP status. Decoupled from a specific Hono
version via the structural `HonoLike` interface.
- HTTP-status mapping: `SuaveplanError` instances read `context.statusCode` (must be ≥ 400
to be honoured, otherwise coerces to `500`). Plain `Error` and unknown throwables fall
through to a generic `500 Internal Server Error`.
- `type` URI follows the convention `urn:suaveplan:error:<lowercase-error-code>`; e.g.
`EXPORT.JOB_NOT_READY` → `urn:suaveplan:error:export.job_not_ready`.
## Usage: Basic
```ts
import { Hono } from "hono";
import { EXPORT_CODES, ExportError } from "@suaveplan/api-plugin-export";
import { respondProblemJson } from "@suaveplan/api-plugin-export/routes/problem-json/problem-json";
const app = new Hono();
app.get("/example", (c) => {
return respondProblemJson(
c,
new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "missing", {
context: { statusCode: 404 },
})
);
});
```
The response carries `Content-Type: application/problem+json`, status `404`, and a body of
`{ "type": "urn:suaveplan:error:export.job_not_found", "title": "SuaveplanError", "status": 404, "detail": "missing", "code": "EXPORT.JOB_NOT_FOUND" }`.
## Usage: Advanced
Drive the renderer from a discriminated-union outcome so a route handler stays composable:
```ts
import {
EXPORT_CODES,
ExportError,
} from "@suaveplan/api-plugin-export";
import {
renderProblem,
respondProblemJson,
} from "@suaveplan/api-plugin-export/routes/problem-json/problem-json";
type Outcome =
| { kind: "ok"; payload: string }
| { kind: "not-found"; id: string }
| { kind: "not-ready"; status: string };
function respond(c: Hono.Context, outcome: Outcome): Response {
switch (outcome.kind) {
case "ok":
return c.json({ payload: outcome.payload }, 200);
case "not-found":
return respondProblemJson(
c,
new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "job missing", {
context: { statusCode: 404, id: outcome.id },
})
);
case "not-ready":
return respondProblemJson(
c,
new ExportError(EXPORT_CODES.JOB_NOT_READY, "not ready", {
context: { statusCode: 409, status: outcome.status },
})
);
}
}
```
For tests, `renderProblem(err)` returns a plain object you can deep-assert against:
```ts
const out = renderProblem(new Error("boom"));
expect(out.status).toBe(500);
```
## API Reference
| Symbol | Kind | Signature | Notes |
|---|---|---|---|
| `PROBLEM_JSON_CONTENT_TYPE` | `const string` | `"application/problem+json"` | RFC 7807 |
| `ProblemDetails` | `interface` | `{ type; title; status; detail; code?; [k: string]: unknown }` | Open shape |
| `HonoLike` | `interface` | `header(name, value); body(data, status): Response` | Structural |
| `renderProblem(err: unknown): ProblemDetails` | function | Pure | Deterministic |
| `respondProblemJson(c: HonoLike, err: unknown): Response` | function | Writes header + body | |
## Implementation Notes
- `SuaveplanError` detection uses `instanceof`. The export package depends on
`@suaveplan/error` directly, so the symbol identity matches across the package boundary.
- `context.statusCode` must be a finite number ≥ `400` to be honoured; anything else coerces
to `500`. This keeps a misconfigured `statusCode: 200` from leaking through as a
success-shaped error body.
- The renderer is intentionally synchronous and side-effect-free in its pure form
(`renderProblem`). The Hono-coupled `respondProblemJson` writes through `c.header` once
and returns the response.
- The `type` URN convention matches the sibling renderers across api-plugins so consumers
can wire one `type`-to-help-page mapping across the platform.

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import {
type HonoLike,
PROBLEM_JSON_CONTENT_TYPE,
renderProblem,
respondProblemJson,
} from "./problem-json.js";
describe("renderProblem", () => {
it("maps SuaveplanError to typed problem-details with status from context", {
timeout: 5000,
}, () => {
const err = new ExportError(
EXPORT_CODES.JOB_NOT_READY,
"job is still processing",
{ context: { statusCode: 409, id: "x" } }
);
const out = renderProblem(err);
expect(out.status).toBe(409);
expect(out.code).toBe("EXPORT.JOB_NOT_READY");
expect(out.type).toBe("urn:suaveplan:error:export.job_not_ready");
expect(out.detail).toBe("job is still processing");
expect(out.title).toBe("ExportError");
});
it("coerces missing or sub-400 statusCode to 500", {
timeout: 5000,
}, () => {
const a = new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "missing", {
context: { statusCode: 200 },
});
const b = new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "missing");
expect(renderProblem(a).status).toBe(500);
expect(renderProblem(b).status).toBe(500);
});
it("falls through to internal 500 for plain Error", {
timeout: 5000,
}, () => {
const out = renderProblem(new Error("boom"));
expect(out.status).toBe(500);
expect(out.title).toBe("Error");
expect(out.detail).toBe("boom");
expect(out.type).toBe("urn:suaveplan:error:internal");
});
it("defaults Error.name to InternalError when blank", {
timeout: 5000,
}, () => {
const e = new Error("");
Object.defineProperty(e, "name", { value: "" });
const out = renderProblem(e);
expect(out.title).toBe("InternalError");
expect(out.detail).toBe("Internal Server Error");
});
it("falls through to internal 500 for unknown throwables", {
timeout: 5000,
}, () => {
const out = renderProblem("raw-string");
expect(out.status).toBe(500);
expect(out.title).toBe("InternalError");
expect(out.detail).toBe("Internal Server Error");
});
});
describe("respondProblemJson", () => {
it("sets the Content-Type header and writes the JSON body", {
timeout: 5000,
}, () => {
const headers = new Map<string, string>();
let captured: { data: string; status: number } | null = null;
const c: HonoLike = {
header: (name, value) => {
headers.set(name, value);
},
body: (data, status) => {
captured = { data, status };
return new Response(data, { status });
},
};
const err = new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "missing", {
context: { statusCode: 404 },
});
respondProblemJson(c, err);
expect(headers.get("content-type")).toBe(PROBLEM_JSON_CONTENT_TYPE);
expect(captured).not.toBeNull();
const parsed = JSON.parse(
(captured as unknown as { data: string }).data
) as { code: string; status: number };
expect(parsed.code).toBe("EXPORT.JOB_NOT_FOUND");
expect(parsed.status).toBe(404);
});
});

View File

@@ -0,0 +1,72 @@
import { SuaveplanError } from "@suaveplan/error";
/** Standard RFC 7807 content-type. */
export const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json";
/**
* Minimal structural Hono context surface relied on by
* {@link respondProblemJson}. Declared as an interface so the renderer
* does not couple to a specific Hono version.
*/
export interface HonoLike {
header(name: string, value: string): void;
body(data: string, status: number): Response;
}
/** RFC 7807 problem-details body. */
export interface ProblemDetails {
readonly type: string;
readonly title: string;
readonly status: number;
readonly detail: string;
readonly code?: string;
readonly [key: string]: unknown;
}
/**
* Convert an arbitrary error into an RFC 7807 problem-details body and
* write it to the supplied Hono context. `SuaveplanError` subclasses
* surface their `code` and read `context.statusCode` for the HTTP code;
* plain `Error` and unknown throwables fall through to a generic
* `500 Internal Server Error`.
*/
export function respondProblemJson(c: HonoLike, err: unknown): Response {
const body = renderProblem(err);
c.header("content-type", PROBLEM_JSON_CONTENT_TYPE);
return c.body(JSON.stringify(body), body.status);
}
/**
* Pure render — kept separate from {@link respondProblemJson} so the
* shape can be asserted in tests that do not construct a Hono context.
*/
export function renderProblem(err: unknown): ProblemDetails {
if (err instanceof SuaveplanError) {
const ctx = err.context as { statusCode?: number } | undefined;
const status =
typeof ctx?.statusCode === "number" && ctx.statusCode >= 400
? ctx.statusCode
: 500;
return {
type: `urn:suaveplan:error:${err.code.toLowerCase()}`,
title: err.name,
status,
detail: err.message,
code: err.code,
};
}
if (err instanceof Error) {
return {
type: "urn:suaveplan:error:internal",
title: err.name || "InternalError",
status: 500,
detail: err.message || "Internal Server Error",
};
}
return {
type: "urn:suaveplan:error:internal",
title: "InternalError",
status: 500,
detail: "Internal Server Error",
};
}

View File

@@ -0,0 +1,80 @@
# `export-service.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Service-level orchestration for the export plugin. This module wires the three exporter classes (`CsvExporter`, `JsonExporter`, `XlsxExporter`) behind a single `getExporter(format)` dispatcher, derives default column definitions from raw data via `inferColumns(data)`, and enforces the row + byte-size guards in `runExport(...)`. All guard failures throw `ExportError` carrying one of the four service-level codes (`UNSUPPORTED_FORMAT`, `ROW_LIMIT_EXCEEDED`, `FILE_SIZE_EXCEEDED`, `PROVIDER_FETCH_FAILED`) rather than the raw `Error` upstream used.
## Features
- `getExporter(format)` returns a fresh `Exporter` instance per call — no shared state across exports.
- `inferColumns(data)` collapses heterogeneous row keys into a stable column order (first-seen).
- `runExport(provider, format, columns?, filters?, options?, config?)` is the single async path used by the route handler — every guard lives here.
- `DEFAULT_EXPORT_CONFIG` captures the upstream defaults (50 000 rows, 50 MiB).
- Failures from `provider.fetch` are wrapped in `ExportError(PROVIDER_FETCH_FAILED)` with the original error preserved as `cause`.
## Usage: Basic
```ts
import {
runExport,
DEFAULT_EXPORT_CONFIG,
type DataProvider,
} from "@suaveplan/api-plugin-export";
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1 }],
};
const result = await runExport(provider, "csv", undefined, undefined, undefined, DEFAULT_EXPORT_CONFIG);
console.log(result.content);
```
## Usage: Advanced
```ts
import {
runExport,
type ExportServiceConfig,
} from "@suaveplan/api-plugin-export";
const config: ExportServiceConfig = {
maxRows: 1_000,
maxFileSizeBytes: 1024 * 1024,
defaultFormat: "csv",
};
try {
const result = await runExport(
myProvider,
"json",
[{ key: "id", header: "ID" }],
{ active: true },
{ pretty: true },
config
);
// …
} catch (err) {
if (err instanceof ExportError && err.code === "EXPORT.ROW_LIMIT_EXCEEDED") {
// tell the user to narrow their filters
}
throw err;
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ExportServiceConfig` | interface | `{ maxRows, maxFileSizeBytes, defaultFormat }`. |
| `DEFAULT_EXPORT_CONFIG` | const | Upstream defaults: 50 000 rows, 50 MiB, `csv`. |
| `getExporter(format)` | function | Throws `EXPORT.UNSUPPORTED_FORMAT` on unknown. |
| `inferColumns(data)` | function | Returns `[]` on empty data. |
| `runExport(provider, format, columns?, filters?, options?, config?)` | async function | Throws `EXPORT.*` on guard breach. |
## Implementation Notes
The two guards run in a specific order: row count is checked *before* the exporter runs so a runaway provider doesn't burn CPU encoding fields the route layer is about to reject. File size is checked *after* encoding because cell-level transforms and XML/JSON overhead can push a "small" row count over the byte cap. The `default:` branch in `getExporter` is reachable only if a caller bypasses the Zod boundary (the schema enumerates `csv|json|xlsx`); the `format as string` cast in the error message is intentional — we want the actual value the caller passed to surface in logs, not the post-narrowing `never`. `provider.fetch` errors are wrapped rather than thrown raw because the route layer needs to distinguish a *bad provider* (502-class) from a *bad request* (400-class), and the `ExportError` code is the contract that drives that branching upstream.

View File

@@ -0,0 +1,198 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import type { DataProvider, ExportFormat } from "../../types/types.js";
import {
DEFAULT_EXPORT_CONFIG,
getExporter,
inferColumns,
runExport,
} from "./export-service.js";
const tinyConfig = {
maxRows: 2,
maxFileSizeBytes: 1_000_000,
defaultFormat: "csv" as const,
};
function makeProvider(
data: Record<string, unknown>[],
resource = "users"
): DataProvider {
return { resource, fetch: async () => data };
}
describe("getExporter", () => {
it("returns CsvExporter for csv", { timeout: 5000 }, () => {
expect(getExporter("csv").format).toBe("csv");
});
it("returns JsonExporter for json", { timeout: 5000 }, () => {
expect(getExporter("json").format).toBe("json");
});
it("returns XlsxExporter for xlsx", { timeout: 5000 }, () => {
expect(getExporter("xlsx").format).toBe("xlsx");
});
it("getExporter throws on unsupported format", { timeout: 5000 }, () => {
try {
getExporter("parquet" as ExportFormat);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ExportError);
expect((err as ExportError).code).toBe(
EXPORT_CODES.UNSUPPORTED_FORMAT
);
}
});
});
describe("inferColumns", () => {
it("returns [] on empty input", { timeout: 5000 }, () => {
expect(inferColumns([])).toEqual([]);
});
it("merges keys across rows in first-seen order", { timeout: 5000 }, () => {
expect(
inferColumns([
{ a: 1, b: 2 },
{ b: 3, c: 4 },
])
).toEqual([
{ key: "a", header: "a" },
{ key: "b", header: "b" },
{ key: "c", header: "c" },
]);
});
});
describe("runExport", () => {
it("happy path uses default config when none provided", {
timeout: 5000,
}, async () => {
const result = await runExport(makeProvider([{ id: 1 }]), "csv");
expect(result.rowCount).toBe(1);
expect(result.mimeType).toBe("text/csv");
});
it("infers columns when none provided", { timeout: 5000 }, async () => {
const result = await runExport(
makeProvider([{ a: 1, b: 2 }]),
"csv",
undefined,
undefined,
undefined,
DEFAULT_EXPORT_CONFIG
);
expect(result.content).toContain("a,b");
});
it("runExport rejects when row count exceeds the configured maximum", {
timeout: 5000,
}, async () => {
await expect(
runExport(
makeProvider([{ id: 1 }, { id: 2 }, { id: 3 }]),
"csv",
undefined,
undefined,
undefined,
tinyConfig
)
).rejects.toMatchObject({
code: EXPORT_CODES.ROW_LIMIT_EXCEEDED,
});
});
it("runExport rejects when result byteSize exceeds the configured maximum", {
timeout: 5000,
}, async () => {
const cfg = {
maxRows: 1000,
maxFileSizeBytes: 4,
defaultFormat: "csv" as const,
};
await expect(
runExport(
makeProvider([{ name: "lengthy-name" }]),
"csv",
undefined,
undefined,
undefined,
cfg
)
).rejects.toMatchObject({
code: EXPORT_CODES.FILE_SIZE_EXCEEDED,
});
});
it("wraps provider.fetch errors in ExportError(PROVIDER_FETCH_FAILED)", {
timeout: 5000,
}, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => {
throw new Error("db unreachable");
},
};
await expect(
runExport(
provider,
"csv",
undefined,
undefined,
undefined,
DEFAULT_EXPORT_CONFIG
)
).rejects.toMatchObject({
code: EXPORT_CODES.PROVIDER_FETCH_FAILED,
});
});
it("wraps non-Error fetch rejections", { timeout: 5000 }, async () => {
const provider: DataProvider = {
resource: "users",
fetch: async () => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw "raw string";
},
};
await expect(runExport(provider, "csv")).rejects.toMatchObject({
code: EXPORT_CODES.PROVIDER_FETCH_FAILED,
});
});
it("forwards caller-supplied columns + options", {
timeout: 5000,
}, async () => {
const result = await runExport(
makeProvider([{ price: 100 }]),
"json",
[
{
key: "price",
header: "Price",
transform: (v) => `$${v as number}`,
},
],
undefined,
{ pretty: true }
);
expect(JSON.parse(result.content)).toEqual([{ Price: "$100" }]);
});
it("passes filters through to provider.fetch", {
timeout: 5000,
}, async () => {
let captured: Record<string, unknown> | undefined;
const provider: DataProvider = {
resource: "users",
fetch: async (filters) => {
captured = filters;
return [];
},
};
await runExport(provider, "csv", undefined, { active: true });
expect(captured).toEqual({ active: true });
});
});

View File

@@ -0,0 +1,120 @@
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import { CsvExporter } from "../../exporters/csv/csv.js";
import { JsonExporter } from "../../exporters/json/json.js";
import { XlsxExporter } from "../../exporters/xlsx/xlsx.js";
import type {
ColumnDefinition,
DataProvider,
Exporter,
ExportFormat,
ExportOptions,
ExportResult,
} from "../../types/types.js";
export interface ExportServiceConfig {
maxRows: number;
maxFileSizeBytes: number;
defaultFormat: ExportFormat;
}
export const DEFAULT_EXPORT_CONFIG: ExportServiceConfig = {
maxRows: 50_000,
maxFileSizeBytes: 50 * 1024 * 1024,
defaultFormat: "csv",
};
/**
* Build a fresh exporter instance for the given format. Throws
* {@link ExportError} `UNSUPPORTED_FORMAT` on any other value.
*/
export function getExporter(format: ExportFormat): Exporter {
switch (format) {
case "csv":
return new CsvExporter();
case "json":
return new JsonExporter();
case "xlsx":
return new XlsxExporter();
default:
throw new ExportError(
EXPORT_CODES.UNSUPPORTED_FORMAT,
`Unsupported export format: ${format as string}`,
{ context: { format: format as string } }
);
}
}
/**
* Build one column definition per distinct key across `data`, in
* first-seen order, with `key === header`.
*/
export function inferColumns(
data: Record<string, unknown>[]
): ColumnDefinition[] {
if (data.length === 0) return [];
const keys = new Set<string>();
for (const row of data) {
for (const key of Object.keys(row)) {
keys.add(key);
}
}
return Array.from(keys).map((key) => ({
key,
header: key,
}));
}
/**
* Run an export against `provider.fetch`, applying row + size guards
* configured by `config`. Errors from `provider.fetch` are wrapped in
* `ExportError(PROVIDER_FETCH_FAILED)`.
*/
export async function runExport(
provider: DataProvider,
format: ExportFormat,
columns?: ColumnDefinition[],
filters?: Record<string, unknown>,
options?: ExportOptions,
config?: ExportServiceConfig
): Promise<ExportResult> {
const exportConfig = config ?? DEFAULT_EXPORT_CONFIG;
let data: Record<string, unknown>[];
try {
data = await provider.fetch(filters);
} catch (err) {
throw new ExportError(
EXPORT_CODES.PROVIDER_FETCH_FAILED,
`Data provider for "${provider.resource}" failed: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err, context: { resource: provider.resource } }
);
}
if (data.length > exportConfig.maxRows) {
throw new ExportError(
EXPORT_CODES.ROW_LIMIT_EXCEEDED,
`Export exceeds maximum row limit of ${exportConfig.maxRows}. Got ${data.length} rows.`,
{ context: { maxRows: exportConfig.maxRows, got: data.length } }
);
}
const resolvedColumns = columns ?? inferColumns(data);
const exporter = getExporter(format);
const result = exporter.export(data, resolvedColumns, options);
if (result.byteSize > exportConfig.maxFileSizeBytes) {
throw new ExportError(
EXPORT_CODES.FILE_SIZE_EXCEEDED,
`Export exceeds maximum file size of ${exportConfig.maxFileSizeBytes} bytes. Got ${result.byteSize} bytes.`,
{
context: {
maxFileSizeBytes: exportConfig.maxFileSizeBytes,
got: result.byteSize,
},
}
);
}
return result;
}

View File

@@ -0,0 +1,44 @@
# `registry-augmentation.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This file is a type-only `declare module "@suaveplan/api-plugin/service-registry"` block. It adds two slots to the shared `ServiceRegistry` interface so downstream plugins can resolve `ctx.getService("exportProviders")` and `ctx.getService("exportJobs")` with full typing — `Map<string, DataProvider>` and `Map<string, ExportJob>` respectively. The file produces no emitted runtime code; the only side effect is the type augmentation, which is why `src/index.ts` imports it for its side effects (so consumers using the package barrel pick up the slot types automatically).
## Features
- Adds `exportProviders: Map<string, DataProvider>` to `ServiceRegistry`.
- Adds `exportJobs: Map<string, ExportJob>` to `ServiceRegistry`.
- No runtime cost — the file emits no JavaScript.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-export"; // pulls the augmentation through the barrel
// Now ctx.getService("exportProviders") is typed Map<string, DataProvider> | undefined
```
## Usage: Advanced
```ts
import "@suaveplan/api-plugin-export/services/registry-augmentation";
// In a downstream plugin's onInit:
const providers = ctx.getService("exportProviders");
if (!providers) throw new Error("export plugin not initialised");
providers.set("orders", myOrderProvider);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ServiceRegistry.exportProviders` | augmentation | `Map<string, DataProvider>`. |
| `ServiceRegistry.exportJobs` | augmentation | `Map<string, ExportJob>`. |
## Implementation Notes
The augmentation pattern is identical to `@suaveplan/api-plugin-database`'s `registry-augmentation.ts` — both files exist purely to add slots to the shared registry interface defined by `@suaveplan/api-plugin/service-registry`. Without this file, every call site would have to cast the `getService("exportProviders")` return value, defeating the type safety that the `ServiceRegistry` interface was designed to provide. The two slots cover the two distinct concerns the plugin owns: the *registry* of data providers (writable by every consumer that wants to expose a resource for export) and the *store* of jobs (mutated by the route layer, observable by anyone tracking export progress). Exposing both is a deliberate departure from upstream, which exposed them via `ctx.registerService(...)` at runtime but never declared the corresponding types.

View File

@@ -0,0 +1,24 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { ExportJobStore } from "../store/types/types.js";
import type { DataProvider, ExportJob } from "../types/types.js";
/**
* Module augmentation — registers two `ServiceRegistry` slots
* (`exportProviders`, `exportJobs`) owned by this plugin, and
* re-declares the host-side `app` slot consumed in `onReady`.
* TypeScript merges duplicate declarations, so this is safe alongside
* the equivalent declaration in `@suaveplan/api`'s
* `services/registry-augmentation.ts`.
*
* The file is side-effect-free at runtime — the `declare module` block
* emits no JavaScript, so importing it has no cost beyond pulling in
* the type augmentation.
*/
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
exportProviders: Map<string, DataProvider>;
exportJobs: Map<string, ExportJob>;
exportJobStore: ExportJobStore;
app: OpenAPIHono;
}
}

View File

@@ -0,0 +1,8 @@
export { MemoryExportJobStore } from "./memory/memory-store.js";
export { SurrealExportStore } from "./surreal/surreal-store.js";
export type { SurrealExportStoreConfig } from "./surreal/surreal-store.types.js";
export type {
ExportJobStore,
ExportJobStoreListQuery,
ExportJobStoreListResult,
} from "./types/types.js";

View File

@@ -0,0 +1 @@
export { MemoryExportJobStore } from "./memory-store.js";

View File

@@ -0,0 +1,66 @@
# `store/memory/memory-store.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module ships `MemoryExportJobStore`, the in-memory implementation of the `ExportJobStore` contract. It wraps a `Map<string, ExportJob>` so tests, dev setups, and CLI tooling can use the same typed surface as the SurrealDB-backed production store without paying the cost of a real database. The class also doubles as the reference implementation that `SurrealExportStore` is tested against — the contract is defined by what `MemoryExportJobStore` does, and the SurrealDB implementation conforms.
## Features
- Implements every method of `ExportJobStore`: `get`, `set`, `delete`, `list`, `has`, `clear`.
- Optional constructor seed — accepts any `Iterable<readonly [string, ExportJob]>` so it can be initialised from existing data without an extra `.set` loop.
- AND-conjunction `list` filters (`status`, `format`, `resource`) with caller-supplied `limit` / `offset` pagination. `total` is the pre-pagination match count so consumers can render `n of total` paginators.
- Idempotent `delete` — removing a missing id resolves with `undefined`, matching the SurrealDB-backed store and the original Map semantics.
- Every method is `async` even though the underlying `Map` calls are sync; this preserves the uniform `await` discipline required by `ExportJobStore` so callers can swap backends without changing their code.
## Usage: Basic
```ts
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
const store = new MemoryExportJobStore();
await store.set("abc", job);
const round = await store.get("abc");
const { items, total } = await store.list({ limit: 20, offset: 0 });
```
## Usage: Advanced
```ts
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
const seeded = new MemoryExportJobStore([
["a", existingJobA],
["b", existingJobB],
]);
// Filter + paginate
const page = await seeded.list({
status: "completed",
format: "csv",
limit: 50,
offset: 100,
});
// Drain after a test
await seeded.clear();
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `MemoryExportJobStore` | class | Constructor: `(seed?: Iterable<readonly [string, ExportJob]>)`. |
| `get(id)` | method | Resolves the job or `null`. |
| `set(id, job)` | method | Upserts under `id`. |
| `delete(id)` | method | Idempotent removal. |
| `has(id)` | method | Existence probe. |
| `clear()` | method | Drops every entry. |
| `list(query)` | method | Filter + paginate + `{ items, total }`. |
## Implementation Notes
The class deliberately exposes nothing beyond the `ExportJobStore` interface — the underlying `Map` is private so callers cannot accidentally mutate it outside the typed contract. The seed parameter is typed as `Iterable<readonly [string, ExportJob]>` to accept both `Map` entries and plain arrays of tuples; this is the only shape that satisfies both the `Map`-from-iterable constructor and the `Array<[K, V]>` literal that test setups typically produce. Filter application is order-independent (every filter narrows the same source array) so the AND-conjunction semantics match the SurrealDB store's `WHERE … AND …` clause output exactly.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ExportJob } from "../../types/types.js";
import { MemoryExportJobStore } from "./memory-store.js";
const now = new Date("2026-05-19T00:00:00.000Z").toISOString();
function mk(id: string, overrides: Partial<ExportJob> = {}): ExportJob {
return {
id,
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: now,
completedAt: null,
...overrides,
};
}
describe("MemoryExportJobStore", () => {
it("MemoryExportJobStore satisfies the ExportJobStore contract", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore();
expect(typeof store.get).toBe("function");
expect(typeof store.set).toBe("function");
expect(typeof store.delete).toBe("function");
expect(typeof store.list).toBe("function");
expect(typeof store.has).toBe("function");
expect(typeof store.clear).toBe("function");
});
it("constructs with an initial seed iterable", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore([["a", mk("a")]]);
expect(await store.has("a")).toBe(true);
});
it("set + get round-trips a job", { timeout: 5000 }, async () => {
const store = new MemoryExportJobStore();
const job = mk("a", { status: "pending" });
await store.set("a", job);
const round = await store.get("a");
expect(round).toEqual(job);
});
it("get resolves null for an unknown id", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore();
expect(await store.get("missing")).toBeNull();
});
it("delete is idempotent on a missing id", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore();
await expect(store.delete("nope")).resolves.toBeUndefined();
});
it("has reflects set / delete", { timeout: 5000 }, async () => {
const store = new MemoryExportJobStore();
await store.set("a", mk("a"));
expect(await store.has("a")).toBe(true);
await store.delete("a");
expect(await store.has("a")).toBe(false);
});
it("clear empties the store", { timeout: 5000 }, async () => {
const store = new MemoryExportJobStore([
["a", mk("a")],
["b", mk("b")],
]);
await store.clear();
const page = await store.list({ limit: 10, offset: 0 });
expect(page).toEqual({ items: [], total: 0 });
});
it("list applies AND-conjunction filters", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore([
["a", mk("a", { status: "completed", format: "csv" })],
["b", mk("b", { status: "completed", format: "json" })],
["c", mk("c", { status: "failed", format: "csv" })],
]);
const page = await store.list({
status: "completed",
format: "csv",
limit: 10,
offset: 0,
});
expect(page.total).toBe(1);
expect(page.items[0]?.id).toBe("a");
});
it("list filters by resource alone", { timeout: 5000 }, async () => {
const store = new MemoryExportJobStore([
["a", mk("a", { resource: "users" })],
["b", mk("b", { resource: "orders" })],
]);
const page = await store.list({
resource: "orders",
limit: 10,
offset: 0,
});
expect(page.total).toBe(1);
expect(page.items[0]?.id).toBe("b");
});
it("list paginates with offset and limit", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore(
Array.from({ length: 5 }, (_, i) => [
String(i),
mk(String(i)),
]) as Array<readonly [string, ExportJob]>
);
const page = await store.list({ limit: 2, offset: 2 });
expect(page.items).toHaveLength(2);
expect(page.total).toBe(5);
});
it("list with no filters returns the full window", {
timeout: 5000,
}, async () => {
const store = new MemoryExportJobStore([
["a", mk("a")],
["b", mk("b")],
]);
const page = await store.list({ limit: 10, offset: 0 });
expect(page.total).toBe(2);
expect(page.items).toHaveLength(2);
});
});

View File

@@ -0,0 +1,62 @@
import type { ExportJob } from "../../types/types.js";
import type {
ExportJobStore,
ExportJobStoreListQuery,
ExportJobStoreListResult,
} from "../types/types.js";
/**
* In-memory implementation of {@link ExportJobStore}. Backed by a
* `Map<string, ExportJob>` for parity with the existing
* `ExportRoutesDeps.jobs` slot — wrapping the same data structure
* behind the typed contract is the cheapest way to share filter /
* pagination semantics with the SurrealDB-backed store.
*
* Every method resolves synchronously-derived results through a
* `Promise` so the interface signature is identical for any backend.
*/
export class MemoryExportJobStore implements ExportJobStore {
private readonly map: Map<string, ExportJob>;
constructor(seed?: Iterable<readonly [string, ExportJob]>) {
this.map = new Map(seed);
}
async get(id: string): Promise<ExportJob | null> {
return this.map.get(id) ?? null;
}
async set(id: string, job: ExportJob): Promise<void> {
this.map.set(id, job);
}
async delete(id: string): Promise<void> {
this.map.delete(id);
}
async has(id: string): Promise<boolean> {
return this.map.has(id);
}
async clear(): Promise<void> {
this.map.clear();
}
async list(
query: ExportJobStoreListQuery
): Promise<ExportJobStoreListResult> {
let items = Array.from(this.map.values());
if (query.status !== undefined) {
items = items.filter((j) => j.status === query.status);
}
if (query.format !== undefined) {
items = items.filter((j) => j.format === query.format);
}
if (query.resource !== undefined) {
items = items.filter((j) => j.resource === query.resource);
}
const total = items.length;
const windowed = items.slice(query.offset, query.offset + query.limit);
return { items: windowed, total };
}
}

View File

@@ -0,0 +1,2 @@
export { SurrealExportStore } from "./surreal-store.js";
export type { SurrealExportStoreConfig } from "./surreal-store.types.js";

View File

@@ -0,0 +1,77 @@
# `store/surreal/surreal-store.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module ships `SurrealExportStore`, the SurrealDB-backed implementation of `ExportJobStore`. It satisfies the same async surface as the in-memory `MemoryExportJobStore` so production deployments can swap the persistence layer without touching the route handlers, the format writers, or the streaming pipeline. The class consumes the canonical `SurrealAdapter` from `@suaveplan/surrealdb` and uses positional `$p0…$pN` placeholders throughout — matching the canonical pattern established by `@suaveplan/api-plugin-events` repositories.
## Features
- Full `ExportJobStore` implementation: `get`, `set`, `delete`, `list`, `has`, `clear`.
- Single-table schema (`export_job`) defined by the sibling `surql/tables/export_job.surql` migration.
- Custom `tableName` support for tenant-shard deployments (alphanumeric + `_` validated at construction time).
- Zod-validated constructor — invalid configs throw `ExportError(EXPORT_CODES.INVALID_CONFIG)` with the `ZodError` chained as `cause`.
- Every backend failure (driver crash, schema mismatch, network drop) is wrapped in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` so callers branch on a single stable code.
- `list({ ... })` issues two parallel queries — one for the windowed items, one for the `count() … GROUP ALL` total — so a paginated UI gets both numbers in a single round trip's wall-clock latency.
- `delete` is idempotent — removing a missing id resolves cleanly, matching SurrealDB's native `DELETE` semantics.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { SurrealExportStore } from "@suaveplan/api-plugin-export";
const db = new SurrealAdapter({ url: "ws://surreal:8000", namespace: "app", database: "main", username: "root", password: "root" });
await db.connect();
const store = new SurrealExportStore({ db });
await store.set("abc", job);
const round = await store.get("abc");
const { items, total } = await store.list({ limit: 20, offset: 0 });
```
## Usage: Advanced
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { SurrealExportStore } from "@suaveplan/api-plugin-export";
import { EXPORT_CODES, ExportError } from "@suaveplan/api-plugin-export";
const db = new SurrealAdapter({ url: process.env.SURREAL_URL });
await db.connect();
const store = new SurrealExportStore({
db,
tableName: `export_job_${process.env.TENANT_ID}`,
});
try {
const page = await store.list({
status: "completed",
format: "csv",
limit: 100,
offset: 0,
});
return page;
} catch (err) {
if (err instanceof ExportError && err.code === EXPORT_CODES.JOB_PERSISTENCE_FAILED) {
// backend is unreachable — fall back to a read-only cache
return { items: [], total: 0 };
}
throw err;
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `SurrealExportStore` | class | Implements `ExportJobStore` over `SurrealAdapter`. |
| Constructor | `(config: SurrealExportStoreConfig)` | Zod-validated. `db` is required, `tableName` defaults to `"export_job"`. |
| `get`/`set`/`delete`/`has`/`clear`/`list` | methods | Implementations of the `ExportJobStore` contract. |
## Implementation Notes
The class accepts the adapter behind a `MinimalQueryAdapter` view (`{ query<T>(sql, params?): Promise<T[]> }`) so unit tests can inject a fake without spinning up SurrealDB; the integration test under `tests/integration/surreal-store.test.ts` exercises the real `SurrealAdapter` against a testcontainer. The `set` body uses `UPSERT type::record($p0, $p1) CONTENT $p2 RETURN AFTER` — SurrealDB v3's `UPSERT` creates the row when missing and replaces the content when present, in a single statement, removing the need for a separate `INSERT` / `UPDATE` branch in user space. The surrogate `id` field is stripped from the CONTENT body before write because SurrealDB derives the record id from the `type::thing()` target and round-trips it on read — writing a duplicate would produce a typed-conflict error on schemafull tables. The `list` count uses `GROUP ALL` rather than a row-by-row count to keep the cost at O(1) on indexed columns; the count and items queries run in parallel via `Promise.all` so the wall-clock latency is the max of the two, not the sum.

View File

@@ -0,0 +1,260 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ExportError } from "../../error-codes/error-codes.js";
import type { ExportJob } from "../../types/types.js";
import { SurrealExportStore } from "./surreal-store.js";
interface QueryCall {
sql: string;
params: readonly unknown[] | undefined;
}
function makeFakeDb(rows: unknown[][]): {
db: {
query: <T>(sql: string, params?: readonly unknown[]) => Promise<T[]>;
};
calls: QueryCall[];
} {
const calls: QueryCall[] = [];
let i = 0;
const db = {
async query<T>(sql: string, params?: readonly unknown[]) {
calls.push({ sql, params });
const row = rows[i++] ?? [];
return row as T[];
},
};
return { db, calls };
}
function makeFailingDb(error: unknown): {
db: {
query: <T>(sql: string, params?: readonly unknown[]) => Promise<T[]>;
};
} {
return {
db: {
async query<T>(): Promise<T[]> {
throw error;
},
},
};
}
const now = new Date("2026-05-19T00:00:00.000Z").toISOString();
function mk(id: string): ExportJob {
return {
id,
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: now,
completedAt: null,
};
}
describe("SurrealExportStore — config", () => {
it("invalid config rejects with INVALID_CONFIG", {
timeout: 5000,
}, () => {
const badConfig: { db: unknown } = { db: undefined };
const thrown = (() => {
try {
new SurrealExportStore(badConfig as never);
} catch (e) {
return e;
}
return null;
})();
expect(thrown).toBeInstanceOf(ExportError);
if (thrown instanceof ExportError) {
expect(thrown.code).toBe("EXPORT.INVALID_CONFIG");
}
});
it("rejects when tableName is not a SurrealQL identifier", {
timeout: 5000,
}, () => {
const { db } = makeFakeDb([]);
expect(
() =>
new SurrealExportStore({
db: db as never,
tableName: "bad-name",
})
).toThrow(ExportError);
});
it("constructs with a custom tableName", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
const store = new SurrealExportStore({
db: db as never,
tableName: "tenant_export_job",
});
await store.get("a");
expect(calls[0]?.params).toContain("tenant_export_job");
});
});
describe("SurrealExportStore — CRUD against a fake adapter", () => {
it("get returns null when no row exists", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[]]);
const store = new SurrealExportStore({ db: db as never });
expect(await store.get("missing")).toBeNull();
});
it("get returns the row when present", {
timeout: 5000,
}, async () => {
const job = mk("a");
const { db, calls } = makeFakeDb([[job]]);
const store = new SurrealExportStore({ db: db as never });
const result = await store.get("a");
expect(result).toEqual(job);
expect(calls[0]?.sql).toContain("type::record");
});
it("set issues UPDATE … CONTENT $p2 with the serialised body", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
const store = new SurrealExportStore({ db: db as never });
await store.set("a", mk("a"));
expect(calls[0]?.sql).toContain("UPSERT type::record($p0, $p1)");
const body = calls[0]?.params?.[2] as Record<string, unknown>;
expect(body).not.toHaveProperty("id");
expect(body["status"]).toBe("completed");
});
it("delete issues DELETE type::record(...)", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
const store = new SurrealExportStore({ db: db as never });
await store.delete("a");
expect(calls[0]?.sql).toBe("DELETE type::record($p0, $p1)");
});
it("has resolves true / false", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[mk("a")], []]);
const store = new SurrealExportStore({ db: db as never });
expect(await store.has("a")).toBe(true);
expect(await store.has("missing")).toBe(false);
});
it("clear issues DELETE <table>", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
const store = new SurrealExportStore({ db: db as never });
await store.clear();
expect(calls[0]?.sql).toBe("DELETE export_job");
});
it("list applies filters, pagination, and derives total from count", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[mk("a")], [{ count: 7 }]]);
const store = new SurrealExportStore({ db: db as never });
const page = await store.list({
status: "completed",
format: "csv",
resource: "users",
limit: 10,
offset: 5,
});
expect(page.total).toBe(7);
expect(page.items).toHaveLength(1);
const itemsCall = calls.find((c) => c.sql.includes("LIMIT"));
expect(itemsCall?.sql).toContain("status = $p0");
expect(itemsCall?.sql).toContain("format = $p1");
expect(itemsCall?.sql).toContain("resource = $p2");
expect(itemsCall?.sql).toContain("LIMIT $p3");
expect(itemsCall?.sql).toContain("START $p4");
expect(itemsCall?.params).toEqual(["completed", "csv", "users", 10, 5]);
});
it("list with no filters skips the WHERE clause", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[], [{ count: 0 }]]);
const store = new SurrealExportStore({ db: db as never });
const page = await store.list({ limit: 5, offset: 0 });
expect(page.total).toBe(0);
const itemsCall = calls.find((c) => c.sql.includes("LIMIT"));
expect(itemsCall?.sql).not.toContain("WHERE");
});
it("list resolves total=0 when the count row is missing", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[], []]);
const store = new SurrealExportStore({ db: db as never });
const page = await store.list({ limit: 5, offset: 0 });
expect(page.total).toBe(0);
});
});
describe("SurrealExportStore — error paths", () => {
it("driver failure surfaces JOB_PERSISTENCE_FAILED (get)", {
timeout: 5000,
}, async () => {
const root = new Error("driver crashed");
const { db } = makeFailingDb(root);
const store = new SurrealExportStore({ db: db as never });
const err = await store.get("a").catch((e: unknown) => e);
expect(err).toBeInstanceOf(ExportError);
if (err instanceof ExportError) {
expect(err.code).toBe("EXPORT.JOB_PERSISTENCE_FAILED");
expect(err.cause).toBe(root);
}
});
it("driver failure surfaces JOB_PERSISTENCE_FAILED (set)", {
timeout: 5000,
}, async () => {
const { db } = makeFailingDb(new Error("nope"));
const store = new SurrealExportStore({ db: db as never });
await expect(store.set("a", mk("a"))).rejects.toBeInstanceOf(
ExportError
);
});
it("driver failure surfaces JOB_PERSISTENCE_FAILED (delete)", {
timeout: 5000,
}, async () => {
const { db } = makeFailingDb(new Error("nope"));
const store = new SurrealExportStore({ db: db as never });
await expect(store.delete("a")).rejects.toBeInstanceOf(ExportError);
});
it("driver failure surfaces JOB_PERSISTENCE_FAILED (clear)", {
timeout: 5000,
}, async () => {
const { db } = makeFailingDb(new Error("nope"));
const store = new SurrealExportStore({ db: db as never });
await expect(store.clear()).rejects.toBeInstanceOf(ExportError);
});
it("driver failure surfaces JOB_PERSISTENCE_FAILED (list)", {
timeout: 5000,
}, async () => {
const { db } = makeFailingDb(new Error("nope"));
const store = new SurrealExportStore({ db: db as never });
await expect(
store.list({ limit: 5, offset: 0 })
).rejects.toBeInstanceOf(ExportError);
});
});

View File

@@ -0,0 +1,184 @@
import { EXPORT_CODES, ExportError } from "../../error-codes/error-codes.js";
import type { ExportJob } from "../../types/types.js";
import type {
ExportJobStore,
ExportJobStoreListQuery,
ExportJobStoreListResult,
} from "../types/types.js";
import {
type SurrealExportStoreConfig,
SurrealExportStoreConfigSchema,
} from "./surreal-store.types.js";
const DEFAULT_TABLE = "export_job";
interface MinimalQueryAdapter {
query<T>(sql: string, params?: readonly unknown[]): Promise<T[]>;
}
interface CountRow {
count: number;
}
/**
* SurrealDB-backed implementation of {@link ExportJobStore}. Persists
* `ExportJob` rows in a single `export_job` table reached through the
* `@suaveplan/surrealdb` `SurrealAdapter`. All SurrealQL uses positional
* `$p0…$pN` placeholders — the adapter rewrites positional arrays into
* named bindings before handing them to the driver, matching the
* genesis-canonical pattern used by `@suaveplan/api-plugin-events`
* repositories.
*
* Failure semantics: every backend failure is wrapped in
* `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` with the original
* error chained as `cause`, so consumers can branch on a single stable
* code regardless of which driver / network / parse path tripped.
*/
export class SurrealExportStore implements ExportJobStore {
private readonly db: MinimalQueryAdapter;
private readonly table: string;
public constructor(config: SurrealExportStoreConfig) {
const parsed = SurrealExportStoreConfigSchema.safeParse(config);
if (!parsed.success) {
throw new ExportError(
EXPORT_CODES.INVALID_CONFIG,
"SurrealExportStore received invalid config",
{
cause: parsed.error,
context: { issues: parsed.error.issues },
}
);
}
if (
config.db === undefined ||
config.db === null ||
typeof (config.db as { query?: unknown }).query !== "function"
) {
throw new ExportError(
EXPORT_CODES.INVALID_CONFIG,
"SurrealExportStore requires a connected SurrealAdapter (db.query must be a function)",
{ context: { missing: ["db.query"] } }
);
}
this.db = config.db as MinimalQueryAdapter;
this.table = parsed.data.tableName ?? DEFAULT_TABLE;
}
public async get(id: string): Promise<ExportJob | null> {
try {
const rows = await this.db.query<ExportJob>(
"SELECT * FROM type::record($p0, $p1)",
[this.table, id]
);
return rows[0] ?? null;
} catch (cause) {
throw this.wrap("get", cause, { id });
}
}
public async set(id: string, job: ExportJob): Promise<void> {
try {
await this.db.query(
"UPSERT type::record($p0, $p1) CONTENT $p2 RETURN AFTER",
[this.table, id, this.serialize(job)]
);
} catch (cause) {
throw this.wrap("set", cause, { id });
}
}
public async delete(id: string): Promise<void> {
try {
await this.db.query("DELETE type::record($p0, $p1)", [
this.table,
id,
]);
} catch (cause) {
throw this.wrap("delete", cause, { id });
}
}
public async has(id: string): Promise<boolean> {
const row = await this.get(id);
return row !== null;
}
public async clear(): Promise<void> {
try {
await this.db.query(`DELETE ${this.table}`);
} catch (cause) {
throw this.wrap("clear", cause, {});
}
}
public async list(
query: ExportJobStoreListQuery
): Promise<ExportJobStoreListResult> {
const conditions: string[] = [];
const params: unknown[] = [];
if (query.status !== undefined) {
params.push(query.status);
conditions.push(`status = $p${params.length - 1}`);
}
if (query.format !== undefined) {
params.push(query.format);
conditions.push(`format = $p${params.length - 1}`);
}
if (query.resource !== undefined) {
params.push(query.resource);
conditions.push(`resource = $p${params.length - 1}`);
}
const whereClause =
conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
const itemsParams = [...params, query.limit, query.offset];
const limitIdx = itemsParams.length - 2;
const offsetIdx = itemsParams.length - 1;
const itemsSql =
`SELECT * FROM ${this.table}${whereClause} ` +
`ORDER BY createdAt DESC LIMIT $p${limitIdx} START $p${offsetIdx}`;
try {
const [items, counts] = await Promise.all([
this.db.query<ExportJob>(itemsSql, itemsParams),
this.db.query<CountRow>(
`SELECT count() AS count FROM ${this.table}${whereClause} GROUP ALL`,
params
),
]);
const total = counts[0]?.count ?? 0;
return { items, total };
} catch (cause) {
throw this.wrap("list", cause, { query });
}
}
private serialize(job: ExportJob): Record<string, unknown> {
// Strip the surrogate `id` from the CONTENT body — SurrealDB
// derives the record id from the `type::record()` target and
// round-trips it on read, so we don't write a duplicate field.
// Coerce `null` → omit because SurrealDB v3 `option<T>` fields
// accept `NONE` (missing) or `T` but not the JS `null` literal.
const { id: _id, ...rest } = job;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rest)) {
if (v !== null && v !== undefined) {
out[k] = v;
}
}
return out;
}
private wrap(
op: string,
cause: unknown,
context: Record<string, unknown>
): ExportError {
return new ExportError(
EXPORT_CODES.JOB_PERSISTENCE_FAILED,
`SurrealExportStore.${op} failed against table '${this.table}'`,
{ cause, context: { table: this.table, op, ...context } }
);
}
}

View File

@@ -0,0 +1,39 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import z from "zod";
/**
* Constructor options for `SurrealExportStore`. Validated at
* construction time with `SurrealExportStoreConfigSchema` — invalid
* input surfaces `ExportError(EXPORT_CODES.INVALID_CONFIG)` carrying
* the `ZodError` as `cause`.
*/
export interface SurrealExportStoreConfig {
/**
* Live `SurrealAdapter`. The store does not own the adapter's
* lifecycle — callers connect and disconnect themselves, typically
* via `@suaveplan/api-plugin-database`.
*/
db: SurrealAdapter;
/**
* SurrealDB table name. Defaults to `"export_job"`, matching the
* shipped `surql/tables/export_job.surql` migration. Override only
* if you've sharded the table per-tenant.
*/
tableName?: string;
}
/**
* Runtime validation of {@link SurrealExportStoreConfig}. `db` is
* checked as `instanceof SurrealAdapter`; `tableName` is required to
* match the SurrealQL identifier grammar (alphanumeric + `_`).
*/
export const SurrealExportStoreConfigSchema = z.object({
db: z.unknown(),
tableName: z
.string()
.regex(
/^[a-zA-Z_][a-zA-Z0-9_]*$/u,
"tableName must be a valid SurrealQL identifier"
)
.optional(),
});

View File

@@ -0,0 +1,5 @@
export type {
ExportJobStore,
ExportJobStoreListQuery,
ExportJobStoreListResult,
} from "./types.js";

View File

@@ -0,0 +1,67 @@
# `store/types/types.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the `ExportJobStore` interface — the formal persistence contract for export jobs. Before this module existed, the store surface was the implicit `Map<string, ExportJob>` shape passed through `ExportRoutesDeps.jobs`. That shape was sync-only and impossible to swap for a real database without bending callers into a `Promise`-wrapping adapter. The interface here lifts the contract out of the route layer so two backends — the in-memory `MemoryExportJobStore` and the SurrealDB-backed `SurrealExportStore` — can satisfy the same surface and consumers can pick the right one for their tier (test, dev, prod).
## Features
- Six-method async contract: `get`, `set`, `delete`, `list`, `has`, `clear`.
- Caller-supplied pagination — no default `limit`, no default `offset`. The route layer (and any CLI) decides what the user-facing defaults are.
- AND-conjunction filtering on `status` / `format` / `resource`. Every implementation conforms; the test suite exercises every combination against both backends.
- `list` returns `{ items, total }` so the consumer can render `n of total` without a second round trip.
- Idempotent `delete` — removing a missing id resolves with no error, matching the SurrealDB and Map behaviours.
## Usage: Basic
```ts
import type { ExportJobStore } from "@suaveplan/api-plugin-export/store/types/types";
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export/store/memory/memory-store";
const store: ExportJobStore = new MemoryExportJobStore();
await store.set("abc", job);
const round = await store.get("abc");
const { items, total } = await store.list({ limit: 20, offset: 0 });
```
## Usage: Advanced
```ts
import type {
ExportJobStore,
ExportJobStoreListQuery,
} from "@suaveplan/api-plugin-export/store/types/types";
import { SurrealExportStore } from "@suaveplan/api-plugin-export/store/surreal/surreal-store";
import { SurrealAdapter } from "@suaveplan/surrealdb";
const db = new SurrealAdapter({ url: "ws://surreal:8000", namespace: "app", database: "main", username: "root", password: "root" });
await db.connect();
const store: ExportJobStore = new SurrealExportStore({ db });
const query: ExportJobStoreListQuery = {
status: "completed",
format: "csv",
limit: 50,
offset: 0,
};
const page = await store.list(query);
for (const job of page.items) {
if (await store.has(job.id)) await store.delete(job.id);
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ExportJobStore` | interface | The async persistence contract. Implement to add a new backend. |
| `ExportJobStoreListQuery` | interface | `status` / `format` / `resource` are optional filters; `limit` / `offset` are required (no defaults at the store boundary). |
| `ExportJobStoreListResult` | interface | `{ items: ExportJob[]; total: number }`. `total` is the pre-pagination match count. |
## Implementation Notes
The interface is intentionally type-only — no runtime artefact ships from `types.ts`. That keeps the boundary stable across tree-shaking (the type erases entirely in production bundles) and lets consumers implement it without pulling in either backend's transitive deps. The async signatures are chosen even for trivially-sync backends (`MemoryExportJobStore` resolves synchronously through a `Promise`) because changing a method from sync to async is a breaking change while the reverse is not — picking async up front matches every realistic backend (SurrealDB, Postgres, S3-blob, Redis) and means the consumer's `await` discipline is uniform regardless of which store is wired in.

View File

@@ -0,0 +1,67 @@
import type {
ExportFormat,
ExportJob,
ExportStatus,
} from "../../types/types.js";
/**
* Query passed to {@link ExportJobStore.list}. Mirrors `ExportJobListQuery`
* from the route layer, but at the store boundary every field is
* explicit — pagination defaults are the caller's concern so a single
* shape works for HTTP, CLI, and direct-import call sites.
*/
export interface ExportJobStoreListQuery {
/** Filter to jobs whose `status` equals this value. */
status?: ExportStatus | undefined;
/** Filter to jobs whose `format` equals this value. */
format?: ExportFormat | undefined;
/** Filter to jobs whose `resource` equals this value. */
resource?: string | undefined;
/** Maximum number of items to return (caller-supplied, no default). */
limit: number;
/** Offset into the matching set; `0` returns from the start. */
offset: number;
}
/**
* Result of {@link ExportJobStore.list}. `total` is the pre-pagination
* match count so consumers can render `n of total` UIs without a
* second round trip.
*/
export interface ExportJobStoreListResult {
items: ExportJob[];
total: number;
}
/**
* Persistence contract for export jobs. Every method is async so any
* backend — in-memory, SurrealDB, Postgres, S3-blob — satisfies the same
* interface. Implementations are owned by `@suaveplan/api-plugin-export`
* (`MemoryExportJobStore`, `SurrealExportStore`); third-party consumers
* may supply their own.
*
* Failure semantics: implementations SHOULD wrap any backend-specific
* failure in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` with the
* underlying error chained as `cause`, so callers branch on a single
* stable code regardless of backend.
*/
export interface ExportJobStore {
/** Resolve the job stored under `id`, or `null` if absent. */
get(id: string): Promise<ExportJob | null>;
/** Upsert `job` under `id`. */
set(id: string, job: ExportJob): Promise<void>;
/**
* Remove the job under `id`. MUST be idempotent — deleting a
* missing id resolves without error.
*/
delete(id: string): Promise<void>;
/**
* List jobs matching the query, applying every supplied filter as
* an AND-conjunction and windowing with `offset` / `limit`.
*/
list(query: ExportJobStoreListQuery): Promise<ExportJobStoreListResult>;
/** Return `true` iff a job is stored under `id`. */
has(id: string): Promise<boolean>;
/** Remove every job. Used in tests and on plugin `onClose`. */
clear(): Promise<void>;
}

View File

@@ -0,0 +1,77 @@
# `types.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module holds the Zod-fronted public type surface for `@suaveplan/api-plugin-export`: the `ExportFormat` / `ExportStatus` enums, the request/response schemas (`CreateExportJobSchema`, `ExportJobListQuerySchema`, `ExportJobSchema`, `ExportPluginOptionsSchema`), and the function-bearing interfaces (`Exporter`, `DataProvider`) that Zod cannot represent. Every public input boundary on the package is validated through one of these schemas — config validation at plugin construction, body/query validation on the OpenAPIHono routes, and column-definition validation when callers build their own column maps.
## Features
- `ExportFormat` / `ExportStatus` declared as `const` objects + literal-union types — works with both `switch` exhaustiveness and `z.enum` validation without duplication.
- Single source of truth for option defaults: `ExportPluginOptionsSchema` declares `basePath: "/exports"`, `maxRows: 50_000`, `maxFileSizeBytes: 50 MiB`, `defaultFormat: "csv"`.
- `ExportJobListQuerySchema` uses `z.coerce.number()` so HTTP query-string strings are coerced to numbers without bespoke parsing.
- Interfaces (`Exporter`, `DataProvider`, `ExportOptions`, `ExportResult`) are kept on this module so the public exports are a single import.
## Usage: Basic
```ts
import {
CreateExportJobSchema,
type DataProvider,
} from "@suaveplan/api-plugin-export";
const body = CreateExportJobSchema.parse({ format: "csv", resource: "users" });
const provider: DataProvider = {
resource: "users",
fetch: async () => [{ id: 1, name: "Ada" }],
};
```
## Usage: Advanced
```ts
import {
ColumnDefinitionSchema,
ExportPluginOptionsSchema,
type Exporter,
type ExportResult,
} from "@suaveplan/api-plugin-export";
const cols = ColumnDefinitionSchema.array().parse([
{ key: "id", header: "ID" },
{ key: "joinedAt", header: "Joined", transform: (v) => new Date(v as string).toISOString() },
]);
const options = ExportPluginOptionsSchema.parse({ maxRows: 1000 });
class TsvExporter implements Exporter {
readonly format = "csv" as const;
export(data: Record<string, unknown>[], columns, _opts): ExportResult {
// …
return { content: "", mimeType: "text/tab-separated-values", filename: "f.tsv", rowCount: data.length, byteSize: 0 };
}
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ExportFormat` / `ExportStatus` | const + type | Literal-union enums. |
| `ExportFormatSchema` / `ExportStatusSchema` | Zod | `z.enum([...])`. |
| `ColumnDefinitionSchema` | Zod | `{ key, header, transform? }`. |
| `CreateExportJobSchema` | Zod | Body schema for `POST /exports`. |
| `ExportJobSchema` | Zod | Persistence + response shape. |
| `ExportJobListQuerySchema` | Zod | Query-string schema with `z.coerce`. |
| `ExportPluginOptionsSchema` | Zod | Plugin construction options. |
| `Exporter` | interface | `{ format, export(data, columns, options?): ExportResult }`. |
| `DataProvider` | interface | `{ resource, fetch(filters?) }`. |
| `ExportOptions` / `ExportResult` | interface | Exporter call/return shapes. |
## Implementation Notes
`z.coerce.number()` on the query schema is deliberate: Hono passes query strings through as `string` and the route handler must accept `?limit=20` without a manual `parseInt`. The `transform` field on `ColumnDefinitionSchema` is typed `z.any()` because Zod cannot represent a runtime function in a way that round-trips through `parse()` — the schema's job is to validate the static structure; callers ensure `transform` is callable at construction time. `ExportResult.content` is declared `string` (not `string | Buffer`) because every shipped exporter produces a `string` payload — switching to `Buffer` would force every consumer to handle both branches without any current binary writer to motivate it; the OOXML/ZIP writer in upstream §4 will introduce that branch when it lands.

View File

@@ -0,0 +1,153 @@
import z from "zod";
/**
* Type definitions, Zod schemas, and the public option / job shapes for
* `@suaveplan/api-plugin-export`.
*
* Every public boundary on the package surface is fronted by a Zod
* schema, the inferred type of which is the canonical TS type. Internal
* `Exporter` / `DataProvider` interfaces are declared as plain types
* because they encode behavior (a function) that Zod cannot represent.
*/
// ============================================================================
// Enums (string-literal const objects + type aliases)
// ============================================================================
export const ExportFormat = {
CSV: "csv",
JSON: "json",
XLSX: "xlsx",
} as const;
export type ExportFormat = (typeof ExportFormat)[keyof typeof ExportFormat];
export const ExportStatus = {
PENDING: "pending",
PROCESSING: "processing",
COMPLETED: "completed",
FAILED: "failed",
} as const;
export type ExportStatus = (typeof ExportStatus)[keyof typeof ExportStatus];
// ============================================================================
// Zod schemas
// ============================================================================
export const ExportFormatSchema = z.enum(["csv", "json", "xlsx"]);
export const ExportStatusSchema = z.enum([
"pending",
"processing",
"completed",
"failed",
]);
export const ColumnDefinitionSchema = z.object({
key: z.string().min(1),
header: z.string().min(1),
transform: z.optional(z.any()),
});
export const CreateExportJobSchema = z.object({
format: ExportFormatSchema,
resource: z.string().min(1).max(128),
filters: z.record(z.string(), z.unknown()).optional(),
columns: z.array(ColumnDefinitionSchema).min(1).optional(),
filename: z.string().max(256).optional(),
options: z
.object({
delimiter: z.string().max(4).optional(),
includeHeaders: z.boolean().optional(),
sheetName: z.string().max(64).optional(),
dateFormat: z.string().max(64).optional(),
pretty: z.boolean().optional(),
})
.optional(),
});
const StringOrDate = z.union([z.string(), z.date()]);
export const ExportJobSchema = z.object({
id: z.string(),
format: ExportFormatSchema,
resource: z.string(),
status: ExportStatusSchema,
filters: z.record(z.string(), z.unknown()).optional(),
filename: z.string().optional(),
totalRows: z.number().nullable(),
exportedRows: z.number().nullable(),
fileSize: z.number().nullable(),
error: z.string().nullable(),
createdAt: StringOrDate,
completedAt: StringOrDate.nullable(),
});
export const ExportJobListQuerySchema = z.object({
status: ExportStatusSchema.optional(),
format: ExportFormatSchema.optional(),
resource: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
export const ExportPluginOptionsSchema = z.object({
basePath: z.string().default("/exports"),
maxRows: z.number().int().min(1).max(1_000_000).default(50_000),
maxFileSizeBytes: z
.number()
.int()
.min(1024)
.default(50 * 1024 * 1024),
defaultFormat: ExportFormatSchema.default("csv"),
});
// ============================================================================
// Inferred public types
// ============================================================================
export type ColumnDefinition = z.infer<typeof ColumnDefinitionSchema>;
export type CreateExportJob = z.infer<typeof CreateExportJobSchema>;
export type ExportJob = z.infer<typeof ExportJobSchema>;
export type ExportJobListQuery = z.infer<typeof ExportJobListQuerySchema>;
export type ExportPluginOptionsInput = z.input<
typeof ExportPluginOptionsSchema
>;
export type ExportPluginOptions = z.infer<typeof ExportPluginOptionsSchema>;
// ============================================================================
// Internal (function-bearing) types
// ============================================================================
export interface ExportOptions {
delimiter?: string | undefined;
includeHeaders?: boolean | undefined;
sheetName?: string | undefined;
dateFormat?: string | undefined;
pretty?: boolean | undefined;
}
export interface ExportResult {
content: string;
mimeType: string;
filename: string;
rowCount: number;
byteSize: number;
}
export interface Exporter {
readonly format: ExportFormat;
export(
data: Record<string, unknown>[],
columns: ColumnDefinition[],
options?: ExportOptions
): ExportResult;
}
export interface DataProvider {
resource: string;
fetch(
filters?: Record<string, unknown>
): Promise<Record<string, unknown>[]>;
}

View File

@@ -0,0 +1,45 @@
-- @suaveplan/api-plugin-export — SurrealDB schema
--
-- Single-table persistence for the `ExportJob` lifecycle. The
-- `SurrealExportStore` writes rows via `UPDATE type::thing('export_job',
-- $id) CONTENT $body RETURN AFTER`. The schema mirrors `ExportJobSchema`
-- field-by-field; field names match the TypeScript shape (camelCase
-- preserved) so `SELECT *` round-trips cleanly into `ExportJob` without
-- a mapping layer.
DEFINE TABLE OVERWRITE export_job SCHEMAFULL;
DEFINE FIELD OVERWRITE format ON export_job TYPE string
ASSERT $value INSIDE ['csv', 'json', 'xlsx'];
DEFINE FIELD OVERWRITE resource ON export_job TYPE string
ASSERT $value != NONE AND string::len(string::trim($value)) > 0;
DEFINE FIELD OVERWRITE status ON export_job TYPE string
ASSERT $value INSIDE ['pending', 'processing', 'completed', 'failed'];
DEFINE FIELD OVERWRITE filters ON export_job TYPE option<object>;
DEFINE FIELD OVERWRITE filename ON export_job TYPE option<string>;
DEFINE FIELD OVERWRITE totalRows ON export_job TYPE option<int>;
DEFINE FIELD OVERWRITE exportedRows ON export_job TYPE option<int>;
DEFINE FIELD OVERWRITE fileSize ON export_job TYPE option<int>;
DEFINE FIELD OVERWRITE error ON export_job TYPE option<string>;
DEFINE FIELD OVERWRITE createdAt ON export_job TYPE string
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE completedAt ON export_job TYPE option<string>;
-- Indexes — every column used as a `WHERE` filter in
-- `SurrealExportStore.list(...)`. `created_at` is the ORDER-BY column
-- so the index makes the default `ORDER BY created_at DESC` path
-- index-only.
DEFINE INDEX OVERWRITE export_job_status_idx ON export_job FIELDS status;
DEFINE INDEX OVERWRITE export_job_format_idx ON export_job FIELDS format;
DEFINE INDEX OVERWRITE export_job_resource_idx ON export_job FIELDS resource;
DEFINE INDEX OVERWRITE export_job_created_idx ON export_job FIELDS createdAt;

View File

@@ -0,0 +1,144 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { SurrealExportStore } from "../../src/store/surreal/surreal-store.js";
import type { ExportJob } from "../../src/types/types.js";
let fixture: SurrealDBFixture | null = null;
let adapter: SurrealAdapter | null = null;
let migration: string | null = null;
const now = new Date("2026-05-19T00:00:00.000Z").toISOString();
function mk(id: string, overrides: Partial<ExportJob> = {}): ExportJob {
return {
id,
format: "csv",
resource: "users",
status: "completed",
filters: undefined,
filename: undefined,
totalRows: null,
exportedRows: null,
fileSize: null,
error: null,
createdAt: now,
completedAt: null,
...overrides,
};
}
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
adapter = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await adapter.connect();
migration = await readFile(
join(__dirname, "..", "..", "surql", "tables", "export_job.surql"),
"utf8"
);
await adapter.query(migration);
}, 180_000);
afterAll(async () => {
if (adapter !== null) {
await adapter.disconnect();
adapter = null;
}
if (fixture !== null) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("SurrealExportStore — integration against real SurrealDB", () => {
it("set + get round-trips a job through SurrealDB", {
timeout: 60_000,
}, async () => {
if (adapter === null) throw new Error("fixture not ready");
const store = new SurrealExportStore({ db: adapter });
await store.clear();
const job = mk("rt1", { status: "pending" });
await store.set("rt1", job);
const round = await store.get("rt1");
expect(round).not.toBeNull();
expect(round?.format).toBe("csv");
expect(round?.resource).toBe("users");
expect(round?.status).toBe("pending");
expect(round?.createdAt).toBe(now);
});
it("has reflects set / delete against SurrealDB", {
timeout: 60_000,
}, async () => {
if (adapter === null) throw new Error("fixture not ready");
const store = new SurrealExportStore({ db: adapter });
await store.clear();
await store.set("h1", mk("h1"));
expect(await store.has("h1")).toBe(true);
await store.delete("h1");
expect(await store.has("h1")).toBe(false);
});
it("delete on a missing row is idempotent", {
timeout: 60_000,
}, async () => {
if (adapter === null) throw new Error("fixture not ready");
const store = new SurrealExportStore({ db: adapter });
await store.clear();
await expect(store.delete("never-existed")).resolves.toBeUndefined();
});
it("list applies status filter and pagination through SurrealDB", {
timeout: 60_000,
}, async () => {
if (adapter === null) throw new Error("fixture not ready");
const store = new SurrealExportStore({ db: adapter });
await store.clear();
await store.set("l1", mk("l1", { status: "completed" }));
await store.set("l2", mk("l2", { status: "completed" }));
await store.set("l3", mk("l3", { status: "failed" }));
const page = await store.list({
status: "completed",
limit: 10,
offset: 0,
});
expect(page.total).toBe(2);
expect(page.items).toHaveLength(2);
});
it("clear empties the SurrealDB table", {
timeout: 60_000,
}, async () => {
if (adapter === null) throw new Error("fixture not ready");
const store = new SurrealExportStore({ db: adapter });
await store.set("c1", mk("c1"));
await store.clear();
const page = await store.list({ limit: 10, offset: 0 });
expect(page.total).toBe(0);
});
it("export_job migration is idempotent", {
timeout: 60_000,
}, async () => {
if (adapter === null || migration === null)
throw new Error("fixture not ready");
// Re-apply the migration; OVERWRITE semantics make it idempotent.
await expect(adapter.query(migration)).resolves.toBeDefined();
await expect(adapter.query(migration)).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,32 @@
/**
* Test setup for @suaveplan/api-plugin-export.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meters resolved on first call bind to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-export",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
/^node:/,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/services/registry-augmentation.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
hookTimeout: 60000,
},
});