PDF Audit Export
A write handler exports tenant data as a PDF and keeps it as a FileRef.
What it shows
Section titled “What it shows”renderTablePdf(@cosmicdrift/kumiko-bundled-features/renderer-pdf) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.- The export is a
beforeTransactionhandler: it reads the rows (reads work in that phase), renders the PDF and callsuploadFileBytesBeforeTransaction(@cosmicdrift/kumiko-framework/files), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes. - The handler itself calls
createFileRefFromUploadedBytes(ctx, event.prepared)and creates thefile_refsrow and thefileRef.createdevent in the transaction. If the transaction does not commit, the phase’sreleasedeletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; the hourlyfiles:job:sweep-orphaned-uploadsjob deletes them once they are older than the grace period (24 hours by default,uploadSweepGraceHours). - The result is a normal FileRef:
GET /api/files/:idserves it withContent-Type: application/pdf, other tenants get 404.
Source
Section titled “Source”Feature entry point: src/feature.ts. The export reads at most 200 inspections per call.
Needs a running Postgres and TEST_DATABASE_URL set (e.g. postgres://postgres:postgres@127.0.0.1:5432/postgres, see demo/.env.example).
cd samples/recipes/pdf-audit-exportbun testSource code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// PDF Audit Export Sample//// A write handler turns tenant data into a PDF and keeps it as a regular// FileRef: renderTablePdf draws the table (pure JS, no browser). The storage// write runs in the beforeTransaction phase, so the transaction holds no pool// connection while the bytes upload; the handler then only creates the// fileRef row from event.prepared. Called from another handler (a nested// write) there is no such phase, so the handler renders and stores inside the// caller's transaction instead.
import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";import { createDateField, createEntity, createEntityExecutor, createSelectField, createTextField, defineFeature, defineWriteHandler, MAX_LIST_LIMIT,} from "@cosmicdrift/kumiko-framework/engine";import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";import { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction,} from "@cosmicdrift/kumiko-framework/files";import * as z from "zod";
export const INSPECTION_RESULTS = ["ok", "defect"] as const;
export const inspectionEntity = createEntity({ table: "read_sample_inspections", fields: { assetName: createTextField({ personal: false, reason: "is_business_data", required: true }), inspectedOn: createDateField({ required: true }), result: createSelectField({ options: INSPECTION_RESULTS, required: true }), inspector: createTextField({ personal: "ref" }), },});
const RESULT_LABELS: Readonly<Record<(typeof INSPECTION_RESULTS)[number], string>> = { ok: "In Ordnung", defect: "Mangel",};
// The renderer takes ready-formatted strings, so date formatting stays with the caller.function formatGermanDate(isoDate: string): string { const [year, month, day] = isoDate.split("-"); return `${day}.${month}.${year}`;}
function toFileNameSegment(text: string): string { return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");}
// One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.const AUDIT_ROW_LIMIT = MAX_LIST_LIMIT - 1;
const access = { access: { roles: ["Admin", "User"] } } as const;
export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => { r.entity({ name: "inspection", ...inspectionEntity }); const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
type AuditPayload = { assetName: string; from: string; to: string }; type ExecutorUser = Parameters<typeof inspectionExecutor.list>[1]; type ExecutorDb = Parameters<typeof inspectionExecutor.list>[2];
async function renderAudit(payload: AuditPayload, user: ExecutorUser, db: ExecutorDb) { const { assetName, from, to } = payload; const inspections = await inspectionExecutor.list( { filters: [ { field: "assetName", op: "eq", value: assetName }, { field: "inspectedOn", op: "gte", value: from }, { field: "inspectedOn", op: "lte", value: to }, ], sort: "inspectedOn", sortDirection: "asc", limit: AUDIT_ROW_LIMIT + 1, }, user, db, ); // A protocol that silently drops inspections would look complete. if (inspections.rows.length > AUDIT_ROW_LIMIT) { return writeFailure( new UnprocessableError("audit_period_too_large", { details: { limit: AUDIT_ROW_LIMIT }, }), ); }
const { pdfBytes } = await renderTablePdf({ title: `Prüfprotokoll ${assetName}`, meta: [ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` }, { label: "Prüfungen", value: String(inspections.rows.length) }, ], columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }], rows: inspections.rows.map((row) => [ formatGermanDate(String(row["inspectedOn"])), RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])], String(row["inspector"] ?? ""), ]), emptyText: "Keine Prüfungen im Zeitraum", footerText: `Prüfbericht ${assetName}`, pageLabel: "Seite {page} von {pages}", });
return { isSuccess: true as const, data: { fileName: `audit-${toFileNameSegment(assetName)}.pdf`, mimeType: "application/pdf", data: pdfBytes, }, }; }
r.writeHandler({ name: "inspection:create", schema: z.object({ assetName: z.string().min(1), inspectedOn: z.iso.date(), result: z.enum(INSPECTION_RESULTS), inspector: z.string().optional(), }), handler: async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db), ...access, });
r.writeHandler( defineWriteHandler({ name: "export-audit", schema: z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }), access: access.access, // Nested in another command there is no phase of its own; the handler then does this work itself. beforeTransactionWhenNested: "skip", beforeTransaction: async (event, ctx) => { const rendered = await renderAudit(event.payload, event.user, ctx.db); if (!rendered.isSuccess) return rendered; return uploadFileBytesBeforeTransaction(ctx, rendered.data); }, handler: async (event, ctx) => { const created = event.prepared !== undefined ? await createFileRefFromUploadedBytes(ctx, event.prepared) : await renderAudit(event.payload, event.user, ctx.db).then((rendered) => rendered.isSuccess ? createFileRefFromBytes(ctx, rendered.data) : rendered, ); if (!created.isSuccess) return created; return { isSuccess: true as const, data: { fileRefId: created.data.id } }; }, }), );});📄 On GitHub: samples/recipes/pdf-audit-export/src/feature.ts