@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.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. 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
+ ```