@cosmicdrift/kumiko-framework 0.363.0 → 0.365.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +319 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -0,0 +1,306 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/pdf-audit-export. Do not edit. -->
2
+
3
+ # pdf-audit-export
4
+
5
+ Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and uploads the bytes before the transaction (uploadFileBytesBeforeTransaction) and stores them as a FileRef (createFileRefFromUploadedBytes).
6
+
7
+ ## README
8
+
9
+ # PDF Audit Export
10
+
11
+ A write handler exports tenant data as a PDF and keeps it as a FileRef.
12
+
13
+ ## What it shows
14
+
15
+ - `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.
16
+ - 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.
17
+ - 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; nothing sweeps them.
18
+ - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
19
+
20
+ ## Source
21
+
22
+ Feature entry point: `src/feature.ts`. The export reads at most 200 inspections per call.
23
+
24
+ ## Tests
25
+
26
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
27
+
28
+ ```bash
29
+ cd samples/recipes/pdf-audit-export
30
+ bun test
31
+ ```
32
+
33
+ ## Source files
34
+
35
+ ### src/__tests__/feature.integration.test.ts
36
+
37
+ ```ts
38
+ // PDF Audit Export Sample — Integration Test
39
+ //
40
+ // Proves the whole path through real HTTP calls: inspections are written,
41
+ // the export handler renders and stores a PDF, GET /api/files/:id serves it
42
+ // and its text contains exactly the inspections of the requested period.
43
+
44
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
45
+ import {
46
+ createFilesFeature,
47
+ createInMemoryFileProvider,
48
+ } from "@cosmicdrift/kumiko-framework/files";
49
+ import {
50
+ createTestUser,
51
+ setupTestStack,
52
+ type TestStack,
53
+ testTenantId,
54
+ unsafeCreateEntityTable,
55
+ } from "@cosmicdrift/kumiko-framework/stack";
56
+ import { extractText, getDocumentProxy } from "unpdf";
57
+ import { inspectionEntity, pdfAuditExportFeature } from "../feature";
58
+
59
+ let stack: TestStack;
60
+
61
+ const userA = createTestUser({ id: 1, tenantId: testTenantId(1), roles: ["Admin"] });
62
+ const userB = createTestUser({ id: 2, tenantId: testTenantId(2), roles: ["Admin"] });
63
+
64
+ beforeAll(async () => {
65
+ stack = await setupTestStack({
66
+ features: [createFilesFeature(), pdfAuditExportFeature],
67
+ files: { storageProvider: createInMemoryFileProvider() },
68
+ });
69
+ await unsafeCreateEntityTable(stack.db, inspectionEntity);
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await stack.cleanup();
74
+ });
75
+
76
+ async function addInspection(
77
+ inspectedOn: string,
78
+ result: "ok" | "defect",
79
+ inspector: string,
80
+ assetName = "Kessel 3",
81
+ ): Promise<void> {
82
+ await stack.http.writeOk(
83
+ "pdf-audit-export:write:inspection:create",
84
+ { assetName, inspectedOn, result, inspector },
85
+ userA,
86
+ );
87
+ }
88
+
89
+ async function download(user: typeof userA, fileRefId: string): Promise<Response> {
90
+ const token = await stack.jwt.sign(user);
91
+ return stack.app.request(`/api/files/${fileRefId}`, {
92
+ headers: { Authorization: `Bearer ${token}` },
93
+ });
94
+ }
95
+
96
+ describe("export-audit", () => {
97
+ test("stores a PDF FileRef containing only the inspections of the period and asset", async () => {
98
+ await addInspection("2026-01-10", "ok", "Zeile-vor-Zeitraum");
99
+ await addInspection("2026-02-05", "defect", "Zeile-im-Zeitraum-Eins");
100
+ await addInspection("2026-03-20", "ok", "Zeile-im-Zeitraum-Zwei");
101
+ await addInspection("2026-05-02", "ok", "Zeile-nach-Zeitraum");
102
+ await addInspection("2026-02-06", "ok", "Zeile-anderer-Asset", "Pumpe 7");
103
+
104
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
105
+ "pdf-audit-export:write:export-audit",
106
+ { assetName: "Kessel 3", from: "2026-02-01", to: "2026-03-31" },
107
+ userA,
108
+ );
109
+
110
+ const res = await download(userA, fileRefId);
111
+ expect(res.status).toBe(200);
112
+ expect(res.headers.get("Content-Type")).toBe("application/pdf");
113
+ const proxy = await getDocumentProxy(new Uint8Array(await res.arrayBuffer()));
114
+ const { text } = await extractText(proxy, { mergePages: true });
115
+
116
+ expect(text).toContain("Prüfprotokoll Kessel 3");
117
+ expect(text).toContain("05.02.2026");
118
+ expect(text).toContain("Mangel");
119
+ expect(text).toContain("Zeile-im-Zeitraum-Eins");
120
+ expect(text).toContain("20.03.2026");
121
+ expect(text).toContain("Zeile-im-Zeitraum-Zwei");
122
+ expect(text).not.toContain("Zeile-vor-Zeitraum");
123
+ expect(text).not.toContain("Zeile-nach-Zeitraum");
124
+ expect(text).not.toContain("Zeile-anderer-Asset");
125
+ expect(text).toContain("Seite 1 von 1");
126
+ });
127
+
128
+ test("a user of another tenant gets 404 for the exported file", async () => {
129
+ await addInspection("2026-02-05", "ok", "Zeile-Tenant-A");
130
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
131
+ "pdf-audit-export:write:export-audit",
132
+ { assetName: "Kessel 3", from: "2026-02-01", to: "2026-03-31" },
133
+ userA,
134
+ );
135
+
136
+ expect((await download(userB, fileRefId)).status).toBe(404);
137
+ });
138
+
139
+ test("a period without inspections still yields a PDF with the empty hint", async () => {
140
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
141
+ "pdf-audit-export:write:export-audit",
142
+ { assetName: "Unbekannte Anlage", from: "2026-02-01", to: "2026-03-31" },
143
+ userA,
144
+ );
145
+
146
+ const res = await download(userA, fileRefId);
147
+ const proxy = await getDocumentProxy(new Uint8Array(await res.arrayBuffer()));
148
+ const { text } = await extractText(proxy, { mergePages: true });
149
+ expect(text).toContain("Keine Prüfungen im Zeitraum");
150
+ });
151
+
152
+ test("a period with more inspections than the export holds is rejected instead of cut off", async () => {
153
+ const assetName = "Großanlage";
154
+ for (let day = 0; day < 101; day += 1) {
155
+ const inspectedOn = new Date(Date.UTC(2025, 0, 1 + day)).toISOString().slice(0, 10);
156
+ await addInspection(inspectedOn, "ok", `Prüfer-${day}`, assetName);
157
+ }
158
+
159
+ const err = await stack.http.writeErr(
160
+ "pdf-audit-export:write:export-audit",
161
+ { assetName, from: "2025-01-01", to: "2025-12-31" },
162
+ userA,
163
+ );
164
+ expect(err.httpStatus).toBe(422);
165
+ });
166
+ });
167
+ ```
168
+
169
+ ### src/feature.ts
170
+
171
+ ```ts
172
+ // PDF Audit Export Sample
173
+ //
174
+ // A write handler turns tenant data into a PDF and keeps it as a regular
175
+ // FileRef: renderTablePdf draws the table (pure JS, no browser). The storage
176
+ // write runs in the beforeTransaction phase, so the transaction holds no pool
177
+ // connection while the bytes upload; the handler then only creates the
178
+ // fileRef row from event.prepared.
179
+
180
+ import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
181
+ import {
182
+ createDateField,
183
+ createEntity,
184
+ createEntityExecutor,
185
+ createSelectField,
186
+ createTextField,
187
+ defineFeature,
188
+ defineWriteHandler,
189
+ } from "@cosmicdrift/kumiko-framework/engine";
190
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
191
+ import {
192
+ createFileRefFromUploadedBytes,
193
+ uploadFileBytesBeforeTransaction,
194
+ } from "@cosmicdrift/kumiko-framework/files";
195
+ import * as z from "zod";
196
+
197
+ export const INSPECTION_RESULTS = ["ok", "defect"] as const;
198
+
199
+ export const inspectionEntity = createEntity({
200
+ table: "read_sample_inspections",
201
+ fields: {
202
+ assetName: createTextField({ personal: false, reason: "is_business_data", required: true }),
203
+ inspectedOn: createDateField({ required: true }),
204
+ result: createSelectField({ options: INSPECTION_RESULTS, required: true }),
205
+ inspector: createTextField({ personal: "ref" }),
206
+ },
207
+ });
208
+
209
+ const RESULT_LABELS: Readonly<Record<(typeof INSPECTION_RESULTS)[number], string>> = {
210
+ ok: "In Ordnung",
211
+ defect: "Mangel",
212
+ };
213
+
214
+ // The renderer takes ready-formatted strings, so date formatting stays with the caller.
215
+ function formatGermanDate(isoDate: string): string {
216
+ const [year, month, day] = isoDate.split("-");
217
+ return `${day}.${month}.${year}`;
218
+ }
219
+
220
+ function toFileNameSegment(text: string): string {
221
+ return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
222
+ }
223
+
224
+ // One row above the limit tells "exactly full" from "cut off"; entity lists cap at 200 rows.
225
+ const AUDIT_ROW_LIMIT = 100;
226
+
227
+ const access = { access: { roles: ["Admin", "User"] } } as const;
228
+
229
+ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
230
+ r.entity("inspection", inspectionEntity);
231
+ const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
232
+
233
+ r.writeHandler(
234
+ "inspection:create",
235
+ z.object({
236
+ assetName: z.string().min(1),
237
+ inspectedOn: z.iso.date(),
238
+ result: z.enum(INSPECTION_RESULTS),
239
+ inspector: z.string().optional(),
240
+ }),
241
+ async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db),
242
+ access,
243
+ );
244
+
245
+ r.writeHandler(
246
+ defineWriteHandler({
247
+ name: "export-audit",
248
+ schema: z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
249
+ access: access.access,
250
+ beforeTransaction: async (event, ctx) => {
251
+ const { assetName, from, to } = event.payload;
252
+ const inspections = await inspectionExecutor.list(
253
+ {
254
+ filters: [
255
+ { field: "assetName", op: "eq", value: assetName },
256
+ { field: "inspectedOn", op: "gte", value: from },
257
+ { field: "inspectedOn", op: "lte", value: to },
258
+ ],
259
+ sort: "inspectedOn",
260
+ sortDirection: "asc",
261
+ limit: AUDIT_ROW_LIMIT + 1,
262
+ },
263
+ event.user,
264
+ ctx.db,
265
+ );
266
+ // A protocol that silently drops inspections would look complete.
267
+ if (inspections.rows.length > AUDIT_ROW_LIMIT) {
268
+ return writeFailure(
269
+ new UnprocessableError("audit_period_too_large", {
270
+ details: { limit: AUDIT_ROW_LIMIT },
271
+ }),
272
+ );
273
+ }
274
+
275
+ const { pdfBytes } = await renderTablePdf({
276
+ title: `Prüfprotokoll ${assetName}`,
277
+ meta: [
278
+ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
279
+ { label: "Prüfungen", value: String(inspections.rows.length) },
280
+ ],
281
+ columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
282
+ rows: inspections.rows.map((row) => [
283
+ formatGermanDate(String(row["inspectedOn"])),
284
+ RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
285
+ String(row["inspector"] ?? ""),
286
+ ]),
287
+ emptyText: "Keine Prüfungen im Zeitraum",
288
+ footerText: `Prüfbericht ${assetName}`,
289
+ pageLabel: "Seite {page} von {pages}",
290
+ });
291
+
292
+ return uploadFileBytesBeforeTransaction(ctx, {
293
+ fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
294
+ mimeType: "application/pdf",
295
+ data: pdfBytes,
296
+ });
297
+ },
298
+ handler: async (event, ctx) => {
299
+ const created = await createFileRefFromUploadedBytes(ctx, event.prepared);
300
+ if (!created.isSuccess) return created;
301
+ return { isSuccess: true as const, data: { fileRefId: created.data.id } };
302
+ },
303
+ }),
304
+ );
305
+ });
306
+ ```