@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/idempotency. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# idempotency
|
|
4
|
+
|
|
5
|
+
Same request, same result. Dedupes on request ID.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Idempotency
|
|
10
|
+
|
|
11
|
+
Same request, same result — dedupe on request ID.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- `requestId` prevents duplicate inserts and returns the cached result
|
|
16
|
+
- Custom write handler that owns business defaults
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd samples/recipes/idempotency
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Idempotency Sample — Integration Test
|
|
37
|
+
// Proves: duplicate requestId returns cached result, no double insert
|
|
38
|
+
|
|
39
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
40
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
41
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
42
|
+
import {
|
|
43
|
+
createTestUser,
|
|
44
|
+
setupTestStack,
|
|
45
|
+
type TestStack,
|
|
46
|
+
unsafeCreateEntityTable,
|
|
47
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
48
|
+
import { orderEntity, orderFeature } from "../feature";
|
|
49
|
+
|
|
50
|
+
let stack: TestStack;
|
|
51
|
+
|
|
52
|
+
const customer = createTestUser({ roles: ["Customer"] });
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
stack = await setupTestStack({ features: [orderFeature] });
|
|
56
|
+
await unsafeCreateEntityTable(stack.db, orderEntity);
|
|
57
|
+
await createEventsTable(stack.db);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await stack.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
stack.events.reset();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("idempotent writes", () => {
|
|
69
|
+
test("same requestId returns cached result, no duplicate", async () => {
|
|
70
|
+
const requestId = "order-idem-001";
|
|
71
|
+
|
|
72
|
+
const first = await stack.http.writeOk<SaveContext>(
|
|
73
|
+
"orders:write:order:place",
|
|
74
|
+
{ customerName: "Alice", product: "Widget" },
|
|
75
|
+
customer,
|
|
76
|
+
requestId,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const second = await stack.http.writeOk<SaveContext>(
|
|
80
|
+
"orders:write:order:place",
|
|
81
|
+
{ customerName: "Alice", product: "Widget" },
|
|
82
|
+
customer,
|
|
83
|
+
requestId,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Same ID = cached, not a new record
|
|
87
|
+
expect(second.id).toBe(first.id);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("different requestIds create separate records", async () => {
|
|
91
|
+
const first = await stack.http.writeOk<SaveContext>(
|
|
92
|
+
"orders:write:order:place",
|
|
93
|
+
{ customerName: "Bob", product: "Gadget A" },
|
|
94
|
+
customer,
|
|
95
|
+
"order-a",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const second = await stack.http.writeOk<SaveContext>(
|
|
99
|
+
"orders:write:order:place",
|
|
100
|
+
{ customerName: "Bob", product: "Gadget B" },
|
|
101
|
+
customer,
|
|
102
|
+
"order-b",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(first.id).not.toBe(second.id);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("no requestId = always creates new record", async () => {
|
|
109
|
+
const first = await stack.http.writeOk<SaveContext>(
|
|
110
|
+
"orders:write:order:place",
|
|
111
|
+
{ customerName: "Carol", product: "Same" },
|
|
112
|
+
customer,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const second = await stack.http.writeOk<SaveContext>(
|
|
116
|
+
"orders:write:order:place",
|
|
117
|
+
{ customerName: "Carol", product: "Same" },
|
|
118
|
+
customer,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(first.id).not.toBe(second.id);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### src/feature.ts
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// Idempotency Sample
|
|
130
|
+
// Shows: requestId prevents duplicate inserts, returns cached result.
|
|
131
|
+
// Custom `order:place` handler so we can inject a default status — a generic
|
|
132
|
+
// `order:create` would not know about the business rule "new orders start as
|
|
133
|
+
// pending".
|
|
134
|
+
|
|
135
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
136
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
137
|
+
import { z } from "zod";
|
|
138
|
+
|
|
139
|
+
export const orderEntity = createEntity({
|
|
140
|
+
table: "read_sample_orders",
|
|
141
|
+
fields: {
|
|
142
|
+
customerName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
143
|
+
product: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
144
|
+
status: createTextField({ personal: false, reason: "is_business_data", default: "pending" }),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const orderTable = buildEntityTable("order", orderEntity);
|
|
149
|
+
export const orderFeature = defineFeature("orders", (r) => {
|
|
150
|
+
r.entity("order", orderEntity);
|
|
151
|
+
|
|
152
|
+
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" });
|
|
153
|
+
|
|
154
|
+
r.writeHandler(
|
|
155
|
+
"order:place",
|
|
156
|
+
z.object({
|
|
157
|
+
customerName: z.string().min(1),
|
|
158
|
+
product: z.string().min(1),
|
|
159
|
+
}),
|
|
160
|
+
async (event, ctx) =>
|
|
161
|
+
orderExecutor.create({ ...event.payload, status: "pending" }, event.user, ctx.db),
|
|
162
|
+
{ access: { roles: ["Admin", "Customer"] } },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
r.queryHandler(
|
|
166
|
+
"order:list",
|
|
167
|
+
z.object({ limit: z.number().optional() }),
|
|
168
|
+
async (query, ctx) => orderExecutor.list(query.payload, query.user, ctx.db),
|
|
169
|
+
{
|
|
170
|
+
access: {
|
|
171
|
+
openToAll: {
|
|
172
|
+
reason:
|
|
173
|
+
"any signed-in user may list orders in this recipe; placing an order still requires a role above",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
```
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/inbound-mail-triage. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# inbound-mail-triage
|
|
4
|
+
|
|
5
|
+
Inbound-mail consumer pattern: inbound-mail-foundation + in-memory provider, a worker-lane triage job attached to the ingest handler, verified end-to-end through createAllInOneEntrypoint.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Sample: Inbound-Mail Triage
|
|
10
|
+
|
|
11
|
+
Attach a business process to inbound e-mail: `inbound-mail-foundation` owns transport, dedup and PII — this sample's `mail-triage` feature turns every ingested mail into a triage item via a worker-lane job.
|
|
12
|
+
|
|
13
|
+
## What you learn here
|
|
14
|
+
|
|
15
|
+
- **Consumer pattern**: hook your business process onto the foundation's ingest write-handler with `r.job({ trigger: { on: InboundMailFoundationHandlers.ingestMessage } })` — the job receives the plaintext normalized mail (PII encryption happens inside the handler, after your trigger payload was captured).
|
|
16
|
+
- **Idempotency discipline**: the trigger also fires on idempotent replays (duplicate ingest returns success) — key your side-effects by `providerMessageId` so replays overwrite instead of duplicating.
|
|
17
|
+
- **Provider swap**: the test drives `inbound-provider-inmemory`; production mounts `inbound-provider-imap` (password/app-password, IMAP IDLE) against the same contract — the consumer code does not change.
|
|
18
|
+
|
|
19
|
+
## Feature composition
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
tenant-lifecycle (transitively pulls tenant + config + compliance-profiles)
|
|
23
|
+
inbound-mail-foundation (streams, projections, ingest)
|
|
24
|
+
inbound-provider-inmemory (scriptable provider)
|
|
25
|
+
mail-triage ← this sample (the app-side consumer)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Flow
|
|
29
|
+
|
|
30
|
+
1. `connect-account` creates a mail account (shared mailbox, `ownerUserId = null`).
|
|
31
|
+
2. A mail is ingested — in production the watch-supervisor (IMAP IDLE push) or the reconciliation poll dispatches `ingest-message`; the inline projection materializes `read_inbound_messages` in the same transaction.
|
|
32
|
+
3. The handler-trigger fans the write out to the worker lane; the triage job records `{ from, subject, scope, threadHint }`.
|
|
33
|
+
4. A replay of the same `providerMessageId` reports `duplicate: true`, creates no second row — and the keyed triage store stays at one item.
|
|
34
|
+
|
|
35
|
+
## Tests
|
|
36
|
+
|
|
37
|
+
- E2E through `createAllInOneEntrypoint` (HTTP → dispatcher → BullMQ worker lane, no framework-internal shortcuts): connect → ingest → triage item appears.
|
|
38
|
+
- Replay scenario: duplicate ingest stays idempotent on both the projection and the consumer side.
|
|
39
|
+
|
|
40
|
+
## Source files
|
|
41
|
+
|
|
42
|
+
### src/__tests__/feature.integration.test.ts
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// Inbound-Mail Triage Sample — End-to-End Proof
|
|
46
|
+
//
|
|
47
|
+
// What we're proving (via createAllInOneEntrypoint + BullMQ + Hono — no
|
|
48
|
+
// framework-internal shortcuts):
|
|
49
|
+
// 1. connect-account creates a mail account (event-sourced stream +
|
|
50
|
+
// projection row).
|
|
51
|
+
// 2. ingest-message lands on the HTTP surface (in production the
|
|
52
|
+
// watch-supervisor dispatches it), the inline projection
|
|
53
|
+
// materializes read_inbound_messages, and the handler-trigger
|
|
54
|
+
// fires the worker-lane triage job with the plaintext payload.
|
|
55
|
+
// 3. An idempotent replay (same providerMessageId) does NOT create a
|
|
56
|
+
// second message row — and the keyed triage store keeps exactly
|
|
57
|
+
// one item even though the trigger fires again.
|
|
58
|
+
|
|
59
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
60
|
+
import {
|
|
61
|
+
createComplianceProfilesFeature,
|
|
62
|
+
tenantComplianceProfileEntity,
|
|
63
|
+
} from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
|
|
64
|
+
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
65
|
+
import {
|
|
66
|
+
InboundMailFoundationHandlers,
|
|
67
|
+
inboundMailFoundationFeature,
|
|
68
|
+
inboundMessageEntity,
|
|
69
|
+
mailAccountEntity,
|
|
70
|
+
mailThreadEntity,
|
|
71
|
+
seenMessageEntity,
|
|
72
|
+
syncCursorEntity,
|
|
73
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
74
|
+
import { inboundProviderInMemoryFeature } from "@cosmicdrift/kumiko-bundled-features/inbound-provider-inmemory";
|
|
75
|
+
import { createTenantFeature, tenantEntity } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
76
|
+
import { createTenantLifecycleFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
|
|
77
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
78
|
+
import { createAllInOneEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
79
|
+
import {
|
|
80
|
+
createArchivedStreamsTable,
|
|
81
|
+
createEventsTable,
|
|
82
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
83
|
+
import { createEventConsumerStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
84
|
+
import {
|
|
85
|
+
createTestDb,
|
|
86
|
+
createTestRedis,
|
|
87
|
+
type TestDb,
|
|
88
|
+
type TestRedis,
|
|
89
|
+
TestUsers,
|
|
90
|
+
unsafeCreateEntityTable,
|
|
91
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
92
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
93
|
+
import { createMailTriageFeature, triageInbox } from "../feature";
|
|
94
|
+
|
|
95
|
+
const JWT = "inbound-mail-triage-secret-minimum-32-chars!";
|
|
96
|
+
const adminUser = { ...TestUsers.systemAdmin, roles: ["SystemAdmin", "TenantAdmin"] };
|
|
97
|
+
|
|
98
|
+
let testDb: TestDb;
|
|
99
|
+
let testRedis: TestRedis;
|
|
100
|
+
|
|
101
|
+
beforeAll(async () => {
|
|
102
|
+
[testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
|
|
103
|
+
await createEventsTable(testDb.db);
|
|
104
|
+
await createArchivedStreamsTable(testDb.db);
|
|
105
|
+
await createEventConsumerStateTable(testDb.db);
|
|
106
|
+
// Entity-/Projection-Tables des Flows — der Entrypoint pusht (anders
|
|
107
|
+
// als setupTestStack) keine Tabellen.
|
|
108
|
+
await unsafeCreateEntityTable(testDb.db, tenantEntity);
|
|
109
|
+
await unsafeCreateEntityTable(testDb.db, tenantComplianceProfileEntity);
|
|
110
|
+
await unsafeCreateEntityTable(testDb.db, mailAccountEntity);
|
|
111
|
+
await unsafeCreateEntityTable(testDb.db, inboundMessageEntity);
|
|
112
|
+
await unsafeCreateEntityTable(testDb.db, mailThreadEntity);
|
|
113
|
+
await unsafeCreateEntityTable(testDb.db, syncCursorEntity);
|
|
114
|
+
await unsafeCreateEntityTable(testDb.db, seenMessageEntity);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function ingestPayload(accountId: string) {
|
|
122
|
+
return {
|
|
123
|
+
accountId,
|
|
124
|
+
ownerUserId: null,
|
|
125
|
+
providerName: "inmemory",
|
|
126
|
+
providerMessageId: "uid-triage-1",
|
|
127
|
+
messageIdHeader: "triage-1@example.com",
|
|
128
|
+
providerThreadId: null,
|
|
129
|
+
references: [],
|
|
130
|
+
from: "Mieterin <mieterin@example.com>",
|
|
131
|
+
to: ["inbox@acme.example"],
|
|
132
|
+
cc: [],
|
|
133
|
+
subject: "Heizung defekt",
|
|
134
|
+
snippet: "Die Heizung im 2. OG faellt seit gestern aus …",
|
|
135
|
+
receivedAtIso: "2026-07-10T08:30:00Z",
|
|
136
|
+
bodyRef: "",
|
|
137
|
+
scope: "inbox",
|
|
138
|
+
providerCursor: '{"offset":1}',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("inbound-mail triage sample", () => {
|
|
143
|
+
test("inbound mail → ingest → worker-lane triage job; replay stays idempotent", async () => {
|
|
144
|
+
triageInbox.clear();
|
|
145
|
+
const registry = createRegistry([
|
|
146
|
+
createConfigFeature(),
|
|
147
|
+
createTenantFeature(),
|
|
148
|
+
createComplianceProfilesFeature(),
|
|
149
|
+
createTenantLifecycleFeature(),
|
|
150
|
+
inboundMailFoundationFeature,
|
|
151
|
+
inboundProviderInMemoryFeature,
|
|
152
|
+
createMailTriageFeature(),
|
|
153
|
+
]);
|
|
154
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
155
|
+
const entry = createAllInOneEntrypoint({
|
|
156
|
+
registry,
|
|
157
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
158
|
+
jwtSecret: JWT,
|
|
159
|
+
redisUrl,
|
|
160
|
+
queueNamePrefix: `inbound-mail-triage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
161
|
+
});
|
|
162
|
+
await entry.start();
|
|
163
|
+
try {
|
|
164
|
+
const token = await entry.jwt.sign(adminUser);
|
|
165
|
+
const headers = {
|
|
166
|
+
"Content-Type": "application/json",
|
|
167
|
+
Authorization: `Bearer ${token}`,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// 1. Postfach verbinden (shared: ownerUserId=null).
|
|
171
|
+
const connectRes = await entry.app.request("/api/write", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers,
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
type: InboundMailFoundationHandlers.connectAccount,
|
|
176
|
+
payload: {
|
|
177
|
+
provider: "inmemory",
|
|
178
|
+
authMethod: "password",
|
|
179
|
+
displayName: "Team-Inbox",
|
|
180
|
+
address: "inbox@acme.example",
|
|
181
|
+
scope: "shared",
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
expect(connectRes.status).toBe(200);
|
|
186
|
+
const connectBody = (await connectRes.json()) as {
|
|
187
|
+
data: { accountId: string };
|
|
188
|
+
};
|
|
189
|
+
const accountId = connectBody.data.accountId;
|
|
190
|
+
expect(accountId).toBeDefined();
|
|
191
|
+
|
|
192
|
+
// 2. Mail ingest'en — in Produktion dispatcht das der
|
|
193
|
+
// Watch-Supervisor (IDLE-Push) bzw. der Reconciliation-Poll.
|
|
194
|
+
const ingestRes = await entry.app.request("/api/write", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers,
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
type: InboundMailFoundationHandlers.ingestMessage,
|
|
199
|
+
payload: ingestPayload(accountId),
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
expect(ingestRes.status).toBe(200);
|
|
203
|
+
const ingestBody = (await ingestRes.json()) as {
|
|
204
|
+
data: { duplicate: boolean };
|
|
205
|
+
};
|
|
206
|
+
expect(ingestBody.data.duplicate).toBe(false);
|
|
207
|
+
|
|
208
|
+
// 3. Der Business-Prozess (worker-lane job) feuert asynchron.
|
|
209
|
+
await waitFor(() => {
|
|
210
|
+
expect(triageInbox.size).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
const item = triageInbox.get("uid-triage-1");
|
|
213
|
+
expect(item?.from).toContain("mieterin@example.com");
|
|
214
|
+
expect(item?.subject).toBe("Heizung defekt");
|
|
215
|
+
expect(item?.scope).toBe("inbox");
|
|
216
|
+
expect(item?.threadHint).toBe("triage-1@example.com");
|
|
217
|
+
|
|
218
|
+
// 4. Replay (IDLE-Doppel-Notify / Cursor-Overlap): Handler meldet
|
|
219
|
+
// duplicate, Trigger feuert erneut — der keyed Store bleibt
|
|
220
|
+
// bei genau einem Item.
|
|
221
|
+
const replayRes = await entry.app.request("/api/write", {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers,
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
type: InboundMailFoundationHandlers.ingestMessage,
|
|
226
|
+
payload: ingestPayload(accountId),
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
expect(replayRes.status).toBe(200);
|
|
230
|
+
const replayBody = (await replayRes.json()) as {
|
|
231
|
+
data: { duplicate: boolean };
|
|
232
|
+
};
|
|
233
|
+
expect(replayBody.data.duplicate).toBe(true);
|
|
234
|
+
// Trigger-Nachlauf abwarten, dann Idempotenz prüfen.
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
236
|
+
expect(triageInbox.size).toBe(1);
|
|
237
|
+
} finally {
|
|
238
|
+
await entry.stop();
|
|
239
|
+
}
|
|
240
|
+
}, 30_000);
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### src/feature.ts
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// Inbound-Mail Triage Sample
|
|
248
|
+
//
|
|
249
|
+
// Shows the consumer pattern for `inbound-mail-foundation`: the
|
|
250
|
+
// foundation owns transport, dedup and PII handling — the app attaches
|
|
251
|
+
// its business process to the ingest write-handler via a worker-lane
|
|
252
|
+
// job. Every inbound mail becomes a triage item; a real app would
|
|
253
|
+
// create a ticket, run an AI classification, extract an invoice or
|
|
254
|
+
// draft a reply here.
|
|
255
|
+
//
|
|
256
|
+
// Feature composition (see the test):
|
|
257
|
+
// inbound-mail-foundation — streams, projections, ingest handler
|
|
258
|
+
// inbound-provider-inmemory — scriptable provider (tests/demos)
|
|
259
|
+
// mail-triage (this file) — the app-side consumer
|
|
260
|
+
//
|
|
261
|
+
// The trigger fires AFTER every successful ingest-message write — that
|
|
262
|
+
// includes idempotent replays (duplicate ingest returns success). The
|
|
263
|
+
// triage store is therefore keyed by providerMessageId: replays
|
|
264
|
+
// overwrite instead of duplicating. Key your side-effects the same way.
|
|
265
|
+
|
|
266
|
+
import {
|
|
267
|
+
InboundMailFoundationHandlers,
|
|
268
|
+
type RawInboundMessage,
|
|
269
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
270
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
271
|
+
|
|
272
|
+
export type TriageItem = {
|
|
273
|
+
readonly from: string;
|
|
274
|
+
readonly subject: string;
|
|
275
|
+
readonly scope: string;
|
|
276
|
+
readonly threadHint: string | null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// In-memory store so the integration-test can assert the job ran with
|
|
280
|
+
// the right payload. A real app would do domain writes / AI calls /
|
|
281
|
+
// notifications — the side-effect is opaque to the framework.
|
|
282
|
+
export const triageInbox = new Map<string, TriageItem>();
|
|
283
|
+
|
|
284
|
+
export function createMailTriageFeature(): FeatureDefinition {
|
|
285
|
+
return defineFeature("mail-triage", (r) => {
|
|
286
|
+
r.describe(
|
|
287
|
+
"Sample consumer for inbound-mail-foundation: a worker-lane job triggered by the ingest-message write-handler turns every inbound mail into a triage item.",
|
|
288
|
+
);
|
|
289
|
+
r.requires("inbound-mail-foundation");
|
|
290
|
+
|
|
291
|
+
// The job receives the dispatcher-validated ingest payload — i.e.
|
|
292
|
+
// the PLAINTEXT normalized mail (PII encryption happens inside the
|
|
293
|
+
// handler right before the event append, so consumers on the
|
|
294
|
+
// handler-trigger path never deal with ciphertext).
|
|
295
|
+
r.job(
|
|
296
|
+
"triage-inbound",
|
|
297
|
+
{
|
|
298
|
+
trigger: { on: InboundMailFoundationHandlers.ingestMessage },
|
|
299
|
+
runIn: "worker",
|
|
300
|
+
},
|
|
301
|
+
async (rawPayload) => {
|
|
302
|
+
// Dispatcher-validated ingest payload — one cast at the job boundary
|
|
303
|
+
// to the foundation's own message shape instead of per-field casts.
|
|
304
|
+
const payload = rawPayload as unknown as RawInboundMessage;
|
|
305
|
+
triageInbox.set(payload.providerMessageId, {
|
|
306
|
+
from: payload.from,
|
|
307
|
+
subject: payload.subject,
|
|
308
|
+
scope: payload.scope,
|
|
309
|
+
threadHint: payload.messageIdHeader,
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
```
|