Skip to content

PDF Audit Export

A write handler exports tenant data as a PDF and keeps it as a FileRef.

  • 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 beforeTransaction handler: it reads the rows (reads work in that phase), renders the PDF and calls uploadFileBytesBeforeTransaction (@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 the file_refs row and the fileRef.created event in the transaction. If the transaction does not commit, the phase’s release deletes 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 hourly files:job:sweep-orphaned-uploads job deletes them once they are older than the grace period (24 hours by default, uploadSweepGraceHours).
  • The result is a normal FileRef: GET /api/files/:id serves it with Content-Type: application/pdf, other tenants get 404.

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).

Terminal window
cd samples/recipes/pdf-audit-export
bun test

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