Initial Commit
This commit is contained in:
89
packages/api-stack/api/api-plugin-export/README.md
Normal file
89
packages/api-stack/api/api-plugin-export/README.md
Normal 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 .
|
||||
|
||||
144
packages/api-stack/api/api-plugin-export/package.json
Normal file
144
packages/api-stack/api/api-plugin-export/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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("&<>"'");
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
119
packages/api-stack/api/api-plugin-export/src/index.ts
Normal file
119
packages/api-stack/api/api-plugin-export/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
118
packages/api-stack/api/api-plugin-export/src/plugin/plugin.ts
Normal file
118
packages/api-stack/api/api-plugin-export/src/plugin/plugin.ts
Normal 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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 | ||||