@cosmicdrift/kumiko-framework 0.393.0 → 0.395.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.
- package/AGENTS.md +1 -1
- package/agents/examples/cross-feature-events.md +87 -0
- package/agents/examples/dashboard-stats.md +141 -2
- package/agents/examples/env-composition.md +7 -7
- package/agents/examples/inbound-mail-triage.md +12 -0
- package/agents/examples/pdf-audit-export.md +103 -50
- package/agents/features.md +17 -15
- package/agents/widgets.md +1 -1
- package/dist/api/entity-read-access.d.ts +1 -0
- package/dist/api/entity-read-access.js +20 -5
- package/dist/api/server-event-dispatcher.js +23 -2
- package/dist/compliance/profiles.js +4 -4
- package/dist/crypto/index.d.ts +1 -0
- package/dist/crypto/index.js +1 -0
- package/dist/crypto/kms-outage.d.ts +8 -0
- package/dist/crypto/kms-outage.js +33 -0
- package/dist/crypto/pii-field-encryption.js +4 -3
- package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
- package/dist/engine/boot-validator/entity-handler.js +2 -16
- package/dist/engine/boot-validator/query-output-columns.js +7 -0
- package/dist/engine/boot-validator/query-refs.js +9 -0
- package/dist/engine/boot-validator/screens-actions.js +19 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
- package/dist/engine/boot-validator/screens-dashboard.js +13 -6
- package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
- package/dist/engine/boot-validator/screens-lists.js +3 -0
- package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
- package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
- package/dist/engine/boot-validator/transaction-phases.js +28 -0
- package/dist/engine/define-handler.d.ts +3 -2
- package/dist/engine/define-handler.js +3 -0
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +1 -17
- package/dist/engine/handler-definition-slots.d.ts +17 -0
- package/dist/engine/handler-definition-slots.js +20 -0
- package/dist/engine/qualified-name.d.ts +2 -1
- package/dist/engine/qualified-name.js +2 -14
- package/dist/engine/registry-facade.js +6 -0
- package/dist/engine/registry-read-models.d.ts +2 -0
- package/dist/engine/registry-read-models.js +24 -0
- package/dist/engine/registry-state.d.ts +4 -0
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/registry-validate.js +12 -1
- package/dist/engine/registry.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
- package/dist/events-cli.d.ts +19 -0
- package/dist/events-cli.js +121 -0
- package/dist/files/bind-pending-file-refs.js +2 -1
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/can-caller-read-file-ref.d.ts +10 -0
- package/dist/files/can-caller-read-file-ref.js +12 -0
- package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
- package/dist/files/create-file-ref-from-bytes.js +7 -2
- package/dist/files/create-file-ref.d.ts +4 -12
- package/dist/files/create-file-ref.js +2 -45
- package/dist/files/default-file-guard.d.ts +4 -0
- package/dist/files/default-file-guard.js +41 -0
- package/dist/files/delete-record-file-refs.d.ts +8 -0
- package/dist/files/delete-record-file-refs.js +20 -0
- package/dist/files/file-routes.d.ts +1 -0
- package/dist/files/file-routes.js +19 -38
- package/dist/files/index.d.ts +6 -2
- package/dist/files/index.js +4 -1
- package/dist/files/record-only-file-entity-types.d.ts +2 -0
- package/dist/files/record-only-file-entity-types.js +22 -0
- package/dist/files/record-read-check.js +11 -0
- package/dist/files/types.d.ts +2 -1
- package/dist/files/types.js +1 -1
- package/dist/files/upload-field-resolution.d.ts +24 -0
- package/dist/files/upload-field-resolution.js +51 -0
- package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
- package/dist/jobs/job-runner-event-triggers.js +64 -0
- package/dist/jobs/job-runner-helpers.d.ts +5 -0
- package/dist/jobs/job-runner-helpers.js +10 -0
- package/dist/jobs/job-runner-types.d.ts +1 -0
- package/dist/jobs/job-runner.js +3 -51
- package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
- package/dist/pipeline/aggregate-type-removal.js +146 -0
- package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
- package/dist/pipeline/catch-up-job-consumer.js +38 -0
- package/dist/pipeline/dispatch-write.js +4 -9
- package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
- package/dist/pipeline/job-trigger-consumer.js +48 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
- package/dist/pipeline/pre-transaction-reservations.js +16 -0
- package/dist/pipeline/system-hooks.d.ts +0 -3
- package/dist/pipeline/system-hooks.js +0 -48
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +11 -3
- package/src/changes.json +48 -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 {
|
|
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 })`, `
|
|
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
|
-
| `
|
|
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["
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
...
|
|
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
|
|
253
|
-
|
|
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 =
|
|
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
|
},
|