first commit
This commit is contained in:
@@ -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,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,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,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.
|
||||
56
docs/api-stack/api/api-plugin-export/src/otel/instruments.md
Normal file
56
docs/api-stack/api/api-plugin-export/src/otel/instruments.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# `instruments.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrument factory for `@suaveplan/api-plugin-export`. Builds a cached bundle of six instruments — five counters and one histogram — bound to the meter named `@suaveplan/api-plugin-export`. The cache lets the hot path (`POST /exports` + `GET /:id/download`) resolve instruments once per process instead of paying the meter-provider lookup cost on every job, while `resetExportInstruments()` gives tests a hook to rebind against an in-memory `MeterProvider` installed at suite startup.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy resolution against the global `MeterProvider`, cached after the first call.
|
||||
- `export.jobs_total` counter (with `outcome` attribute) for every job, completed or failed.
|
||||
- `export.rows_total` and `export.bytes_total` counters that track the cumulative size of successful exports.
|
||||
- `export.duration_ms` histogram so dashboards / SLOs can report p50 / p99 latency.
|
||||
- `export.download.served` counter (with `format` attribute) for every successful download.
|
||||
- `export.download.failed` counter (with `reason` attribute) for every failed download.
|
||||
- `resetExportInstruments()` for tests installing fresh providers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getExportInstruments } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const inst = getExportInstruments();
|
||||
inst.jobsTotal.add(1, { outcome: "completed" });
|
||||
inst.rowsTotal.add(rowCount);
|
||||
inst.bytesTotal.add(byteSize);
|
||||
inst.durationMs.record(elapsedMs);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// In test setup, after installing a fresh MeterProvider:
|
||||
import {
|
||||
getExportInstruments,
|
||||
resetExportInstruments,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
|
||||
resetExportInstruments();
|
||||
const inst = getExportInstruments(); // rebinds against the new provider
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ExportInstruments` | interface | `{ jobsTotal, rowsTotal, bytesTotal, durationMs, downloadServed, downloadFailed }`. |
|
||||
| `getExportInstruments()` | function | Lazy + cached. |
|
||||
| `resetExportInstruments()` | function | Drops the cache. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bundle is held in a module-scope `let cached: ExportInstruments | undefined` rather than in a class instance, because the function is the only documented entry point and there is no per-plugin state to keep — every plugin instance in a process shares the same meter. Counters use `add(value, attributes?)`; the `outcome` attribute is set by the call site rather than declared on the instrument because OTel attributes are runtime keys, not instrument metadata. The histogram unit is set to `"ms"` (lower-case) per OTel semantic conventions for latency. Resetting between tests is mandatory: meter handles cache internally inside `@opentelemetry/api`, so without `resetExportInstruments()` a test would emit to whichever provider was installed when the *first* `getExportInstruments()` call ran — usually the global default provider, which loses every metric.
|
||||
59
docs/api-stack/api/api-plugin-export/src/plugin/plugin.md
Normal file
59
docs/api-stack/api/api-plugin-export/src/plugin/plugin.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# `plugin.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The plugin factory for `@suaveplan/api-plugin-export`. Construction-time it validates options through `ExportPluginOptionsSchema` (rejecting with `ExportError(EXPORT.INVALID_CONFIG)` on failure) and returns a `PluginDescriptor` with three lifecycle hooks. `onInit` registers the in-memory `providers` + `jobs` maps on the augmented `ServiceRegistry`. `onReady` resolves the host `app` (`OpenAPIHono`) and mounts the export routes at the configured `basePath`. `onClose` clears both maps so a re-registration of the plugin starts clean.
|
||||
|
||||
## Features
|
||||
|
||||
- Caller-supplied options are honoured end-to-end — the upstream `_config`-discard bug is fixed at migration time.
|
||||
- Side-effect import of `registry-augmentation` makes `ctx.getService("exportProviders")` / `ctx.getService("exportJobs")` type-narrow correctly at every call site that imports this module.
|
||||
- `registerDataProvider(providers, provider)` is a convenience the upstream package shipped — re-exposed here so downstream code does not need to construct the providers map by hand.
|
||||
- `onReady` mounts routes via `ctx.getService("app")` (the suaveplanent of upstream's `ctx.app.route(...)` call) so the plugin participates in the rest of the plugin ecosystem cleanly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createExportPlugin } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const registry = createPluginRegistry();
|
||||
registry.register(createExportPlugin({ basePath: "/v1/exports", maxRows: 10_000 }));
|
||||
await registry.init();
|
||||
await registry.ready();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createExportPlugin,
|
||||
registerDataProvider,
|
||||
type DataProvider,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
|
||||
const plugin = createExportPlugin({ defaultFormat: "json" });
|
||||
// later, after onInit completes:
|
||||
const providers = ctx.getService("exportProviders");
|
||||
if (providers) {
|
||||
const userProvider: DataProvider = {
|
||||
resource: "users",
|
||||
fetch: async () => db.select("user").get(),
|
||||
};
|
||||
registerDataProvider(providers, userProvider);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `createExportPlugin(options?)` | function | Returns `PluginDescriptor<ExportPluginOptions>`. Throws `EXPORT.INVALID_CONFIG` on bad input. |
|
||||
| `registerDataProvider(providers, provider)` | function | `providers.set(provider.resource, provider)` helper. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The descriptor uses `defaults: config` so the `PluginContext`-level `getOwnConfig()` returns the merged, fully-defaulted options shape — downstream plugins that read the plugin config (or that the host orchestrator inspects) see the same values the route handler uses. The `app` lookup in `onReady` returns `OpenAPIHono | undefined` (per the `ServiceRegistry` augmentation in `@suaveplan/api`), so the missing case is checked explicitly with an `ExportError(INVALID_CONFIG)` — this is a misconfiguration of the host, not of the plugin options, but the same code is correct because the host failed to seed the `app` slot before plugin registration. `runOnInit` is async even though it does no `await` because the lifecycle hook signature requires it; making it async eliminates a subtle TypeScript variance gotcha when the registry awaits the return value. The runtime maps are held on a `PluginRuntime` interface so the three hook helpers can pass state without resorting to module-scope `let` (which would conflict with multiple plugin instances co-existing in one process).
|
||||
@@ -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,59 @@
|
||||
# `exports.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenAPIHono route group exposed at the configured `basePath` (default `/exports`). Wires four endpoints — `GET /`, `POST /`, `GET /:id`, `DELETE /:id` — against the in-memory `providers` and `jobs` maps that the plugin builds at `onInit`. Every input passes through a Zod schema (`ExportJobListQuerySchema`, `CreateExportJobSchema`, `z.object({ id })`), every error response is an `{ error: string }` body, and every successful POST returns `{ job, downloadUrl }`. The actual POST work is in `post-handler.ts` so this file stays under the 300-line file-size budget.
|
||||
|
||||
## Features
|
||||
|
||||
- `GET /` filters by `status` / `format` / `resource` and windows by `limit`/`offset`.
|
||||
- `POST /` resolves the provider, persists a `processing` job, fetches data, enforces row + size caps, runs the exporter, records OTel metrics, and returns the completed job + a `downloadUrl` referencing the (future) `/:id/download` endpoint.
|
||||
- `GET /:id` returns the job or `404`.
|
||||
- `DELETE /:id` deletes the job or `404`.
|
||||
- Injectable `jobIdFactory` and `now` for deterministic tests.
|
||||
- `onExportStarted` / `onExportCompleted` callbacks let consumers wire a real event bus on top of the route layer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createExportRoutes, DEFAULT_EXPORT_CONFIG } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const routes = createExportRoutes({
|
||||
providers: new Map(),
|
||||
jobs: new Map(),
|
||||
exportConfig: DEFAULT_EXPORT_CONFIG,
|
||||
});
|
||||
app.route("/exports", routes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createExportRoutes } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const routes = createExportRoutes({
|
||||
providers,
|
||||
jobs,
|
||||
exportConfig,
|
||||
jobIdFactory: () => `exp_${seq++}`,
|
||||
now: () => new Date("2026-05-19T00:00:00Z"),
|
||||
onExportStarted: ({ jobId, resource }) => logger.info({ jobId, resource }, "export started"),
|
||||
onExportCompleted: ({ jobId, rowCount, byteSize }) =>
|
||||
logger.info({ jobId, rowCount, byteSize }, "export completed"),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ExportRoutesDeps` | interface | Dependency bag accepted by `createExportRoutes`. |
|
||||
| `createExportRoutes(deps)` | function | Returns an `OpenAPIHono` ready to mount. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `runPostExport` helper in `post-handler.ts` owns the bulk of the work — the route definitions stay in this file so that the OpenAPI schema is one click away from the route name. Failure paths set `job.status = "failed"` and persist `job.error` *before* returning, so `GET /:id` immediately afterwards sees the failure record (mirroring upstream behaviour). The `downloadUrl` response is `/${jobId}/download` — the actual streaming endpoint is upstream §3 future work, deferred in this migration. The downstream consumer is expected to combine this `downloadUrl` with the `basePath` themselves (e.g. `${basePath}${downloadUrl}`) — the upstream package made the same choice and the route layer cannot know the full mount path. Every route is also registered against an explicit `createRoute(...)` descriptor so an OpenAPI document generated by `OpenAPIHono.getOpenAPIDocument()` includes the export routes with full Zod-derived schemas.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `post-handler.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implementation of the `POST /exports` handler, extracted from `exports.ts` so the route-registration file stays under the 300-line file-size budget. This is the only non-trivial async path in the route layer: it resolves the provider, persists a fresh `processing` job, fetches data, enforces the row + byte-size caps, runs the exporter, records OpenTelemetry metrics, and returns the completed job + a `downloadUrl`. Every failure mode updates the job record in place before returning, so a follow-up `GET /:id` sees the failure persisted.
|
||||
|
||||
## Features
|
||||
|
||||
- Provider missing → `404` with `EXPORT.PROVIDER_NOT_FOUND` message.
|
||||
- Row cap breach → `400`, `status: "failed"`, `jobsTotal{outcome=failed}` incremented.
|
||||
- Byte cap breach → `400`, `status: "failed"`, `jobsTotal{outcome=failed}` incremented.
|
||||
- Unhandled error → `400`, `status: "failed"`, `error` is `err.message` or `String(err)`.
|
||||
- Happy path → `200`, `status: "completed"`, `jobsTotal{outcome=completed}` + `rowsTotal` + `bytesTotal` + `durationMs` recorded.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Indirect — `runPostExport` is wired by `createExportRoutes(...)`.
|
||||
import { createExportRoutes } from "@suaveplan/api-plugin-export";
|
||||
const routes = createExportRoutes({ providers, jobs, exportConfig });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { runPostExport } from "@suaveplan/api-plugin-export/routes/exports/post-handler";
|
||||
|
||||
// Direct invocation, e.g. from a custom Hono group that wants the same
|
||||
// pipeline but a different OpenAPI shape.
|
||||
app.post("/my-export", async (c) => {
|
||||
const body = MyBodySchema.parse(await c.req.json());
|
||||
const outcome = await runPostExport(deps, body);
|
||||
if (outcome.kind === "not-found") return c.json({ error: outcome.error }, 404);
|
||||
if (outcome.kind === "bad-request") return c.json({ error: outcome.error }, 400);
|
||||
return c.json({ job: outcome.job, downloadUrl: outcome.downloadUrl }, 200);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `runPostExport(deps, body)` | async function | Returns a `PostExportOutcome` discriminated union. |
|
||||
| `PostExportOutcome` | type | `{ kind: "not-found" \| "bad-request"; error } \| { kind: "ok"; job; downloadUrl }`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The handler holds a single `const t0 = Date.now()` because all four outcomes need the `durationMs` recording, and threading a `t0` through three try/catch branches would obscure the control flow. The two cap breaches are explicit `return` statements rather than `throw new ExportError(...)` because the route layer maps cap breaches to `400` while keeping the unhandled-error path open for genuine bugs — duplicating the bookkeeping is intentional to keep each return site self-contained. The unhandled-error branch deliberately reports `err.message` rather than wrapping the value in another `ExportError` — the route is the last stop before the wire, and double-wrapping would hide root causes from log aggregators. The `now()` and `jobIdFactory` injectables let tests assert on a stable `createdAt` / `completedAt` and a stable `jobId` without monkey-patching `Date.now`. `instruments.jobsTotal.add(1, { outcome: ... })` matches the conventions in `@suaveplan/api-plugin-database`'s `pool` instrumentation, so dashboards can reuse the same `outcome` axis across plugins.
|
||||
@@ -0,0 +1,127 @@
|
||||
# `routes/problem-json` — RFC 7807 problem-details renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-plugin-export` returns RFC 7807 `application/problem+json` bodies for every
|
||||
failure surface on its route group — missing job, not-ready job, missing provider,
|
||||
provider-fetch rejection. This module centralises that mapping so each route handler stays
|
||||
focused on its happy path while the renderer translates `ExportError` / plain `Error` /
|
||||
unknown throwable into a stable wire shape.
|
||||
|
||||
The renderer is a peer of the sibling renderers shipped by `@suaveplan/api-plugin-import`,
|
||||
`@suaveplan/api-plugin-database`, and `@suaveplan/api-plugin-cms`. Each api-plugin package
|
||||
owns its own copy so the package-local error-code namespace flows straight through to the
|
||||
`type` URI without cross-package coupling.
|
||||
|
||||
## Features
|
||||
|
||||
- `PROBLEM_JSON_CONTENT_TYPE` constant — exported so consumers can match on the wire
|
||||
content-type without hard-coding the string.
|
||||
- `ProblemDetails` type — the canonical RFC 7807 shape with an open `[key: string]: unknown`
|
||||
bag so callers may extend with custom diagnostic fields.
|
||||
- `renderProblem(err)` — pure function that takes any throwable and returns a
|
||||
`ProblemDetails` object. Easy to unit-test outside Hono.
|
||||
- `respondProblemJson(c, err)` — writes the `Content-Type` header on a Hono-like context
|
||||
and returns the JSON body with the chosen HTTP status. Decoupled from a specific Hono
|
||||
version via the structural `HonoLike` interface.
|
||||
- HTTP-status mapping: `SuaveplanError` instances read `context.statusCode` (must be ≥ 400
|
||||
to be honoured, otherwise coerces to `500`). Plain `Error` and unknown throwables fall
|
||||
through to a generic `500 Internal Server Error`.
|
||||
- `type` URI follows the convention `urn:suaveplan:error:<lowercase-error-code>`; e.g.
|
||||
`EXPORT.JOB_NOT_READY` → `urn:suaveplan:error:export.job_not_ready`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { EXPORT_CODES, ExportError } from "@suaveplan/api-plugin-export";
|
||||
import { respondProblemJson } from "@suaveplan/api-plugin-export/routes/problem-json/problem-json";
|
||||
|
||||
const app = new Hono();
|
||||
app.get("/example", (c) => {
|
||||
return respondProblemJson(
|
||||
c,
|
||||
new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "missing", {
|
||||
context: { statusCode: 404 },
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
The response carries `Content-Type: application/problem+json`, status `404`, and a body of
|
||||
`{ "type": "urn:suaveplan:error:export.job_not_found", "title": "SuaveplanError", "status": 404, "detail": "missing", "code": "EXPORT.JOB_NOT_FOUND" }`.
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the renderer from a discriminated-union outcome so a route handler stays composable:
|
||||
|
||||
```ts
|
||||
import {
|
||||
EXPORT_CODES,
|
||||
ExportError,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
import {
|
||||
renderProblem,
|
||||
respondProblemJson,
|
||||
} from "@suaveplan/api-plugin-export/routes/problem-json/problem-json";
|
||||
|
||||
type Outcome =
|
||||
| { kind: "ok"; payload: string }
|
||||
| { kind: "not-found"; id: string }
|
||||
| { kind: "not-ready"; status: string };
|
||||
|
||||
function respond(c: Hono.Context, outcome: Outcome): Response {
|
||||
switch (outcome.kind) {
|
||||
case "ok":
|
||||
return c.json({ payload: outcome.payload }, 200);
|
||||
case "not-found":
|
||||
return respondProblemJson(
|
||||
c,
|
||||
new ExportError(EXPORT_CODES.JOB_NOT_FOUND, "job missing", {
|
||||
context: { statusCode: 404, id: outcome.id },
|
||||
})
|
||||
);
|
||||
case "not-ready":
|
||||
return respondProblemJson(
|
||||
c,
|
||||
new ExportError(EXPORT_CODES.JOB_NOT_READY, "not ready", {
|
||||
context: { statusCode: 409, status: outcome.status },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For tests, `renderProblem(err)` returns a plain object you can deep-assert against:
|
||||
|
||||
```ts
|
||||
const out = renderProblem(new Error("boom"));
|
||||
expect(out.status).toBe(500);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Signature | Notes |
|
||||
|---|---|---|---|
|
||||
| `PROBLEM_JSON_CONTENT_TYPE` | `const string` | `"application/problem+json"` | RFC 7807 |
|
||||
| `ProblemDetails` | `interface` | `{ type; title; status; detail; code?; [k: string]: unknown }` | Open shape |
|
||||
| `HonoLike` | `interface` | `header(name, value); body(data, status): Response` | Structural |
|
||||
| `renderProblem(err: unknown): ProblemDetails` | function | Pure | Deterministic |
|
||||
| `respondProblemJson(c: HonoLike, err: unknown): Response` | function | Writes header + body | |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `SuaveplanError` detection uses `instanceof`. The export package depends on
|
||||
`@suaveplan/error` directly, so the symbol identity matches across the package boundary.
|
||||
- `context.statusCode` must be a finite number ≥ `400` to be honoured; anything else coerces
|
||||
to `500`. This keeps a misconfigured `statusCode: 200` from leaking through as a
|
||||
success-shaped error body.
|
||||
- The renderer is intentionally synchronous and side-effect-free in its pure form
|
||||
(`renderProblem`). The Hono-coupled `respondProblemJson` writes through `c.header` once
|
||||
and returns the response.
|
||||
- The `type` URN convention matches the sibling renderers across api-plugins so consumers
|
||||
can wire one `type`-to-help-page mapping across the platform.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `export-service.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Service-level orchestration for the export plugin. This module wires the three exporter classes (`CsvExporter`, `JsonExporter`, `XlsxExporter`) behind a single `getExporter(format)` dispatcher, derives default column definitions from raw data via `inferColumns(data)`, and enforces the row + byte-size guards in `runExport(...)`. All guard failures throw `ExportError` carrying one of the four service-level codes (`UNSUPPORTED_FORMAT`, `ROW_LIMIT_EXCEEDED`, `FILE_SIZE_EXCEEDED`, `PROVIDER_FETCH_FAILED`) rather than the raw `Error` upstream used.
|
||||
|
||||
## Features
|
||||
|
||||
- `getExporter(format)` returns a fresh `Exporter` instance per call — no shared state across exports.
|
||||
- `inferColumns(data)` collapses heterogeneous row keys into a stable column order (first-seen).
|
||||
- `runExport(provider, format, columns?, filters?, options?, config?)` is the single async path used by the route handler — every guard lives here.
|
||||
- `DEFAULT_EXPORT_CONFIG` captures the upstream defaults (50 000 rows, 50 MiB).
|
||||
- Failures from `provider.fetch` are wrapped in `ExportError(PROVIDER_FETCH_FAILED)` with the original error preserved as `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
runExport,
|
||||
DEFAULT_EXPORT_CONFIG,
|
||||
type DataProvider,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
|
||||
const provider: DataProvider = {
|
||||
resource: "users",
|
||||
fetch: async () => [{ id: 1 }],
|
||||
};
|
||||
const result = await runExport(provider, "csv", undefined, undefined, undefined, DEFAULT_EXPORT_CONFIG);
|
||||
console.log(result.content);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runExport,
|
||||
type ExportServiceConfig,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
|
||||
const config: ExportServiceConfig = {
|
||||
maxRows: 1_000,
|
||||
maxFileSizeBytes: 1024 * 1024,
|
||||
defaultFormat: "csv",
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runExport(
|
||||
myProvider,
|
||||
"json",
|
||||
[{ key: "id", header: "ID" }],
|
||||
{ active: true },
|
||||
{ pretty: true },
|
||||
config
|
||||
);
|
||||
// …
|
||||
} catch (err) {
|
||||
if (err instanceof ExportError && err.code === "EXPORT.ROW_LIMIT_EXCEEDED") {
|
||||
// tell the user to narrow their filters
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ExportServiceConfig` | interface | `{ maxRows, maxFileSizeBytes, defaultFormat }`. |
|
||||
| `DEFAULT_EXPORT_CONFIG` | const | Upstream defaults: 50 000 rows, 50 MiB, `csv`. |
|
||||
| `getExporter(format)` | function | Throws `EXPORT.UNSUPPORTED_FORMAT` on unknown. |
|
||||
| `inferColumns(data)` | function | Returns `[]` on empty data. |
|
||||
| `runExport(provider, format, columns?, filters?, options?, config?)` | async function | Throws `EXPORT.*` on guard breach. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The two guards run in a specific order: row count is checked *before* the exporter runs so a runaway provider doesn't burn CPU encoding fields the route layer is about to reject. File size is checked *after* encoding because cell-level transforms and XML/JSON overhead can push a "small" row count over the byte cap. The `default:` branch in `getExporter` is reachable only if a caller bypasses the Zod boundary (the schema enumerates `csv|json|xlsx`); the `format as string` cast in the error message is intentional — we want the actual value the caller passed to surface in logs, not the post-narrowing `never`. `provider.fetch` errors are wrapped rather than thrown raw because the route layer needs to distinguish a *bad provider* (502-class) from a *bad request* (400-class), and the `ExportError` code is the contract that drives that branching upstream.
|
||||
@@ -0,0 +1,44 @@
|
||||
# `registry-augmentation.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This file is a type-only `declare module "@suaveplan/api-plugin/service-registry"` block. It adds two slots to the shared `ServiceRegistry` interface so downstream plugins can resolve `ctx.getService("exportProviders")` and `ctx.getService("exportJobs")` with full typing — `Map<string, DataProvider>` and `Map<string, ExportJob>` respectively. The file produces no emitted runtime code; the only side effect is the type augmentation, which is why `src/index.ts` imports it for its side effects (so consumers using the package barrel pick up the slot types automatically).
|
||||
|
||||
## Features
|
||||
|
||||
- Adds `exportProviders: Map<string, DataProvider>` to `ServiceRegistry`.
|
||||
- Adds `exportJobs: Map<string, ExportJob>` to `ServiceRegistry`.
|
||||
- No runtime cost — the file emits no JavaScript.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-export"; // pulls the augmentation through the barrel
|
||||
// Now ctx.getService("exportProviders") is typed Map<string, DataProvider> | undefined
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-export/services/registry-augmentation";
|
||||
|
||||
// In a downstream plugin's onInit:
|
||||
const providers = ctx.getService("exportProviders");
|
||||
if (!providers) throw new Error("export plugin not initialised");
|
||||
providers.set("orders", myOrderProvider);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ServiceRegistry.exportProviders` | augmentation | `Map<string, DataProvider>`. |
|
||||
| `ServiceRegistry.exportJobs` | augmentation | `Map<string, ExportJob>`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The augmentation pattern is identical to `@suaveplan/api-plugin-database`'s `registry-augmentation.ts` — both files exist purely to add slots to the shared registry interface defined by `@suaveplan/api-plugin/service-registry`. Without this file, every call site would have to cast the `getService("exportProviders")` return value, defeating the type safety that the `ServiceRegistry` interface was designed to provide. The two slots cover the two distinct concerns the plugin owns: the *registry* of data providers (writable by every consumer that wants to expose a resource for export) and the *store* of jobs (mutated by the route layer, observable by anyone tracking export progress). Exposing both is a deliberate departure from upstream, which exposed them via `ctx.registerService(...)` at runtime but never declared the corresponding types.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `store/memory/memory-store.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module ships `MemoryExportJobStore`, the in-memory implementation of the `ExportJobStore` contract. It wraps a `Map<string, ExportJob>` so tests, dev setups, and CLI tooling can use the same typed surface as the SurrealDB-backed production store without paying the cost of a real database. The class also doubles as the reference implementation that `SurrealExportStore` is tested against — the contract is defined by what `MemoryExportJobStore` does, and the SurrealDB implementation conforms.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every method of `ExportJobStore`: `get`, `set`, `delete`, `list`, `has`, `clear`.
|
||||
- Optional constructor seed — accepts any `Iterable<readonly [string, ExportJob]>` so it can be initialised from existing data without an extra `.set` loop.
|
||||
- AND-conjunction `list` filters (`status`, `format`, `resource`) with caller-supplied `limit` / `offset` pagination. `total` is the pre-pagination match count so consumers can render `n of total` paginators.
|
||||
- Idempotent `delete` — removing a missing id resolves with `undefined`, matching the SurrealDB-backed store and the original Map semantics.
|
||||
- Every method is `async` even though the underlying `Map` calls are sync; this preserves the uniform `await` discipline required by `ExportJobStore` so callers can swap backends without changing their code.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const store = new MemoryExportJobStore();
|
||||
await store.set("abc", job);
|
||||
const round = await store.get("abc");
|
||||
const { items, total } = await store.list({ limit: 20, offset: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const seeded = new MemoryExportJobStore([
|
||||
["a", existingJobA],
|
||||
["b", existingJobB],
|
||||
]);
|
||||
|
||||
// Filter + paginate
|
||||
const page = await seeded.list({
|
||||
status: "completed",
|
||||
format: "csv",
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
});
|
||||
|
||||
// Drain after a test
|
||||
await seeded.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `MemoryExportJobStore` | class | Constructor: `(seed?: Iterable<readonly [string, ExportJob]>)`. |
|
||||
| `get(id)` | method | Resolves the job or `null`. |
|
||||
| `set(id, job)` | method | Upserts under `id`. |
|
||||
| `delete(id)` | method | Idempotent removal. |
|
||||
| `has(id)` | method | Existence probe. |
|
||||
| `clear()` | method | Drops every entry. |
|
||||
| `list(query)` | method | Filter + paginate + `{ items, total }`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class deliberately exposes nothing beyond the `ExportJobStore` interface — the underlying `Map` is private so callers cannot accidentally mutate it outside the typed contract. The seed parameter is typed as `Iterable<readonly [string, ExportJob]>` to accept both `Map` entries and plain arrays of tuples; this is the only shape that satisfies both the `Map`-from-iterable constructor and the `Array<[K, V]>` literal that test setups typically produce. Filter application is order-independent (every filter narrows the same source array) so the AND-conjunction semantics match the SurrealDB store's `WHERE … AND …` clause output exactly.
|
||||
@@ -0,0 +1,77 @@
|
||||
# `store/surreal/surreal-store.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module ships `SurrealExportStore`, the SurrealDB-backed implementation of `ExportJobStore`. It satisfies the same async surface as the in-memory `MemoryExportJobStore` so production deployments can swap the persistence layer without touching the route handlers, the format writers, or the streaming pipeline. The class consumes the canonical `SurrealAdapter` from `@suaveplan/surrealdb` and uses positional `$p0…$pN` placeholders throughout — matching the canonical pattern established by `@suaveplan/api-plugin-events` repositories.
|
||||
|
||||
## Features
|
||||
|
||||
- Full `ExportJobStore` implementation: `get`, `set`, `delete`, `list`, `has`, `clear`.
|
||||
- Single-table schema (`export_job`) defined by the sibling `surql/tables/export_job.surql` migration.
|
||||
- Custom `tableName` support for tenant-shard deployments (alphanumeric + `_` validated at construction time).
|
||||
- Zod-validated constructor — invalid configs throw `ExportError(EXPORT_CODES.INVALID_CONFIG)` with the `ZodError` chained as `cause`.
|
||||
- Every backend failure (driver crash, schema mismatch, network drop) is wrapped in `ExportError(EXPORT_CODES.JOB_PERSISTENCE_FAILED)` so callers branch on a single stable code.
|
||||
- `list({ ... })` issues two parallel queries — one for the windowed items, one for the `count() … GROUP ALL` total — so a paginated UI gets both numbers in a single round trip's wall-clock latency.
|
||||
- `delete` is idempotent — removing a missing id resolves cleanly, matching SurrealDB's native `DELETE` semantics.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { SurrealExportStore } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const db = new SurrealAdapter({ url: "ws://surreal:8000", namespace: "app", database: "main", username: "root", password: "root" });
|
||||
await db.connect();
|
||||
const store = new SurrealExportStore({ db });
|
||||
|
||||
await store.set("abc", job);
|
||||
const round = await store.get("abc");
|
||||
const { items, total } = await store.list({ limit: 20, offset: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { SurrealExportStore } from "@suaveplan/api-plugin-export";
|
||||
import { EXPORT_CODES, ExportError } from "@suaveplan/api-plugin-export";
|
||||
|
||||
const db = new SurrealAdapter({ url: process.env.SURREAL_URL });
|
||||
await db.connect();
|
||||
const store = new SurrealExportStore({
|
||||
db,
|
||||
tableName: `export_job_${process.env.TENANT_ID}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await store.list({
|
||||
status: "completed",
|
||||
format: "csv",
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
return page;
|
||||
} catch (err) {
|
||||
if (err instanceof ExportError && err.code === EXPORT_CODES.JOB_PERSISTENCE_FAILED) {
|
||||
// backend is unreachable — fall back to a read-only cache
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `SurrealExportStore` | class | Implements `ExportJobStore` over `SurrealAdapter`. |
|
||||
| Constructor | `(config: SurrealExportStoreConfig)` | Zod-validated. `db` is required, `tableName` defaults to `"export_job"`. |
|
||||
| `get`/`set`/`delete`/`has`/`clear`/`list` | methods | Implementations of the `ExportJobStore` contract. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class accepts the adapter behind a `MinimalQueryAdapter` view (`{ query<T>(sql, params?): Promise<T[]> }`) so unit tests can inject a fake without spinning up SurrealDB; the integration test under `tests/integration/surreal-store.test.ts` exercises the real `SurrealAdapter` against a testcontainer. The `set` body uses `UPSERT type::record($p0, $p1) CONTENT $p2 RETURN AFTER` — SurrealDB v3's `UPSERT` creates the row when missing and replaces the content when present, in a single statement, removing the need for a separate `INSERT` / `UPDATE` branch in user space. The surrogate `id` field is stripped from the CONTENT body before write because SurrealDB derives the record id from the `type::thing()` target and round-trips it on read — writing a duplicate would produce a typed-conflict error on schemafull tables. The `list` count uses `GROUP ALL` rather than a row-by-row count to keep the cost at O(1) on indexed columns; the count and items queries run in parallel via `Promise.all` so the wall-clock latency is the max of the two, not the sum.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `store/types/types.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the `ExportJobStore` interface — the formal persistence contract for export jobs. Before this module existed, the store surface was the implicit `Map<string, ExportJob>` shape passed through `ExportRoutesDeps.jobs`. That shape was sync-only and impossible to swap for a real database without bending callers into a `Promise`-wrapping adapter. The interface here lifts the contract out of the route layer so two backends — the in-memory `MemoryExportJobStore` and the SurrealDB-backed `SurrealExportStore` — can satisfy the same surface and consumers can pick the right one for their tier (test, dev, prod).
|
||||
|
||||
## Features
|
||||
|
||||
- Six-method async contract: `get`, `set`, `delete`, `list`, `has`, `clear`.
|
||||
- Caller-supplied pagination — no default `limit`, no default `offset`. The route layer (and any CLI) decides what the user-facing defaults are.
|
||||
- AND-conjunction filtering on `status` / `format` / `resource`. Every implementation conforms; the test suite exercises every combination against both backends.
|
||||
- `list` returns `{ items, total }` so the consumer can render `n of total` without a second round trip.
|
||||
- Idempotent `delete` — removing a missing id resolves with no error, matching the SurrealDB and Map behaviours.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ExportJobStore } from "@suaveplan/api-plugin-export/store/types/types";
|
||||
import { MemoryExportJobStore } from "@suaveplan/api-plugin-export/store/memory/memory-store";
|
||||
|
||||
const store: ExportJobStore = new MemoryExportJobStore();
|
||||
await store.set("abc", job);
|
||||
const round = await store.get("abc");
|
||||
const { items, total } = await store.list({ limit: 20, offset: 0 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ExportJobStore,
|
||||
ExportJobStoreListQuery,
|
||||
} from "@suaveplan/api-plugin-export/store/types/types";
|
||||
import { SurrealExportStore } from "@suaveplan/api-plugin-export/store/surreal/surreal-store";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
const db = new SurrealAdapter({ url: "ws://surreal:8000", namespace: "app", database: "main", username: "root", password: "root" });
|
||||
await db.connect();
|
||||
const store: ExportJobStore = new SurrealExportStore({ db });
|
||||
|
||||
const query: ExportJobStoreListQuery = {
|
||||
status: "completed",
|
||||
format: "csv",
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
const page = await store.list(query);
|
||||
for (const job of page.items) {
|
||||
if (await store.has(job.id)) await store.delete(job.id);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ExportJobStore` | interface | The async persistence contract. Implement to add a new backend. |
|
||||
| `ExportJobStoreListQuery` | interface | `status` / `format` / `resource` are optional filters; `limit` / `offset` are required (no defaults at the store boundary). |
|
||||
| `ExportJobStoreListResult` | interface | `{ items: ExportJob[]; total: number }`. `total` is the pre-pagination match count. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface is intentionally type-only — no runtime artefact ships from `types.ts`. That keeps the boundary stable across tree-shaking (the type erases entirely in production bundles) and lets consumers implement it without pulling in either backend's transitive deps. The async signatures are chosen even for trivially-sync backends (`MemoryExportJobStore` resolves synchronously through a `Promise`) because changing a method from sync to async is a breaking change while the reverse is not — picking async up front matches every realistic backend (SurrealDB, Postgres, S3-blob, Redis) and means the consumer's `await` discipline is uniform regardless of which store is wired in.
|
||||
77
docs/api-stack/api/api-plugin-export/src/types/types.md
Normal file
77
docs/api-stack/api/api-plugin-export/src/types/types.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# `types.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module holds the Zod-fronted public type surface for `@suaveplan/api-plugin-export`: the `ExportFormat` / `ExportStatus` enums, the request/response schemas (`CreateExportJobSchema`, `ExportJobListQuerySchema`, `ExportJobSchema`, `ExportPluginOptionsSchema`), and the function-bearing interfaces (`Exporter`, `DataProvider`) that Zod cannot represent. Every public input boundary on the package is validated through one of these schemas — config validation at plugin construction, body/query validation on the OpenAPIHono routes, and column-definition validation when callers build their own column maps.
|
||||
|
||||
## Features
|
||||
|
||||
- `ExportFormat` / `ExportStatus` declared as `const` objects + literal-union types — works with both `switch` exhaustiveness and `z.enum` validation without duplication.
|
||||
- Single source of truth for option defaults: `ExportPluginOptionsSchema` declares `basePath: "/exports"`, `maxRows: 50_000`, `maxFileSizeBytes: 50 MiB`, `defaultFormat: "csv"`.
|
||||
- `ExportJobListQuerySchema` uses `z.coerce.number()` so HTTP query-string strings are coerced to numbers without bespoke parsing.
|
||||
- Interfaces (`Exporter`, `DataProvider`, `ExportOptions`, `ExportResult`) are kept on this module so the public exports are a single import.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CreateExportJobSchema,
|
||||
type DataProvider,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
|
||||
const body = CreateExportJobSchema.parse({ format: "csv", resource: "users" });
|
||||
|
||||
const provider: DataProvider = {
|
||||
resource: "users",
|
||||
fetch: async () => [{ id: 1, name: "Ada" }],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ColumnDefinitionSchema,
|
||||
ExportPluginOptionsSchema,
|
||||
type Exporter,
|
||||
type ExportResult,
|
||||
} from "@suaveplan/api-plugin-export";
|
||||
|
||||
const cols = ColumnDefinitionSchema.array().parse([
|
||||
{ key: "id", header: "ID" },
|
||||
{ key: "joinedAt", header: "Joined", transform: (v) => new Date(v as string).toISOString() },
|
||||
]);
|
||||
|
||||
const options = ExportPluginOptionsSchema.parse({ maxRows: 1000 });
|
||||
|
||||
class TsvExporter implements Exporter {
|
||||
readonly format = "csv" as const;
|
||||
export(data: Record<string, unknown>[], columns, _opts): ExportResult {
|
||||
// …
|
||||
return { content: "", mimeType: "text/tab-separated-values", filename: "f.tsv", rowCount: data.length, byteSize: 0 };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ExportFormat` / `ExportStatus` | const + type | Literal-union enums. |
|
||||
| `ExportFormatSchema` / `ExportStatusSchema` | Zod | `z.enum([...])`. |
|
||||
| `ColumnDefinitionSchema` | Zod | `{ key, header, transform? }`. |
|
||||
| `CreateExportJobSchema` | Zod | Body schema for `POST /exports`. |
|
||||
| `ExportJobSchema` | Zod | Persistence + response shape. |
|
||||
| `ExportJobListQuerySchema` | Zod | Query-string schema with `z.coerce`. |
|
||||
| `ExportPluginOptionsSchema` | Zod | Plugin construction options. |
|
||||
| `Exporter` | interface | `{ format, export(data, columns, options?): ExportResult }`. |
|
||||
| `DataProvider` | interface | `{ resource, fetch(filters?) }`. |
|
||||
| `ExportOptions` / `ExportResult` | interface | Exporter call/return shapes. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`z.coerce.number()` on the query schema is deliberate: Hono passes query strings through as `string` and the route handler must accept `?limit=20` without a manual `parseInt`. The `transform` field on `ColumnDefinitionSchema` is typed `z.any()` because Zod cannot represent a runtime function in a way that round-trips through `parse()` — the schema's job is to validate the static structure; callers ensure `transform` is callable at construction time. `ExportResult.content` is declared `string` (not `string | Buffer`) because every shipped exporter produces a `string` payload — switching to `Buffer` would force every consumer to handle both branches without any current binary writer to motivate it; the OOXML/ZIP writer in upstream §4 will introduce that branch when it lands.
|
||||
Reference in New Issue
Block a user