@cosmicdrift/kumiko-framework 0.393.0 → 0.394.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 (92) hide show
  1. package/AGENTS.md +1 -1
  2. package/agents/examples/cross-feature-events.md +87 -0
  3. package/agents/examples/dashboard-stats.md +141 -2
  4. package/agents/examples/env-composition.md +7 -7
  5. package/agents/examples/inbound-mail-triage.md +12 -0
  6. package/agents/examples/pdf-audit-export.md +103 -50
  7. package/agents/features.md +15 -13
  8. package/dist/api/entity-read-access.d.ts +1 -0
  9. package/dist/api/entity-read-access.js +20 -5
  10. package/dist/api/server-event-dispatcher.js +23 -2
  11. package/dist/compliance/profiles.js +4 -4
  12. package/dist/crypto/index.d.ts +1 -0
  13. package/dist/crypto/index.js +1 -0
  14. package/dist/crypto/kms-outage.d.ts +8 -0
  15. package/dist/crypto/kms-outage.js +33 -0
  16. package/dist/crypto/pii-field-encryption.js +4 -3
  17. package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
  18. package/dist/engine/boot-validator/entity-handler.js +2 -16
  19. package/dist/engine/boot-validator/query-output-columns.js +7 -0
  20. package/dist/engine/boot-validator/query-refs.js +9 -0
  21. package/dist/engine/boot-validator/screens-actions.js +19 -0
  22. package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
  23. package/dist/engine/boot-validator/screens-dashboard.js +13 -6
  24. package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
  25. package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
  26. package/dist/engine/boot-validator/screens-lists.js +3 -0
  27. package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
  28. package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
  29. package/dist/engine/boot-validator/transaction-phases.js +28 -0
  30. package/dist/engine/define-handler.d.ts +3 -2
  31. package/dist/engine/define-handler.js +3 -0
  32. package/dist/engine/factories.d.ts +2 -0
  33. package/dist/engine/feature-config-events-jobs.js +3 -0
  34. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  35. package/dist/engine/feature-entity-handlers.js +1 -17
  36. package/dist/engine/handler-definition-slots.d.ts +17 -0
  37. package/dist/engine/handler-definition-slots.js +20 -0
  38. package/dist/engine/qualified-name.d.ts +2 -1
  39. package/dist/engine/qualified-name.js +2 -14
  40. package/dist/engine/registry-facade.js +6 -0
  41. package/dist/engine/registry-read-models.d.ts +2 -0
  42. package/dist/engine/registry-read-models.js +24 -0
  43. package/dist/engine/registry-state.d.ts +4 -0
  44. package/dist/engine/registry-state.js +1 -0
  45. package/dist/engine/registry-validate.js +12 -1
  46. package/dist/engine/registry.js +2 -0
  47. package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
  48. package/dist/events-cli.d.ts +19 -0
  49. package/dist/events-cli.js +121 -0
  50. package/dist/files/bind-pending-file-refs.js +2 -1
  51. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  52. package/dist/files/can-caller-read-file-ref.d.ts +10 -0
  53. package/dist/files/can-caller-read-file-ref.js +12 -0
  54. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  55. package/dist/files/create-file-ref-from-bytes.js +7 -2
  56. package/dist/files/create-file-ref.d.ts +4 -12
  57. package/dist/files/create-file-ref.js +2 -45
  58. package/dist/files/default-file-guard.d.ts +4 -0
  59. package/dist/files/default-file-guard.js +41 -0
  60. package/dist/files/delete-record-file-refs.d.ts +8 -0
  61. package/dist/files/delete-record-file-refs.js +20 -0
  62. package/dist/files/file-routes.d.ts +1 -0
  63. package/dist/files/file-routes.js +9 -36
  64. package/dist/files/index.d.ts +6 -2
  65. package/dist/files/index.js +4 -1
  66. package/dist/files/record-only-file-entity-types.d.ts +2 -0
  67. package/dist/files/record-only-file-entity-types.js +22 -0
  68. package/dist/files/record-read-check.js +11 -0
  69. package/dist/files/types.d.ts +2 -1
  70. package/dist/files/types.js +1 -1
  71. package/dist/files/upload-field-resolution.d.ts +24 -0
  72. package/dist/files/upload-field-resolution.js +51 -0
  73. package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
  74. package/dist/jobs/job-runner-event-triggers.js +64 -0
  75. package/dist/jobs/job-runner-helpers.d.ts +5 -0
  76. package/dist/jobs/job-runner-helpers.js +10 -0
  77. package/dist/jobs/job-runner-types.d.ts +1 -0
  78. package/dist/jobs/job-runner.js +3 -51
  79. package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
  80. package/dist/pipeline/aggregate-type-removal.js +146 -0
  81. package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
  82. package/dist/pipeline/catch-up-job-consumer.js +38 -0
  83. package/dist/pipeline/dispatch-write.js +4 -9
  84. package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
  85. package/dist/pipeline/job-trigger-consumer.js +48 -0
  86. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
  87. package/dist/pipeline/pre-transaction-reservations.js +16 -0
  88. package/dist/pipeline/system-hooks.d.ts +0 -3
  89. package/dist/pipeline/system-hooks.js +0 -48
  90. package/dist/ui-types/index.d.ts +1 -1
  91. package/package.json +11 -3
  92. package/src/changes.json +42 -0
package/AGENTS.md CHANGED
@@ -267,7 +267,7 @@ produces code that compiles and then fails in production.
267
267
 
268
268
  - Integration tests run against the real stack over real HTTP. In an app:
269
269
  `setupAppTestStack([feature])` plus `seedTenant(stack)` from
270
- `@cosmicdrift/kumiko-testing`, then `tenant.api.writeOk` and
270
+ `@cosmicdrift/kumiko-dev-server/testing`, then `tenant.api.writeOk` and
271
271
  `tenant.api.queryOk`. Never assemble a dispatcher yourself
272
272
  (`createTestDispatcher` or similar); it skips the production wiring.
273
273
  - Every test seeds its own tenant. No shared tenant, no global fixtures.
@@ -34,6 +34,13 @@ Three building blocks:
34
34
  external system). With `table`, the apply materializes a persistent
35
35
  read model.
36
36
 
37
+ - **`r.job(name, { trigger: { on: event.name, catchUp: true } }, run)`**
38
+ runs a job per stored event. With `catchUp` the job owns its own
39
+ consumer cursor (`job:<jobQn>`) that starts at the beginning of the
40
+ event store, so a job added to a running app also processes the
41
+ events from before its deploy. Each event enqueues at most one run.
42
+ Without `catchUp` a job only sees events appended after it went live.
43
+
37
44
  ## Flow
38
45
 
39
46
  ```
@@ -69,9 +76,75 @@ The integration test pins three guarantees:
69
76
  ```bash
70
77
  bun kumiko test integration samples/cross-feature-events
71
78
  ```
79
+ 4. **Catch-up job** — `catch-up-job.integration.test.ts` places orders
80
+ before the first dispatcher pass and checks that the `catchUp` job
81
+ invoices each of them exactly once.
72
82
 
73
83
  ## Source files
74
84
 
85
+ ### src/__tests__/catch-up-job.integration.test.ts
86
+
87
+ ```ts
88
+ // The invoice-order job is triggered by the order-placed event with catchUp: it runs once per
89
+ // stored event, including orders that were placed before the dispatcher ever looked at the store.
90
+
91
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
92
+ import { createTestUser } from "@cosmicdrift/kumiko-framework/seeding";
93
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
94
+ import { waitFor } from "@cosmicdrift/kumiko-testing/helpers";
95
+ import {
96
+ setupTestStack,
97
+ type TestStack,
98
+ unsafeCreateEntityTable,
99
+ } from "@cosmicdrift/kumiko-testing/stack";
100
+ import { invoicedOrderIds, orderEntity, pubsubOrderFeature } from "../feature";
101
+
102
+ let stack: TestStack;
103
+
104
+ const customer = createTestUser({ roles: ["Customer"] });
105
+
106
+ beforeAll(async () => {
107
+ stack = await setupTestStack({
108
+ features: [pubsubOrderFeature],
109
+ jobs: { consumerLane: "worker", queueNamePrefix: `cross-feature-events-${generateId()}` },
110
+ });
111
+ await unsafeCreateEntityTable(stack.db, orderEntity);
112
+ });
113
+
114
+ afterAll(async () => {
115
+ await stack.cleanup();
116
+ });
117
+
118
+ async function placeOrder(customerName: string): Promise<string> {
119
+ const data = await stack.http.writeOk(
120
+ "pubsub-orders:write:order:place",
121
+ { customer: customerName, product: "Widget" },
122
+ customer,
123
+ );
124
+ return String(data["id"]);
125
+ }
126
+
127
+ describe("r.job with trigger catchUp", () => {
128
+ test("invoices every placed order exactly once, older ones included", async () => {
129
+ const earlier = [await placeOrder("Erin"), await placeOrder("Frank")];
130
+
131
+ await waitFor(async () => {
132
+ await stack.eventDispatcher?.runOnce();
133
+ await stack.drainJobs();
134
+ expect(invoicedOrderIds.slice().sort()).toEqual(earlier.slice().sort());
135
+ });
136
+
137
+ const later = await placeOrder("Grace");
138
+ await waitFor(async () => {
139
+ await stack.eventDispatcher?.runOnce();
140
+ await stack.drainJobs();
141
+ expect(invoicedOrderIds).toContain(later);
142
+ });
143
+ expect(invoicedOrderIds).toHaveLength(3);
144
+ });
145
+ });
146
+ ```
147
+
75
148
  ### src/__tests__/feature.integration.test.ts
76
149
 
77
150
  ```ts
@@ -254,6 +327,7 @@ export type CapturedEvent = {
254
327
  readonly tenantId: string;
255
328
  };
256
329
  export const capturedEvents: CapturedEvent[] = [];
330
+ export const invoicedOrderIds: string[] = [];
257
331
 
258
332
  // --- Feature ---
259
333
 
@@ -297,6 +371,19 @@ export const pubsubOrderFeature = defineFeature("pubsubOrders", (r) => {
297
371
  access: { roles: ["Admin", "Customer"] },
298
372
  });
299
373
 
374
+ // A job can react to the same event. `catchUp: true` gives the job its own
375
+ // consumer cursor that starts at the beginning of the event store, so a job
376
+ // added to a running app also processes the orders placed before it existed.
377
+ // Without catchUp the job only sees events appended after its deploy.
378
+ r.job(
379
+ "invoice-order",
380
+ { trigger: { on: orderPlaced.name, catchUp: true }, runIn: "worker" },
381
+ async (payload) => {
382
+ const order = orderPlaced.schema.parse(payload);
383
+ invoicedOrderIds.push(order.id);
384
+ },
385
+ );
386
+
300
387
  // Async consumer — Marten-style. Fires after commit, via the event-
301
388
  // dispatcher. No `table` means this MSP is pure side-effect (no state
302
389
  // persisted). Delivery is at-least-once and ordered per consumer.
@@ -28,6 +28,14 @@ query and the fields to read, the query does the counting.
28
28
  `list` with `filter`, `limit: 1` and `totalCount: true`. The total is counted
29
29
  over the whole WHERE clause, so no rows are loaded just to be counted.
30
30
  - **Labels are translation keys**: declared with `r.translations`.
31
+ - **Record-bound dashboard**: `ticket-record-dashboard` binds to a record with `record`
32
+ and embeds the `ticket-card` projectionDetail as a screen panel; the record id
33
+ becomes its entityId.
34
+ - **Gated toolbar button**: `visibleWhen` on a toolbar action asks a flat
35
+ capability query; the button is hidden while it loads or fails.
36
+ - **Fallback by reason**: `fallback: { forbidden, notFound }` renders a screen on
37
+ 403/404 of the gate query; other reasons get the default notice or an error
38
+ banner with retry.
31
39
 
32
40
  ## When to reach for it
33
41
 
@@ -63,7 +71,12 @@ import {
63
71
  type TestStack,
64
72
  unsafeCreateEntityTable,
65
73
  } from "@cosmicdrift/kumiko-testing/stack";
66
- import { TICKET_STATS_QUERY, ticketEntity, ticketFeature } from "../feature";
74
+ import {
75
+ TICKET_CAPABILITIES_QUERY,
76
+ TICKET_STATS_QUERY,
77
+ ticketEntity,
78
+ ticketFeature,
79
+ } from "../feature";
67
80
 
68
81
  let stack: TestStack;
69
82
 
@@ -116,6 +129,42 @@ describe("ticket:stats", () => {
116
129
  expect(result).toEqual({ open: 3, closed: 2, total: 5 });
117
130
  });
118
131
  });
132
+
133
+ describe("record-bound dashboard with a gated detail panel", () => {
134
+ test("the dashboard embeds the detail screen and the gate answers per role", async () => {
135
+ const dashboard = ticketFeature.screens["ticket-record-dashboard"];
136
+ if (dashboard?.type !== "dashboard") throw new Error("ticket-record-dashboard missing");
137
+ expect(dashboard.record).toEqual({ param: "ticketId", payloadKey: "id" });
138
+ expect(dashboard.panels.map((panel) => panel.kind)).toEqual(["screen"]);
139
+
140
+ const card = ticketFeature.screens["ticket-card"];
141
+ expect(card?.fallback).toEqual({ forbidden: "ticket-list", notFound: "ticket-list" });
142
+
143
+ const adminGate = await stack.http.queryOk<{ canClose: boolean }>(
144
+ TICKET_CAPABILITIES_QUERY,
145
+ {},
146
+ admin,
147
+ );
148
+ expect(adminGate.canClose).toBe(true);
149
+ const user = createTestUser({ roles: ["User"], tenantId: testTenantId(31) });
150
+ const userGate = await stack.http.queryOk<{ canClose: boolean }>(
151
+ TICKET_CAPABILITIES_QUERY,
152
+ {},
153
+ user,
154
+ );
155
+ expect(userGate.canClose).toBe(false);
156
+ });
157
+
158
+ test("the toolbar button carries the same gate", () => {
159
+ const list = ticketFeature.screens["ticket-list"];
160
+ if (list?.type !== "projectionList") throw new Error("ticket-list missing");
161
+ expect(list.toolbarActions?.[0]?.visibleWhen).toEqual({
162
+ query: TICKET_CAPABILITIES_QUERY,
163
+ field: "canClose",
164
+ eq: true,
165
+ });
166
+ });
167
+ });
119
168
  ```
120
169
 
121
170
  ### src/feature.ts
@@ -123,7 +172,9 @@ describe("ticket:stats", () => {
123
172
  ```ts
124
173
  // Dashboard Stats Sample
125
174
  // Shows: a dashboard screen with a stat-group and a single stat, backed by one
126
- // query handler that counts in the database.
175
+ // query handler that counts in the database. A second, record-bound dashboard embeds a
176
+ // projectionDetail as a panel, hides a toolbar button behind a capability query, and picks its
177
+ // fallback screen by the reason the gate closed.
127
178
 
128
179
  import {
129
180
  createEntity,
@@ -145,6 +196,7 @@ export const ticketEntity = createEntity({
145
196
  });
146
197
 
147
198
  export const TICKET_STATS_QUERY = "tickets:query:ticket:stats";
199
+ export const TICKET_CAPABILITIES_QUERY = "tickets:query:ticket:capabilities";
148
200
 
149
201
  const readerRoles = { roles: ["Admin", "User"] } as const;
150
202
 
@@ -180,8 +232,51 @@ export const ticketFeature = defineFeature("tickets", (r) => {
180
232
  access: readerRoles,
181
233
  });
182
234
 
235
+ // Gate for the screens below: a flat answer with one boolean the screens test with `eq`.
236
+ r.queryHandler({
237
+ name: "ticket:capabilities",
238
+ schema: z.object({}),
239
+ outputSchema: z.object({ canClose: z.boolean() }),
240
+ handler: async (query) => ({ canClose: query.user.roles.includes("Admin") }),
241
+ access: readerRoles,
242
+ });
243
+
244
+ r.queryHandler({
245
+ name: "ticket:card",
246
+ schema: z.object({ id: z.string() }),
247
+ handler: async (query, ctx) => {
248
+ const page = await ticketExecutor.list(
249
+ { filter: { field: "id", op: "eq", value: query.payload.id }, limit: 1 },
250
+ query.user,
251
+ ctx.db,
252
+ );
253
+ const ticket = page.rows[0];
254
+ return { id: query.payload.id, title: ticket?.title ?? "", status: ticket?.status ?? "" };
255
+ },
256
+ access: readerRoles,
257
+ });
258
+
259
+ r.queryHandler({
260
+ name: "ticket:rows",
261
+ schema: z.object({}),
262
+ handler: async (query, ctx) => {
263
+ const page = await ticketExecutor.list({ limit: 50 }, query.user, ctx.db);
264
+ return { rows: page.rows, nextCursor: null };
265
+ },
266
+ access: readerRoles,
267
+ });
268
+
183
269
  r.translations({
184
270
  keys: {
271
+ "screen:ticket-list.title": { en: "Tickets" },
272
+ "screen:ticket-card.title": { en: "Ticket" },
273
+ "screen:ticket-record-dashboard.title": { en: "Ticket overview" },
274
+ "entity:ticket:field:title": { en: "Title" },
275
+ "entity:ticket:field:status": { en: "Status" },
276
+ "entity:__projection-detail__:field:title": { en: "Title" },
277
+ "entity:__projection-detail__:field:status": { en: "Status" },
278
+ "tickets.toolbar.close": { en: "Close all done" },
279
+ "tickets.dashboard.card": { en: "Ticket" },
185
280
  "tickets.dashboard.group": { en: "Tickets by status" },
186
281
  "tickets.dashboard.open": { en: "Open" },
187
282
  "tickets.dashboard.closed": { en: "Closed" },
@@ -224,5 +319,49 @@ export const ticketFeature = defineFeature("tickets", (r) => {
224
319
  ],
225
320
  access: readerRoles,
226
321
  });
322
+
323
+ // Three additions: a projectionDetail embedded in a record-bound dashboard, a gated toolbar
324
+ // button, and a fallback chosen by reason.
325
+ const gate = { query: TICKET_CAPABILITIES_QUERY, field: "canClose", eq: true } as const;
326
+
327
+ // The list doubles as the fallback target: it has no visibleWhen of its own.
328
+ r.screen({
329
+ id: "ticket-list",
330
+ type: "projectionList",
331
+ query: "tickets:query:ticket:rows",
332
+ columns: ["title", "status"],
333
+ access: readerRoles,
334
+ toolbarActions: [
335
+ {
336
+ kind: "writeHandler",
337
+ id: "close-done",
338
+ label: "tickets.toolbar.close",
339
+ handler: "tickets:write:ticket:update",
340
+ // Live and fail-closed: hidden while the query loads and when it fails.
341
+ visibleWhen: gate,
342
+ },
343
+ ],
344
+ });
345
+
346
+ r.screen({
347
+ id: "ticket-card",
348
+ type: "projectionDetail",
349
+ query: "tickets:query:ticket:card",
350
+ layout: { sections: [{ title: "tickets.dashboard.card", fields: ["title", "status"] }] },
351
+ access: readerRoles,
352
+ visibleWhen: gate,
353
+ // 403 and 404 on the gate query get their own screen; an unmet condition keeps the
354
+ // default notice and any other failure shows an error banner with retry.
355
+ fallback: { forbidden: "ticket-list", notFound: "ticket-list" },
356
+ });
357
+
358
+ // `record` reads the id from the URL; the embedded projectionDetail receives it as its entityId.
359
+ r.screen({
360
+ id: "ticket-record-dashboard",
361
+ type: "dashboard",
362
+ record: { param: "ticketId", payloadKey: "id" },
363
+ panels: [{ kind: "screen", id: "card", screen: "ticket-card" }],
364
+ access: readerRoles,
365
+ });
227
366
  });
228
367
  ```
@@ -18,7 +18,7 @@ instead of keeping a hand-written copy of every feature's variables.
18
18
  `kmsEnvSchema` and `s3EnvSchema`. `composeEnvSchema` collects them and
19
19
  records which feature declared each variable.
20
20
  - **App-level blocks in `extend`**: `buildInfoEnvSchema`, `demoAdminEnvSchema`,
21
- `stripeEnvSchema({ prices })`, `dodoEnvSchema({ prices })` and
21
+ `stripeEnvSchema({ prices })`, `paddleEnvSchema({ prices })` and
22
22
  `createS3EnvSchema({ prefix })` for a
23
23
  second bucket. These belong to no mounted feature, so the app adds them.
24
24
  - **Typed result**: `parseEnv(composed.schema, …)` returns the merged type,
@@ -35,7 +35,7 @@ instead of keeping a hand-written copy of every feature's variables.
35
35
  | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
36
36
  | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
37
37
  | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
38
- | `dodoEnvSchema({ prices })` | `subscription-dodo` | app (`extend`); the feature registers the bridge keys |
38
+ | `paddleEnvSchema({ prices })` | `subscription-paddle` | app (`extend`); the feature registers the bridge keys |
39
39
  | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
40
40
  | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
41
41
  | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
@@ -197,7 +197,7 @@ describe("env composition", () => {
197
197
  expect(sources["S3_BUCKET"]).toBe("file-provider-s3-env");
198
198
  expect(sources["RELEASES_S3_BUCKET"]).toBe("app");
199
199
  expect(sources["STRIPE_PRICE_PRO"]).toBe("app");
200
- expect(sources["DODO_PRODUCT_PRO"]).toBe("app");
200
+ expect(sources["PADDLE_PRICE_PRO"]).toBe("app");
201
201
  });
202
202
 
203
203
  test("parses into a typed env", () => {
@@ -206,7 +206,7 @@ describe("env composition", () => {
206
206
  ...minimalValidValues,
207
207
  S3_BUCKET: "uploads",
208
208
  STRIPE_PRICE_PRO: "price_pro",
209
- DODO_PRODUCT_PRO: "pdt_pro",
209
+ PADDLE_PRICE_PRO: "pri_pro",
210
210
  });
211
211
 
212
212
  const bucket: string | undefined = parsed.S3_BUCKET;
@@ -214,7 +214,7 @@ describe("env composition", () => {
214
214
  expect(bucket).toBe("uploads");
215
215
  expect(publicUrl).toBe("https://app.example.com");
216
216
  expect(parsed.KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).toBe("1");
217
- expect(parsed.DODO_PRODUCT_PRO).toBe("pdt_pro");
217
+ expect(parsed.PADDLE_PRICE_PRO).toBe("pri_pro");
218
218
  });
219
219
 
220
220
  test("reports every missing required variable with its source", () => {
@@ -270,7 +270,7 @@ import {
270
270
  demoAdminEnvSchema,
271
271
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
272
272
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
273
- import { dodoEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
273
+ import { paddleEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-paddle";
274
274
  import { stripeEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
275
275
  import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server/env-schema";
276
276
  import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
@@ -292,7 +292,7 @@ const appEnvSchema = z.object({
292
292
  ...buildInfoEnvSchema.shape,
293
293
  ...demoAdminEnvSchema.shape,
294
294
  ...stripeEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
295
- ...dodoEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
295
+ ...paddleEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
296
296
  ...createS3EnvSchema({ prefix: "RELEASES_S3_" }).shape,
297
297
  APP_PUBLIC_URL: z.url().describe("Public origin of the app, used in mail links."),
298
298
  });
@@ -36,6 +36,14 @@ mail-triage ← this sample (the app-side cons
36
36
 
37
37
  When the provider rejects the stored login, the supervisor sets the account to `auth_error` and stops fetching. With `inbound-provider-imap` mounted, the mail accounts list offers **Reconnect** on that row: the form `mail-account-reconnect-imap` is prefilled with the stored server settings (never the password), `inbound-provider-imap:write:reconnect-mailbox` checks the new login and puts the same account back to `active`. Threads, sync cursor and seen messages stay, so the next poll continues where the account stopped instead of ingesting the mailbox again. The cases are covered in `inbound-provider-imap/__tests__/reconnect-mailbox.integration.test.ts`; they need greenmail, which this sample's in-memory provider does not.
38
38
 
39
+ ## Mail content, quarantine and status
40
+
41
+ Every ingested mail now carries its full text (`bodyText`, at most 32,000 characters, flagged by `bodyTextTruncated`), the original (`.eml`, or `.html` for HTML-only mails) and its attachments as file references; `inbound-mail-foundation:query:message:content` returns them, while `message:list` stays light and never carries the body. Retention removes the mail row but keeps the attachment files. Needs file storage in the app; without it the mail is still imported and the mailbox shows **Unterbrochen** (`file_storage_unavailable`).
42
+
43
+ A mail the provider cannot parse is set aside in quarantine instead of blocking the mailbox: the rest imports, the sync cursor moves on, the account shows `degraded` with `lastError: message_quarantined` and the count, and `acknowledge-quarantine` clears it. Three failed polls in a row (`connection_failed`, `rate_limited`) also mark the mailbox `degraded` until the next success.
44
+
45
+ A disconnected mailbox keeps its account id: `reactivate-account` (or connecting the same OAuth address again) puts it back to `active` with its cursor, so nothing is imported twice, and the mail arrives right away instead of at the next poll. Connecting an address that is already connected is refused with 409.
46
+
39
47
  ## Tests
40
48
 
41
49
  - E2E through `createAllInOneEntrypoint` (HTTP → dispatcher → BullMQ worker lane, no framework-internal shortcuts): connect → ingest → triage item appears.
@@ -70,8 +78,10 @@ import {
70
78
  createInboundMailFoundationFeature,
71
79
  InboundMailFoundationHandlers,
72
80
  inboundMessageEntity,
81
+ ingestFailureEntity,
73
82
  mailAccountEntity,
74
83
  mailThreadEntity,
84
+ quarantineEntity,
75
85
  seenMessageEntity,
76
86
  syncCursorEntity,
77
87
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
@@ -116,6 +126,8 @@ beforeAll(async () => {
116
126
  await unsafeCreateEntityTable(testDb.db, mailThreadEntity);
117
127
  await unsafeCreateEntityTable(testDb.db, syncCursorEntity);
118
128
  await unsafeCreateEntityTable(testDb.db, seenMessageEntity);
129
+ await unsafeCreateEntityTable(testDb.db, quarantineEntity);
130
+ await unsafeCreateEntityTable(testDb.db, ingestFailureEntity);
119
131
  });
120
132
 
121
133
  afterAll(async () => {
@@ -42,7 +42,7 @@ bun test
42
42
  // and its text contains exactly the inspections of the requested period.
43
43
 
44
44
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
45
- import { MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
45
+ import { defineFeature, MAX_LIST_LIMIT } from "@cosmicdrift/kumiko-framework/engine";
46
46
  import {
47
47
  createFilesFeature,
48
48
  createInMemoryFileProvider,
@@ -54,8 +54,22 @@ import {
54
54
  unsafeCreateEntityTable,
55
55
  } from "@cosmicdrift/kumiko-testing/stack";
56
56
  import { extractText, getDocumentProxy } from "unpdf";
57
+ import * as z from "zod";
57
58
  import { inspectionEntity, pdfAuditExportFeature } from "../feature";
58
59
 
60
+ const EXPORT_AUDIT = "pdf-audit-export:write:export-audit";
61
+
62
+ // Another command calling export-audit runs it nested: no beforeTransaction phase of its own.
63
+ const nestedExportFeature = defineFeature("nested-export", (r) => {
64
+ r.requires({ features: ["pdf-audit-export"] });
65
+ r.writeHandler({
66
+ name: "run",
67
+ schema: z.object({ assetName: z.string(), from: z.string(), to: z.string() }),
68
+ access: { roles: ["Admin"] },
69
+ handler: async (event, ctx) => ctx.dispatch.write(EXPORT_AUDIT, event.payload),
70
+ });
71
+ });
72
+
59
73
  let stack: TestStack;
60
74
 
61
75
  const userA = createTestUser({ id: 1, tenantId: testTenantId(1), roles: ["Admin"] });
@@ -63,7 +77,7 @@ const userB = createTestUser({ id: 2, tenantId: testTenantId(2), roles: ["Admin"
63
77
 
64
78
  beforeAll(async () => {
65
79
  stack = await setupTestStack({
66
- features: [createFilesFeature(), pdfAuditExportFeature],
80
+ features: [createFilesFeature(), pdfAuditExportFeature, nestedExportFeature],
67
81
  files: { storageProvider: createInMemoryFileProvider() },
68
82
  });
69
83
  await unsafeCreateEntityTable(stack.db, inspectionEntity);
@@ -163,6 +177,22 @@ describe("export-audit", () => {
163
177
  );
164
178
  expect(err.httpStatus).toBe(422);
165
179
  });
180
+
181
+ test("called from another handler it renders and stores inside the caller's transaction", async () => {
182
+ await addInspection("2026-02-05", "defect", "Zeile-verschachtelt", "Kessel 9");
183
+
184
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
185
+ "nested-export:write:run",
186
+ { assetName: "Kessel 9", from: "2026-02-01", to: "2026-03-31" },
187
+ userA,
188
+ );
189
+
190
+ const res = await download(userA, fileRefId);
191
+ expect(res.status).toBe(200);
192
+ const proxy = await getDocumentProxy(new Uint8Array(await res.arrayBuffer()));
193
+ const { text } = await extractText(proxy, { mergePages: true });
194
+ expect(text).toContain("Zeile-verschachtelt");
195
+ });
166
196
  });
167
197
  ```
168
198
 
@@ -175,7 +205,9 @@ describe("export-audit", () => {
175
205
  // FileRef: renderTablePdf draws the table (pure JS, no browser). The storage
176
206
  // write runs in the beforeTransaction phase, so the transaction holds no pool
177
207
  // connection while the bytes upload; the handler then only creates the
178
- // fileRef row from event.prepared.
208
+ // fileRef row from event.prepared. Called from another handler (a nested
209
+ // write) there is no such phase, so the handler renders and stores inside the
210
+ // caller's transaction instead.
179
211
 
180
212
  import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
181
213
  import {
@@ -190,6 +222,7 @@ import {
190
222
  } from "@cosmicdrift/kumiko-framework/engine";
191
223
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
192
224
  import {
225
+ createFileRefFromBytes,
193
226
  createFileRefFromUploadedBytes,
194
227
  uploadFileBytesBeforeTransaction,
195
228
  } from "@cosmicdrift/kumiko-framework/files";
@@ -231,6 +264,62 @@ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
231
264
  r.entity({ name: "inspection", ...inspectionEntity });
232
265
  const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
233
266
 
267
+ type AuditPayload = { assetName: string; from: string; to: string };
268
+ type ExecutorUser = Parameters<typeof inspectionExecutor.list>[1];
269
+ type ExecutorDb = Parameters<typeof inspectionExecutor.list>[2];
270
+
271
+ async function renderAudit(payload: AuditPayload, user: ExecutorUser, db: ExecutorDb) {
272
+ const { assetName, from, to } = payload;
273
+ const inspections = await inspectionExecutor.list(
274
+ {
275
+ filters: [
276
+ { field: "assetName", op: "eq", value: assetName },
277
+ { field: "inspectedOn", op: "gte", value: from },
278
+ { field: "inspectedOn", op: "lte", value: to },
279
+ ],
280
+ sort: "inspectedOn",
281
+ sortDirection: "asc",
282
+ limit: AUDIT_ROW_LIMIT + 1,
283
+ },
284
+ user,
285
+ db,
286
+ );
287
+ // A protocol that silently drops inspections would look complete.
288
+ if (inspections.rows.length > AUDIT_ROW_LIMIT) {
289
+ return writeFailure(
290
+ new UnprocessableError("audit_period_too_large", {
291
+ details: { limit: AUDIT_ROW_LIMIT },
292
+ }),
293
+ );
294
+ }
295
+
296
+ const { pdfBytes } = await renderTablePdf({
297
+ title: `Prüfprotokoll ${assetName}`,
298
+ meta: [
299
+ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
300
+ { label: "Prüfungen", value: String(inspections.rows.length) },
301
+ ],
302
+ columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
303
+ rows: inspections.rows.map((row) => [
304
+ formatGermanDate(String(row["inspectedOn"])),
305
+ RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
306
+ String(row["inspector"] ?? ""),
307
+ ]),
308
+ emptyText: "Keine Prüfungen im Zeitraum",
309
+ footerText: `Prüfbericht ${assetName}`,
310
+ pageLabel: "Seite {page} von {pages}",
311
+ });
312
+
313
+ return {
314
+ isSuccess: true as const,
315
+ data: {
316
+ fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
317
+ mimeType: "application/pdf",
318
+ data: pdfBytes,
319
+ },
320
+ };
321
+ }
322
+
234
323
  r.writeHandler({
235
324
  name: "inspection:create",
236
325
  schema: z.object({
@@ -248,56 +337,20 @@ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
248
337
  name: "export-audit",
249
338
  schema: z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
250
339
  access: access.access,
340
+ // Nested in another command there is no phase of its own; the handler then does this work itself.
341
+ beforeTransactionWhenNested: "skip",
251
342
  beforeTransaction: async (event, ctx) => {
252
- const { assetName, from, to } = event.payload;
253
- const inspections = await inspectionExecutor.list(
254
- {
255
- filters: [
256
- { field: "assetName", op: "eq", value: assetName },
257
- { field: "inspectedOn", op: "gte", value: from },
258
- { field: "inspectedOn", op: "lte", value: to },
259
- ],
260
- sort: "inspectedOn",
261
- sortDirection: "asc",
262
- limit: AUDIT_ROW_LIMIT + 1,
263
- },
264
- event.user,
265
- ctx.db,
266
- );
267
- // A protocol that silently drops inspections would look complete.
268
- if (inspections.rows.length > AUDIT_ROW_LIMIT) {
269
- return writeFailure(
270
- new UnprocessableError("audit_period_too_large", {
271
- details: { limit: AUDIT_ROW_LIMIT },
272
- }),
273
- );
274
- }
275
-
276
- const { pdfBytes } = await renderTablePdf({
277
- title: `Prüfprotokoll ${assetName}`,
278
- meta: [
279
- { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
280
- { label: "Prüfungen", value: String(inspections.rows.length) },
281
- ],
282
- columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
283
- rows: inspections.rows.map((row) => [
284
- formatGermanDate(String(row["inspectedOn"])),
285
- RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
286
- String(row["inspector"] ?? ""),
287
- ]),
288
- emptyText: "Keine Prüfungen im Zeitraum",
289
- footerText: `Prüfbericht ${assetName}`,
290
- pageLabel: "Seite {page} von {pages}",
291
- });
292
-
293
- return uploadFileBytesBeforeTransaction(ctx, {
294
- fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
295
- mimeType: "application/pdf",
296
- data: pdfBytes,
297
- });
343
+ const rendered = await renderAudit(event.payload, event.user, ctx.db);
344
+ if (!rendered.isSuccess) return rendered;
345
+ return uploadFileBytesBeforeTransaction(ctx, rendered.data);
298
346
  },
299
347
  handler: async (event, ctx) => {
300
- const created = await createFileRefFromUploadedBytes(ctx, event.prepared);
348
+ const created =
349
+ event.prepared !== undefined
350
+ ? await createFileRefFromUploadedBytes(ctx, event.prepared)
351
+ : await renderAudit(event.payload, event.user, ctx.db).then((rendered) =>
352
+ rendered.isSuccess ? createFileRefFromBytes(ctx, rendered.data) : rendered,
353
+ );
301
354
  if (!created.isSuccess) return created;
302
355
  return { isSuccess: true as const, data: { fileRefId: created.data.id } };
303
356
  },